CONTRIBUTING.md
Rama main · réplica de lectura del repositorio original.
# Contributing
Curfew is a personal habit tracker with evidence. Read `CLAUDE.md` first: it
carries the invariants, the conventions and the voice, and breaking an invariant
is a bug even when the tests pass.
## Getting it running
```
bun install
bun run local # docker Postgres, mock data, no sign-in
bun run test # domain core, no database
bun run typecheck
```
Three environment files, all gitignored, all carrying the same keys in the same
order. `.env.example` is the key list. A key missing from one does not fall back
to a default: it leaks in from `.env.local`, which Next loads on every
non-production run, so add a key to one and add it to all three.
| File | Database | Used by |
|---|---|---|
| `.env.local` | docker Postgres, `LOCAL_MODE=1` | `bun run local`, `local:*` |
| `.env.preview` | `curfew-apac-dev` | `bun run dev`, `migrate`, `verify` |
| `.env.production` | the live project | `migrate:production` |
## Adding an activity type
This is the walkthrough, and it is deliberately short. **Adding a type never
edits the engine.** If you find yourself changing anything outside your own
file, the interface got something wrong; say so rather than working around it.
An activity type is one file, no React, under `src/domain/<key>/index.ts`.
### 1. Write the module
```ts
export const pushupsActivity: ActivityType<PushupsConfig, PushupsEvidence> = {
key: "pushups",
name: "Pushups",
description: "A set count, every day you say",
icon: "gym",
defaults: { schedule: EVERY_DAY, dayBoundary: "midnight", grace: 2,
config: { target: 50 } },
configSchema, evidenceSchema,
evidence: { level: "optional", source: "live", detail: "Live camera." },
checkin: { kind: "number" },
chart: { kind: "numeric", heading: "PUSHUPS A DAY",
valueField: "done", targetField: "target" },
fields(config) { ... }, // what the configure screen draws
steps(config) { ... }, // what can be checked in, and when
windows(config, periodStart, timezone) { ... },
evaluate(input) { ... }, // did the period pass
};
```
What each piece decides:
- **`fields`** is what the configure screen draws. `configSchema` says what is
*valid*; this says what it *looks like*. Four control kinds exist and adding a
fifth is an engine change, so try to want one of them.
- **`steps`** is what the check-in screen offers, its own words, and any numbers
it asks for. `repeats: true` means the day can take several.
- **`windows`** resolves those steps to absolute instants for one period. Use
`oneWindow` unless your type genuinely has several.
- **`evaluate`** returns `{ passed, detail }`. `detail` is yours; nothing
outside your module ever reads it.
- **`hint`** is optional, and it is the line under the fields on the check-in
screen. Only your module can write "1180 so far today. The limit is 2000."
- **`countsNow`** is optional and answers one question: would another press of
this step change anything right now? Default yes. Gym says no once today's
session is logged, because it counts one a calendar day. The engine uses it
to hide the button and refuse the press, so a control that cannot do anything
is never offered. If your `evaluate` ignores some check-ins, this is how you
say so.
- **`chart`** names which of four charts the stats screen draws, and nothing
else. `kind` is one of the four; adding a fifth is an engine change.
`heading` is the label over it, in your words and in caps, because
"LAST 30 PERIODS" tells a reader nothing about what the bars are. A
`numeric` or `weekly` chart also names `valueField` and `targetField`: which
keys of *your own* `detail` carry the plotted number and the line it is
measured against. The engine used to guess through a chain of field names,
which is the engine knowing what your type means. It no longer does, so a
chart with an unnamed field simply plots zero, and `registry.test.ts` fails
the type before it ships.
### 2. Register it
```ts
// src/domain/index.ts
register(pushupsActivity);
```
### 3. Sync and enable it
```
bun run sync:activities
```
That inserts a row in `activity_types`, **disabled**. A type is only offered
once an admin enables it under Controls, which is what stops a half-finished
type reaching anyone. CI runs `sync:activities --check` and fails if a
registered module has no row.
### 4. Test it
Add cases to `src/domain/catalog.test.ts`, and if your type says anything on a
screen, to `configure.test.ts` or `checkin.test.ts`, which assert the words
verbatim against the mocks.
The one thing that will fail: `src/domain/no-db-imports.test.ts`. Domain code
never imports the database. If your type needs a fact that is not in its config
or its check-ins, that is a design problem worth raising.
## The rules that are not style
`CLAUDE.md` has the full list. The four that catch people out:
- **`events` is the only source of truth.** Everything else is rebuildable, and
`bun run verify` proves it. If you need a fact that is not derivable from
events, say so rather than adding mutable state.
- **Scoring reads only `checkin.*` events.** Never sessions, never `last_seen`.
If ambient telemetry could affect a fine, the app rewards not opening it.
- **Resolve config as it stood on the period being scored**, never as it stands
now. This applies to `/verify` too, or it reports all history as drift after
any config change.
- **Nothing outside a module knows what a type means.** No `switch` on a type
key outside the registry.
## Before you open a PR
```
bun run typecheck
bun run lint # --max-warnings=0, so a warning is a failure
bun run test
bun run build
bun run verify # no drift against the preview database
bun run break-in # the security round; exits non-zero if anything gives
bun run browser # every screen, against a running server
```
`bun run browser` needs `bun run local:seed` and `bun run local` first, and a
fixture seeded on a different calendar day is refused rather than run: every
fixture anchored on the real clock calls the day it was seeded "today".
If you touched anything a signed-out visitor loads, or `src/middleware.ts`, run
`bun run check:signin -- --http=<a deployment>`. It is the only check that
exercises the sign-in gate at all: LOCAL_MODE makes the middleware stand aside
in every job that serves the app, so the gate is the one path the suite never
takes, and the sign-in page's own photographs and font have both been broken by
it.
If you touched a screen, open it beside its artboard in `.design/` and tick its
row in `.planning/v4/SCREENS.md` in the same commit. If the screen and the mock
disagree, that is drift: fix the code, or amend the mock and the row
deliberately. A screenshot test would only lock in whatever was built, which is
why the gate is a person.
## Voice
Applies to commits, comments and every string that ships. No em-dashes. No
emoji. Curfew is a clerk, not a coach: it states facts and consequences.
"Window closes 7:45 AM. Miss it and today does not count." is the register.
## The licence
Curfew is under the [PolyForm Noncommercial License 1.0.0](./LICENSE). Read it,
fork it, change it, run your own copy for yourself, and send a pull request:
all of that is allowed. Using it for a commercial purpose is not, which
includes running it as a product or a service for other people.
By opening a pull request you license your contribution under the same terms.
The licence covers the code and not the idea. Habit tracking with photo
evidence is not something a licence can reserve, and anybody is free to build
their own.