# Clockspan — agent guide ## What this is A self-hosted, single-day **focus sheet** for working through a workday with ADHD: a punch-style timeclock (lunch deadline, end of day, celebration), top priorities (default three, with a nudge when the list grows), a focus timer that logs what was done and for which priority, a retrospective card (plan vs. log, a "why" note, a nudge before clock-out), a week / month / quarter review, and alarms for lunch, clock-out and the second meal period. "Overtime approved" silences the clock-out alarm only. Every day is persisted; old days can be pruned. Data is **per user**; auth is optional (`AUTH_MODE=none | local | oidc`). One Docker container, SQLite on `/data`. Mobile-first PWA. Meal-period defaults follow California rules. The README has the user-facing description. ## Stack & versions - Node **24** (Active LTS). `nvm use 24` locally; `node:24-alpine` in Docker. - Frontend: React 19 + TypeScript 7 (the native `tsc`) + Vite 8. Drag/drop: `@dnd-kit/sortable`. Punch time entry: `react-aria` + `react-stately` (`useTimeField`, segments) with `@internationalized/date`. No router lib — the date and view live in the URL query (`hooks/useRoute.ts`; today is `date: null`, so a sheet left open over midnight moves to the new day). No CSS framework. - Backend: Express 5 (ESM, `NodeNext`, imports use `.js` extensions), `better-sqlite3` (native), `openid-client` v6 for OIDC, `cookie` for cookie parsing. Passwords: `node:crypto` scrypt (async). - Tests: Vitest 5. Lint: oxlint (`.oxlintrc.json`: correctness + typescript + react-hooks + jsx-a11y rules, syntax level only; it parses TS itself, which is what lets TypeScript be 7). CI: `.github/workflows/ci.yml` runs typecheck, lint, test, build on every PR and push, and on a PR also builds and boots the image (`image-smoke`, never pushed); on `main` it builds, boots (the same `scripts/smoke-image.sh`) and then publishes the `edge` image, and a commit that changed `package.json`'s version (the merged bump PR) also gets the versioned image, the tag and the GitHub Release. - One `package.json` for both sides; `tsconfig.json` = client + shared, `tsconfig.server.json` = server + shared (`rootDir: .`, so `dist/server` and `dist/shared`). `dependencies` is only what the server loads at run time (express, better-sqlite3, cookie, openid-client); the client's libraries (React, React Aria, dnd-kit, …) are bundled by Vite at build time and live in `devDependencies`, so the image's `npm prune --omit=dev` leaves them out. ## Repo map ``` shared/ Imported by BOTH sides (always with a `.js` suffix); pure data + functions settings.ts Settings type, DEFAULT_SETTINGS, CARD_IDS, MAX_PRIORITIES, retention bounds sounds.ts the sound catalog: SOUNDS (id, label, kind none|synth|clip), SOUND_IDS, CLIP_IDS, SOUND_EVENTS (timer, lead, due, overdue, dayDone, priorityDone) api.ts the wire types (Day, Session, Punch, Priority, DaySummary, PruneInfo, AuthInfo…); server JSON builders are annotated with them, the client reads them dates.ts date keys: dateKey/todayKey/parseDateKey/isValidDateKey/addDays/endOfDay, startOfWeek/Month/Quarter, addMonths (+ dates.test.ts) timer.ts pause-aware session timing: activeMs(session, until) (the span minus its pauses; stops at pausedAt), plannedEndAt(session, now) (moves while paused) (+ timer.test.ts) server/ Express API → dist/server (tsc) index.ts boot: load config, warn if AUTH_MODE=none, open DB, listen, SIGTERM app.ts createApp(): trust proxy, securityHeaders, /api/health, resolveUser, auth routers, data routers behind requireAuth, static dist/client + SPA fallback security.ts the ONLY place response headers (CSP, nosniff, frame, referrer, HSTS, no-store on /api) are set; also rejectCrossSiteWrites (403 for a non-GET /api request marked Sec-Fetch-Site cross-site/same-site) config.ts env parsing; throws with a clear message on bad/missing config db.ts open + pragmas (WAL, foreign_keys), append-only MIGRATIONS, default user retention.ts old-day cleanup: cutoffKey, countDays, pruneDays, runRetention (all users, user setting capped by RETENTION_DAYS), scheduleRetention (30 s + 6 h) cli.ts `reset-password [password]` dev/seed.ts seedDatabase(db, opts) → SeedManifest; ensureLocalUsers(). Dev + tests only dev/seed-cli.ts `npm run seed` (flags: --fresh --running --days N --quarter --today --now --auth --sessions) dev/harness.ts startTestApp(): real app on an in-memory DB + fetch client w/ cookie jar **/*.test.ts route/auth/db/header tests beside the code they cover (Vitest, via the harness) auth/session.ts cookie session (token hashed in DB, sliding 30d expiry), cookieOptions(), revokeOtherSessions() auth/password.ts async scrypt hash/verify, DUMMY_HASH, username/password validation auth/middleware.ts resolveUser / requireAuth / requireAdmin / currentUser(req) auth/local.ts /api/auth: me, setup, login (rate-limited), logout, password, users (admin) auth/oidc.ts /api/auth/{me,logout} + /auth/{login,callback}; lazy discovery w/ retry routes/shared.ts requireDate + dateParam, findDay/ensureDay, UID_RE, SessionRow → JSON routes/days.ts GET /days/range?from&to (full days, for the review and the calendar), GET|POST /days/prune, GET /days/:date, PUT punches, PUT priorities (full replace, sparse rows, uid/addedAt), PUT overtime, PUT retro (note, done) routes/sessions.ts POST /days/:date/sessions (start, optional priorityUid), GET /sessions/running, PATCH/:id (label, notes, planned, priorityUid), POST /:id/pause|resume|finish|cancel, DELETE /:id (`loadOwnedSession` does the 404 + scoping for every /:id route) routes/settings.ts mergeSettings() validator; GET/PUT/DELETE /settings client/ Vite root → dist/client index.html viewport-fit=cover, theme-color, manifest, apple-mobile-web-app meta public/ manifest.webmanifest, icons/, sw.js (pass-through fetch; notificationclick) public/icons/icon.svg the app icon's one source (favicon, manifest); the PNGs next to it, apple-touch-icon.png included, come from `npm run icons` src/App.tsx provider stack + Shell (route, customize, settings, today's alarms) src/api.ts fetch wrapper; dispatches UNAUTHENTICATED_EVENT on 401 src/types.ts re-exports only: the shared wire types (api.ts) and Settings types src/styles.css design tokens (:root, dark via prefers-color-scheme), all component CSS src/lib/timeclock.ts PURE: computeTimeclock(punches, settings, now, {frozen}) → tiles/state, timeclockForDate/clampToDay (past days freeze at their end), normalizePunches/ clockOutPosition/extraPairs (row model), secondMealApplies src/lib/alarms.ts PURE: dueEvents(...) scheduler + describeEvent() copy src/lib/alerts.ts the ONLY place that plays audio / calls Notification / pushes banners: playSound(id) runs a synth pattern (SYNTH) or a bundled clip (fetched + decoded once through the one AudioContext) src/lib/sounds.ts clipUrl(id) from one import.meta.glob over src/sounds/, SOUND_EVENT_LABELS (the rows of Settings → Alarms → Sounds) src/sounds/ the bundled clips, .mp3, all CC0; README.md there records each clip's title, author and source (the only place provenance lives) src/lib/copy.ts what the app raises at the user (celebrations, warning pools, confirms, alerts, banners, notices, retro prompt, settings status); no logic src/lib/celebrate.ts PURE: pickCelebration(seed) for the end-of-day notice, pickBurst(seed, n) for the emoji burst (pieces + flight) src/lib/priorities.ts PURE: padPriorities(), warnThreshold(), warningKind(), pickWarning(kind), newUid(), placePriority() (timer → priorities) src/lib/retro.ts PURE: reviewDay(priorities, sessions) → on/off-plan time, mid-day rows src/lib/stickers.ts PURE: stickersForDay(summary) (clocked out, lunch, all priorities, focus, reviewed), stickerEmoji(date, id) (fixed, distinct per day), daySummaryOf(day) (a full day rolled up to a DaySummary), countStickers(weeks) (total, full days, per reason) src/lib/review.ts PURE: periodRange(kind, today, offset) (Mon-start weeks), periodOffset(kind, today, date) (the offset that lands on a date's period), reviewRange(days) src/lib/calendar.ts PURE: calendarMonth(days, settings, today, now, monthStart, showWeekends) → Mon-start rows of CalendarDay (outside / future / hasData / stickers) for History → Days; 5-wide rows without weekends, so hidden days are never counted src/lib/format.ts Intl formatting (time, dates, durations, dayName); formatTime(ms, hour12) + resolveHour12(timeFormat); re-exports shared/dates src/lib/timefield.ts PURE: msToTime/timeToMs (epoch ms ↔ @internationalized/date Time on a date key), guessPeriod() (the AM/PM the time field fills in) src/lib/timer.ts PURE: timerView(session, now) → elapsed/remaining/progress/endAt/paused/ pausedForSeconds/due/overrunSeconds (what the countdown shows), dueKey(), PAUSE_LIMIT_SECONDS, DUE_GRACE_SECONDS src/lib/layout.ts CARDS (titles for CARD_IDS), DEFAULT_LAYOUT, normalizeLayout() src/lib/storage.ts readStored/writeStored: localStorage that never throws (private mode, quota) src/hooks/ useSettings (SettingsProvider, optimistic PUT), useDay (per-date cache + setters), useTimer (running session, timerView per tick, pause/resume, the "time's up" prompt, requestFinish → the finish choice, and the auto-finish after the grace or a forgotten pause, mutationSeq re-sync, wake lock, tab title), useAlarms (fired keys in localStorage per day), useLatest (ref that tracks a value for callbacks), useNow, useRoute, useModalDialog (native : open on mount, cancel/Escape/backdrop close), useRange (keyed GET /days/range for Calendar and Review), useSettled, useTimeFormat ({ hour12, formatTime } from the setting), useWakeLock src/auth/ AuthGate (mode/user → Setup | Login | OIDC button | app), pages src/components/ Header, RunningTimerBar, Banners, Sheet (dnd-kit) + CardShell, Timeclock + TimeField (React Aria hour/minute/AM-PM segments), Priorities, FinishChoice (the "How much to log?" sheet a late Finish opens; mounted once in App), Burst (emoji flying from an anchor, portalled to body; off under reduced motion and the `celebrations` setting), FocusTimer, SessionLog, Retro, History (Days | Review; owns the review period so the calendar can point it at a week), Calendar (month grid, stickers when `settings.stickers`, legend filter, picked-day panel), Review (controlled by History), PeriodNav (◀ label ▶, shared), SettingsDialog (tabs incl. Data: retention + delete-before), Tile (label / value / sub, shared by the timeclock, the day panel and the review), Icons scripts/screenshots.mjs `npm run screenshots`: dev server (reused or started) + seed + headless Chromium over CDP → docs/screenshots/*.png for the README scripts/icons.mjs `npm run icons`: icon.svg → icon-192/512.png (transparent corners), icon-maskable-512.png and apple-touch-icon.png (full-bleed) scripts/browser.mjs the headless Chromium both scripts drive: findBrowser, launchBrowser, Cdp, openBrowser scripts/smoke-image.sh boots a built image and checks it (health, SPA shell, /data owner, PID 1 not root, the HEALTHCHECK command); CI's image-smoke and image jobs run it docs/screenshots/ committed PNGs the README embeds; regenerate after a visible UI change docker/entrypoint.sh PUID/PGID (default 1000/1000) → chown /data + su-exec; 0 keeps root Dockerfile docker-compose.yml .env.example README.md .oxlintrc.json unraid/clockspan.xml the Unraid Community Apps template: a field per .env.example variable (config.test.ts checks), /data → appdata, PUID/PGID 99/100; its TemplateURL is its own raw URL on main, so edits reach Unraid users when they merge ca_profile.xml the repository's Community Apps profile; the portal requires it at the root. Both files point at client/public/icons/icon-512.png and docs/screenshots/*.png by raw URL on main: moving those files breaks the listing CONTRIBUTING.md PR and release rules (imported by CLAUDE.md; see "Branches, PRs and releases") SECURITY.md how to report a vulnerability (GitHub private reporting), supported versions, scope .github/workflows/ci.yml check (+ image-smoke on PRs) → image (ghcr.io) → release (on a version bump); .github/release.yml groups notes by label .github/workflows/workflow-lint.yml zizmor on any change under .github/ (not a required check) ``` ## Commands ```bash nvm use 24 npm install npm run dev # API on :3000 (tsx watch, PORT pinned) + Vite on :5173 (proxies /api, /auth) npm test # vitest: shared + client lib tests + server API tests (~1.5 s) npm test -- server/routes/days # one file npm run test:coverage # the gate CI runs: the same suite, and every file in server/, shared/ and # client/src/lib must be 100% covered (text table of gaps + coverage/index.html) npm run typecheck # client + server (tsconfig.server.test.json also covers dev/ and tests) npm run lint # oxlint npm run format # prettier --write . (format:check is what CI runs) npm run seed # fill data/focus.db with sample days; see "Dev data is disposable" npm run screenshots # regenerate docs/screenshots/ (starts the dev server if needed; finds or # fetches a Chromium into node_modules/.cache; CHROME_BIN to force one) npm run icons # render the PNG icons from client/public/icons/icon.svg (same Chromium) npm run build # dist/client + dist/server + dist/shared npm start # node dist/server/index.js (PORT default 3000; Docker sets 8080) npm run reset-password -- docker compose pull && docker compose up -d # the published image; see README for building locally ``` Dev DB: `./data/focus.db` (gitignored). Delete it to start fresh. `AUTH_MODE=local npm run dev` to exercise the setup/login pages. The `prod` config in `.claude/launch.json` builds and serves the real bundle on :8090 with the real headers; the `web` config is the dev server, and `web-local` / `web-oidc` are the same dev server under `AUTH_MODE=local` / `oidc` (the OIDC one points at a provider that isn't there, so discovery logs a retry now and then; the sign-in button can't complete, everything after sign-in works). One at a time: they share :5173. ## Branches, PRs and releases `main` is protected. Every change is a branch → PR → `check` green → squash merge, and a release is a version-bump PR: CI tags and publishes from the merge, nothing is tagged by hand. The checklist, the PR requirements (title, one label, what must pass) and the version rule are in `CONTRIBUTING.md`. Follow it as written; it is not advice. Dependabot (`.github/dependabot.yml`) opens weekly `skip-changelog` PRs for npm (minor + patch grouped, majors on their own), GitHub Actions (grouped) and the Docker base image (pinned by digest in both stages), each release at least 7 days old (`cooldown`; security updates don't wait). They are merged by hand, as a batch, like any other PR; a major version bump is read like an outside PR first. CONTRIBUTING.md has the routine and says when a dependency merge calls for a patch release. ## Dev data is disposable On a dev checkout, `./data/focus.db` is test data and nothing else. Add, edit, and delete rows, users, days, punches, sessions, and settings as the task needs; delete the file to start over. None of this needs confirmation. Production data lives only on the Docker `/data` volume, which the dev machine cannot reach; the only local state worth protecting is the source tree. Run the destructive paths for real: delete a session or user, cancel a timer, `DELETE /api/settings`. Start from `npm run seed`, not from an empty DB: the last 10 weekdays for the default user (a normal day, an extra out/in pair with a mid-day priority, approved overtime, an unreviewed day with a cancelled session, a half day with no lunch) plus today clocked in two hours ago. Flags are in the `seed-cli.ts` header (`--running` for timer work, `--quarter` for Month / Quarter review, `--fresh` to also reset settings and logins). Under `AUTH_MODE=local` it creates `admin` and `sam` (password `clockspan-dev`); under `AUTH_MODE=oidc`, one "Dev User". `--auth local|oidc` stands in for the env var (with placeholder OIDC values), and `--sessions` signs every seeded user in and prints a `document.cookie = 'fs_session=…'` line per user: run it in the page and reload to be that user, with no password typed and no provider. It replaces the user's days each run, never deletes user rows, and is safe while `npm run dev` is up; reload the page. A signed-in browser check, local or OIDC: `preview_start` `web-local` (or `web-oidc`), then `npm run seed -- --auth local --sessions` (or `--auth oidc`), and set the printed cookie with `javascript_tool`. Ways in, cheapest first: - `npm test`: server tests boot the real app on an in-memory DB through `startTestApp()` (`server/dev/harness.ts`) and hit it with `fetch`. `seed: true` gives the test the sample days and a manifest of what was inserted (`app.seeded`). Reach into `app.db` for what the API cannot set up (a session that started an hour ago). One app per test. - `curl` against `http://localhost:3000/api/...` while `npm run dev` is up. - `sqlite3 data/focus.db` for direct inserts or a look at what a route wrote. - `DATA_DIR=` on `npm run seed` and `npm run dev` when the current DB should survive. - The UI in the preview pane, for what only the UI shows. Tests: pure-function tests in `shared/` and `client/src/lib`; harness tests in `server/**/*.test.ts` for routes, validation, scoping, headers, `mergeSettings`, and migrations (`migrate(db, upTo)` stops early so a backfill can be tested, see `server/db.test.ts`). No temp files: `openDatabase(':memory:')`. Limits that still hold: never commit `data/` or `.env`, and never point `DATA_DIR` outside the repo or the session scratchpad. ## Architecture rules (do not break) - **Anything both sides need lives in `shared/`** (`settings.ts`, `dates.ts`, `api.ts`) and is imported from there with a `.js` suffix. Never mirror a constant, default or type into the other tree; the client's `types.ts` re-exports the shared types so component imports stay short. Every response body has a `shared/api.ts` type: builders are annotated with it (`sessionRowToJson(): Session`, `dayJson(): Day`, …) and each route's answer names its envelope with `satisfies` (`res.json({ deleted } satisfies PruneResult)`), while `client/src/api.ts` reads the same types, so a field renamed on one side fails `typecheck` on the other. Server-only row types (`UserRow`, `SessionRow`) take their unions from there too. - **Response headers are set only in `server/security.ts`** (applied first in `createApp`). The CSP is same-origin with no `unsafe-inline`, so no inline `