52 KiB
Roadmap: FamilySync
Milestones
- ✅ v1.0 MVP — Phases 1–6 (shipped 2026-06-10) — see
milestones/v1.0-ROADMAP.md - 🚧 v1.1 Operability & Polish — Phases 7–16 (planning) — mobile test harness, Gitea CI (runs the harness), faster write-back, in-app admin, per-event reminders, guided setup, real lint gate, desktop e2e, doc-only CI skip + markdown lint, CI dependency audit + security checks + image hygiene
Phases
✅ v1.0 MVP (Phases 1–6) — SHIPPED 2026-06-10
- Phase 1: Foundation + Broker Spike (4/4 plans) — completed 2026-06-04
- Phase 2: Calendar Display (5/5 plans) — completed 2026-06-05
- Phase 3: Event Write-Back + PWA Install (12/12 plans) — completed 2026-06-07
- Phase 4: Shared Lists + Live Sync (7/7 plans) — completed 2026-06-09
- Phase 5: Web Push Notifications (8/8 plans) — completed 2026-06-10
- Phase 6: UX Polish (6/6 plans) — completed 2026-06-10
Full phase detail archived in milestones/v1.0-ROADMAP.md.
🚧 v1.1 Operability & Polish (Phases 7–16)
Make FamilySync configurable, administrable, and maintainable for real multi-member use — without hand-editing env files or the database. The new critical path runs mobile test harness → Gitea CI (CI consumes the harness specs for UI regression), and the admin role → reminders / setup wizard chain (a single /api/admin + /api/setup route surface carrying the v1.1 DB migration). Faster write-back is a fully independent track.
- Phase 7: Mobile Test Harness - Mobile-emulated, authenticated PWA browser harness so the assistant (and CI) can catch mobile-only defects (completed 2026-06-11)
- Phase 8: Gitea CI - Full regression on PR to main (lint/typecheck/unit/API-integration vs a MariaDB service container + the Phase 7 mobile harness as a UI-regression step against a CI-hosted dev stack) + Docker image publish on merge (completed 2026-06-11)
- Phase 9: Faster Write-Back - Event-driven outbox drain so edits land in ~1-2s instead of ~15s, preserving every outbox durability guarantee (completed 2026-06-12)
- Phase 10: Admin Role & Settings - DB foundation (is_admin / reminder_lead / app_config) + role-gated admin UI to rotate app passwords and designate the shared calendar
- Phase 11: Per-Event Reminders - Reminder selector on the event form (incl. "None") serialized as VALARM, with a variable-lead scheduler that honors each event's choice
- Phase 12: Initial Setup Wizard - First-run validated bootstrap of env/VAPID/DB/OIDC + first app password, reusing the admin route surface
- Phase 13: Real Lint Gate (ESLint) - Wire ESLint flat config (typescript-eslint + React) across both apps so the Phase 8 CI lint slot actually fails on violations instead of no-op'ing (completed 2026-06-12)
- Phase 14: Desktop E2E Coverage - Add a Desktop Chrome Playwright profile + make the mobile-authored specs desktop-safe so the Phase 8 regression gate validates desktop, not just mobile (completed 2026-06-12)
- Phase 15: Doc-Only CI Skip + Markdown Lint - Aggregate-gate the slow api/harness CI jobs so doc-only PRs to main merge without running them (no branch-protection deadlock), and add markdownlint to
fast-checksso docs get a fast format+lint gate (promoted from backlog 999.17) (completed 2026-06-12) - Phase 16: CI Dependency Audit, Security Checks & Image Hygiene - Extend Gitea CI with outdated-dependency reporting + vulnerability audit + a baseline of additional security checks, and enforce the dev/prod image boundary so no dev-bypass, secret, or family data ships in published images (absorbs backlog 999.17); independent of the admin chain (completed 2026-06-13)
Phase Details
v1.0 phase detail (Phases 1–6) is archived in
milestones/v1.0-ROADMAP.md.
Phase 7: Mobile Test Harness
Goal: The assistant can drive the PWA in a mobile-emulated, authenticated browser context against the host-side dev stack, so mobile-only layout and flow defects can be caught automatically instead of only by the operator on real devices. This harness is also the artifact Phase 8 (CI) runs for UI regression.
Mode: standard
Depends on: Nothing (fully independent; goes first. One new dev dependency @playwright/test in apps/pwa; no backend changes).
Requirements: TEST-01, TEST-02
Success Criteria (what must be TRUE):
- An automated run can load the PWA in a mobile-emulated viewport (device profile + mobile UA + touch) and assert on responsive layout / tap targets.
- The automated run reaches the authenticated PWA via the existing
DEV_AUTH_BYPASSon the host-side dev stack — no manual login and no Authelia/OIDC mocking. - The harness runs repeatably day-over-day without re-capturing any session state (no stale storage-state failures).
- The harness specs are structured so they can run headlessly in CI (Phase 8) against a dev stack the runner brings up — no dependence on a developer's already-running host stack.
Pitfalls this phase owns (from PITFALLS.md):
- No stale storage-state (Pitfall 14): use
DEV_AUTH_BYPASS=truefor the automated harness rather than a checked-in storage-state.json with an expiring session cookie; decide the auth strategy before the first test. - Service worker block (Pitfall 15): set
serviceWorkers: 'block'(or explicitly unregister) in the context so a previous run's SW does not intercept requests / return stale cached responses; verify no SW-sourced responses in the trace. - Hard constraints: targets the dev build via
DEV_AUTH_BYPASS(DEV_AUTH_BYPASS user 1 has no CalDAV credential/calendars — verify layout/flows, not live event-create); real prod-service-worker / iOS-Safari-standalone mobile testing stays a human/device gate (out of scope).
Plans: 4 plans (3 waves)Plans: Wave 1
- 07-01-PLAN.md — Harness foundation: @playwright/test + WebKit/Chromium browsers, playwright.config.ts (iPhone/WebKit + Pixel/Chromium matrix, serviceWorkers block, env baseURL, vite webServer), vitest exclude, scripts (Wave 1)
Wave 2 (blocked on Wave 1 completion)
- 07-02-PLAN.md — global-setup.ts: /health readiness poll + deterministic mysql2 reset-and-seed (calendar id 10 INSERT IGNORE guard, list + items) + e2e README/guardrails (Wave 2)
Wave 3 (blocked on Wave 2 completion)
- 07-03-PLAN.md — layout.spec.ts: tap targets >=44px, no overflow, in-viewport, accessible names (UI-SPEC Rules 1-4) + harness self-validation injected-defect proofs (Wave 3)
- 07-04-PLAN.md — calendar.spec.ts + lists.spec.ts: populated/empty/error states (Rules 4/5) + DEV_AUTH_BYPASS auth-reached + no-SW-controller precondition (Wave 3)
UI hint: yes
Phase 8: Gitea CI
Goal: Every PR to main runs a full regression that gates the merge — lint, typecheck, unit, API-integration against a MariaDB service container, and the Phase 7 mobile Playwright harness as a UI-regression step against a CI-hosted dev stack — and a merge to main builds and publishes the API Docker image, all on the existing self-hosted Gitea Actions runner.
Mode: standard
Depends on: Phase 7 (the PR regression runs the Phase 7 mobile harness specs as its UI-regression step; without the harness there is nothing to run). No other code dependencies. Start with a runner-probe step.
Requirements: CI-01, CI-02
Success Criteria (what must be TRUE):
- Opening or updating a PR targeting
maintriggers a workflow that runs lint, typecheck (both apps), unit tests, and API integration tests against a MariaDB service container — and a failing run blocks the merge. - The API integration tests connect to the service-container MariaDB (DB_HOST=127.0.0.1, service creds) and pass reliably on a cold first run, not only on re-run.
- The same PR workflow brings up the dev stack inside the runner — the API dev server, the PWA dev server, and the MariaDB service container, with
DEV_AUTH_BYPASS=true— and runs the Phase 7 mobile Playwright harness specs headlessly against that authed PWA; a harness failure blocks the merge. - The harness step waits for both the API and PWA dev servers to be ready (readiness probe / poll) before launching Playwright, so it does not flake on startup races.
- On merge to
main, the API Docker image is built and pushed to the Gitea container registry under a sensible tag. - Registry credentials never appear in plaintext in the CI logs.
Pitfalls this phase owns (from PITFALLS.md):
- Runner-probe first (Pitfall 12): the first workflow only probes
node --version/pnpm --version/ Docker access on theself-hostedrunner before any test or build steps are designed; pin Node 22 explicitly, do not assumeactions/setup-nodeworks as on GitHub. - MariaDB readiness wait (Pitfall 11): add an explicit readiness loop (e.g.
healthcheck.sh --connect --innodb_initialized, NOTmysqladmin pingwhich is removed in MariaDB 11) before anydrizzle-kit migrate/ integration test step; healthy ≠ accepting connections. - Dev-stack readiness races (NEW for the harness step): running the PWA and API dev servers inside CI adds startup/readiness races on top of the MariaDB-11 readiness race. The harness step must wait for both the API and PWA dev servers to be accepting connections (poll their URLs / health endpoints) before Playwright launches — do not race the browser against a not-yet-listening server. Run with
DEV_AUTH_BYPASS=trueso the harness reaches the authed PWA exactly as in Phase 7. - --password-stdin (Pitfall 13):
docker loginvia--password-stdinwith the token piped from a registered Gitea secret (PAT withwrite:package); never-p $TOKENon the command line. - Hard constraints: API integration tests need a real MariaDB and live in
apps/api/tests/(neversrc/); cache the pnpm store; Drizzle generate+migrate to set up the CI DB schema; the harness step reuses the Phase 7 specs unchanged (CI owns only the stack bring-up + readiness wait, not the spec content).
Plans: 4 plans (4 waves)Plans: Wave 1
- 08-01-PLAN.md — Runner probe + operator runner/PAT registration (W0; answers the Docker-vs-host fork)
Wave 2 (blocked on Wave 1 completion)
- 08-02-PLAN.md — ci.yml: fast-checks (lint/typecheck/PWA unit) + API job (MariaDB service + migrate + DB-backed tests)
Wave 3 (blocked on Wave 2 completion)
- 08-03-PLAN.md — ci.yml: harness job (dev-stack bring-up + readiness waits + Phase 7 Playwright specs, both profiles)
Wave 4 (blocked on Wave 3 completion)
- 08-04-PLAN.md — ci.yml: publish job (build production image + push :latest + :v1.1- via --password-stdin)
UI hint: yes
Phase 9: Faster Write-Back
Goal: A created, edited, or deleted event reaches Fastmail within ~1-2 seconds (event-driven outbox drain) instead of waiting up to ~15s for the next interval tick — with every existing durability guarantee intact.
Mode: standard
Depends on: Nothing (fully independent; the only new artifact is a zero-dependency in-process EventEmitter, lib/outboxTrigger.ts). Can run in parallel with any other v1.1 track.
Requirements: CAL-15
Success Criteria (what must be TRUE):
- After creating/editing/deleting an event, the change lands in Fastmail in ~1-2s in the common case (drain is signalled on enqueue, not waited-for on the interval) — observable as the change appearing in the Fastmail native app well before the old ~15s window.
- The route handler still returns an optimistic 202 immediately and never makes a CalDAV call inline — the event-driven signal is fire-and-forget.
- Edit-as-move still writes the new event before deleting the old one (create-before-delete ordering preserved); no event is ever lost when a move drains under rapid enqueues.
- No duplicate CalDAV PUTs occur for the same outbox row when the signal and the 15s fallback interval overlap (exactly-once per uid preserved).
- The 15s
setIntervalfallback still runs and recovers any rows missed by the signal path (startup catch-up, transient errors).
Pitfalls this phase owns (from PITFALLS.md):
- No double-drain (Pitfall 5): the trigger must set a
drainRequestedflag funnelled through the single setInterval-controlled path / the existingisDrainingguard — never callrunOutboxDrain()directly from the signal in a way that bypasses the guard or escapes the error-caught wrapper. - Create-before-delete under concurrent enqueues (Pitfall 6): enqueue CREATE before DELETE; do not fire the signal between the two inserts of a move (publish after both inserts / after the transaction commits).
- Hard constraints:
setIntervalonly (no node-cron); single-process by design — no Redis for the drain (Redis stays for list SSE); all outbox guarantees (fresh-etag-before-PUT, 412 conflict flow, per-uid exactly-once) unchanged.
Plans: 2 plans (2 waves)
Plans: Wave 1
- 09-01-PLAN.md — TDD: outboxTrigger.ts (zero-dep EventEmitter signal) + scheduleOutboxDrain wrapper / drainRequested trailing-re-drain loop + initOutboxTrigger in outboxWorker.ts; trigger-wiring tests (SC-1, SC-4/D-07, D-05) (Wave 1)
Wave 2 (blocked on Wave 1 completion)
- 09-02-PLAN.md — Four post-commit signalOutboxDrain() publish sites in events.ts (create / edit-as-move-after-transaction / same-cal update / delete) + initOutboxTrigger() startup wiring under isMainModule() in index.ts (Wave 2)
Phase 10: Admin Role & Settings
Goal: An admin can manage household configuration that previously required manual DB writes — rotating a member's Fastmail app password and designating the shared family calendar — from a role-gated in-app Settings section, on top of the v1.1 DB foundation this phase introduces. Mode: standard Depends on: Nothing required upstream; this phase carries the v1.1 DB migration (users.is_admin, calendar_events.reminder_lead_minutes, app_config table) that Phases 11 and 12 build on. It is the head of the admin chain (10 → 11, 10 → 12). Requirements: ADMIN-01, ADMIN-02, ADMIN-03 Success Criteria (what must be TRUE):
- An admin sees an Admin section in Settings and can list household members with their credential status; a non-admin member never sees it and cannot invoke any
/api/admin/*route (gets 403). - An admin can enter or rotate a member's Fastmail app password; it is validated against CalDAV (PROPFIND) before saving and stored encrypted — and the password is never displayed, echoed in a response, or logged.
- An admin can pick which synced calendar is the shared family calendar from a list, and the
calendars.is_sharedflag updates accordingly (replacing the manualUPDATE calendars SET is_shared=1step). - The role check is role-agnostic and member-count-agnostic: it gates on
users.is_admin, so more admins can be added later without reworking the guard. - The DB migration (is_admin, reminder_lead_minutes, app_config) is applied via generate+migrate and is in place for downstream phases (reminder_lead_minutes for Phase 11, app_config.setup_complete for Phase 12).
Pitfalls this phase owns (from PITFALLS.md):
- Admin role check inside the sub-router (Pitfall 9): apply
requireAdminwith.use('*', ...)insideadminRouter, not only at the parent mount; integration test must assert 403 for a non-admin authenticated user. - App password never logged/echoed (Pitfall 7): custom zod-validator
hookreturns a generic 400 (no Zodreceived/valuefield); noconsole.logof request bodies inroutes/admin*. - Hard constraints: Drizzle generate+migrate, never push (false destructive diff on populated MariaDB); reuse
broker/crypto.tsencryptPassword(no changes to crypto);/api/admin/credentialsand/api/admin/calendars/:id/sharedare the single shared surface — do NOT duplicate them into/api/setup/*in Phase 12.
Folded-in scope (from backlog 999.5, self-service member onboarding): the credential surface this phase builds is the same one a member needs on first login. Expose a needsProviderSetup signal (member has no member_credentials row) and let a member enter/validate (CalDAV PROPFIND) + encrypt their own Fastmail app password — the self-service counterpart of the admin-managed flow, sharing the validation/encryption/initial-sync path. Non-technical-friendly instructions (link to Fastmail's app-password page, required Calendars/CalDAV scope) are the hard UX constraint. Member-scoped: a member can only set their own credential; never log/echo the password.
Plans: TBD UI hint: yes
Phase 11: Per-Event Reminders
Goal: A user can choose a reminder lead time per event (None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d, default None), serialized as a VALARM on the event, and the push scheduler fires at that exact lead — firing nothing when there is no alarm and never stripping reminders set in other clients.
Mode: standard
Depends on: Phase 10 (the calendar_events.reminder_lead_minutes column from the v1.1 migration is the scheduler's ground truth). Independent of Phases 7/8/9/12.
Requirements: CAL-13, CAL-14, NOTIF-04, NOTIF-05, NOTIF-06
Success Criteria (what must be TRUE):
- When creating or editing a timed event, the user can pick a reminder lead from the preset list (None default); the choice round-trips to Fastmail as a VALARM and is visible/honored on re-open.
- Editing an event that already has a reminder set in another client (Fastmail / Apple Calendar) preserves that VALARM — it is never silently dropped on round-trip.
- A reminder push fires at the event's chosen lead time (e.g. T-30 for a 30-minute lead), not a hardcoded 15-minute lead.
- An event with no reminder set produces no reminder push (no default 15-minute fire).
- An all-day event's reminder fires at a sensible local time (9 AM on the alert day), not midnight; the reminder selector is disabled/hidden for all-day events in the UI; and reminder delivery stays exactly-once across catch-up scans and rescheduled events.
Pitfalls this phase owns (from PITFALLS.md):
- Preserve-on-edit (Pitfall 1): the update path extracts and preserves existing VALARM sub-components from
rawVevent(mirroring the WR-01 RRULE-preserve pattern) — never rebuild-from-scratch and silently strip;outboxPayloadSchemadistinguishes "no change" from explicit "no reminder". - No TRIGGER VALUE=TEXT (Pitfall 2): build the trigger with
ICAL.Duration.fromSeconds(-n*60), not a bare string; unit-test that the ICS emits a DURATION trigger with noVALUE=TEXT. - All-day 9AM semantics (Pitfall 3): guard
buildVeventString(if (!allDay && reminderMinutes > 0)), disable the selector when allDay, keep the scheduler's all-day handling at 9 AM local. - uid:dtstartMs dedup (Pitfall 4): change the scheduler dedup key from bare
uidto compounduid:dtstartMsand widen the scan to a variable per-event window so long leads fire and rescheduled events re-fire; keepeventFieldsSchemaandoutboxPayloadSchemain sync (IN-03). - Hard constraints:
setIntervalonly; scheduler readsreminder_lead_minutesfrom the DB (ground truth), not the outbox payload; drop theisShared-only reminder restriction (a user who set an alarm wants it regardless of calendar).
Plans: TBD UI hint: yes
Phase 12: Initial Setup Wizard
Goal: On first run (no admin/credentials configured), the operator is guided through a validated, step-by-step wizard to bootstrap the app — env presence, generated secrets to copy, DB/OIDC/VAPID/app-password validation — instead of hand-editing .env / docker-compose.yml; once complete, the setup endpoints lock.
Mode: standard
Depends on: Phase 10 (reuses the admin role + /api/admin/credentials and /api/admin/calendars/:id/shared routes; the wizard is the second frontend consumer of that surface, and app_config from the Phase 10 migration holds setup_complete). Goes last. Independent of Phases 7/8/9/11.
Requirements: SETUP-01, SETUP-02, SETUP-03, SETUP-04
Success Criteria (what must be TRUE):
- On a fresh install with nothing configured, the operator reaches a setup wizard (via
GET /api/setup/statusmounted before the OIDC guard) and walks through bootstrap steps instead of editing files by hand. - Each input is validated before the step can complete: DB connects, VAPID private key decodes to exactly 32 bytes and pairs with the public key, OIDC discovery resolves, and the Fastmail app password reaches CalDAV (PROPFIND).
- Generated secrets (session secret, encryption key, VAPID keypair) are displayed for the operator to copy into env; they are never written to the DB or returned in a way that persists, and
APP_PASSWORD_ENCRYPTION_KEY/VAPID_PRIVATE_KEYnever enter the DB at all. - After completion, the wizard-completing user is promoted to admin (
is_admin),app_config.setup_completeis set, and any further call to a setup endpoint returns 423 Locked. - The 423 guard is enforced on every invocation (checked against member-credentials + VAPID env present), not only at startup.
Pitfalls this phase owns (from PITFALLS.md):
- Guard on every invocation (Pitfall 8): the "already set up" guard returns 423 from all setup routes once configured — implement and test the guard before the happy path; a second POST after completion must return 423, not 200.
- Secrets stay in env, never in DB (Pitfalls 8 & 10): the wizard validates secrets by performing a test operation (test encrypt/decrypt, structural VAPID check), never by accepting/storing the key value; no DB column for
vapid_private_keyorapp_password_encryption_key; never log/echo the app password. - Hard constraints:
GET /api/setup/statusmounts before the OIDC guard (like/health); do NOT create/api/setup/credentials— reuse the Phase 10 admin routes; Drizzle generate+migrate (anyapp_configseeding via migration).
Plans: TBD UI hint: yes
Phase 13: Real Lint Gate (ESLint)
Goal: The CI lint gate actually fails on lint violations. A real ESLint flat config (eslint.config.js, typescript-eslint; React + react-hooks plugins for apps/pwa) plus a package-level lint script in apps/api and apps/pwa makes the existing root pnpm -r --if-present lint run a real linter, replacing the hollow no-op gate that exits 0 because no linter exists.
Mode: standard
Depends on: Phase 8 (the CI fast-checks job already runs pnpm lint; this fills the slot Phase 8 shipped wired to auto-activate once a package lint script lands). Independent of all other phases.
Requirements: TBD (promoted from backlog 999.16)
Success Criteria (what must be TRUE):
pnpm lintruns ESLint across bothapps/apiandapps/pwaand exits non-zero on an introduced violation (verified by a deliberate test violation), where today it exits 0 with no linter present.- The CI
fast-checkslint step blocks a PR to main on lint violations — the gate can now fail. - The first real run's existing violations are resolved (fix / warn / disable decided per rule) so the baseline gate ends green.
Pitfalls this phase owns:
- Pick a baseline ruleset (recommended vs strict-type-checked) deliberately — strict surfaces a large upfront cleanup; decide blocking vs advisory before flipping the gate to blocking.
typecheck/tsc already gates type errors; ESLint should not duplicate type-checking rules unnecessarily.
Plans: 3 plans — all complete (scope expanded during planning to add a Prettier format:check gate)
- 13-01-PLAN.md — Install ESLint/Prettier deps + flat config + package scripts + prove the gate fails (SC-1)
- 13-02-PLAN.md — Fix all first-run lint violations across both apps, green
pnpm lint(D-13-05/06) - 13-03-PLAN.md — Prettier reformat (isolated commit) + CI format:check step + green baseline (SC-2/SC-3)
UI hint: no
Phase 14: Desktop E2E Coverage
Goal: The Phase 8 regression gate exercises the desktop layout and flows, not just mobile. A desktop Playwright project (devices['Desktop Chrome'], no touch, wide viewport) is added to apps/pwa/playwright.config.ts, and the existing mobile-authored specs are reviewed/adjusted (or appropriately skipped) so pnpm test:e2e passes on a no-touch desktop viewport as well as the iphone/pixel profiles.
Mode: standard
Depends on: Phase 7 (the harness it extends) and Phase 8 (CI runs pnpm test:e2e and picks up the new project automatically — no CI plumbing change needed beyond any desktop-profile runtime/wait). Independent of Phases 9–13.
Requirements: TBD (promoted from backlog 999.15)
Success Criteria (what must be TRUE):
- A
desktopproject exists inplaywright.config.ts(Desktop Chrome, wide viewport, nohasTouch). - The existing e2e specs pass (or are explicitly, justifiably skipped) on the desktop profile — touch-gesture / mobile-drawer / mobile-only-layout assumptions are handled.
pnpm test:e2ein CI runs and gates on both mobile and desktop profiles (blocking-vs-advisory for desktop decided when planned).
Pitfalls this phase owns:
- The real work is the spec-compat pass, not CI plumbing — Phase 8 reused the Phase 7 harness unchanged, so the config addition is small but specs authored for touch/mobile need per-spec review.
- Desktop WebKit is optional — the Apple member is already covered on mobile Safari via
iphone; Desktop Chrome is likely sufficient for a shared/wall browser.
Plans: 1 plan Plans:
- 14-01-PLAN.md — Add the
desktopPlaywright project, desktop-skip the two mobile-only layout assertions (+ D-04 parity), update spec/README docs, and provepnpm test:e2eis green on iphone + pixel + desktop with a blocking CI gate.
UI hint: no
Phase 15: Doc-Only CI Skip
Goal: Doc-only PRs to main merge without running the slow harness (Playwright e2e + dev-stack bring-up, ~5 min) and api (MariaDB integration) jobs, while fast-checks (Prettier format:check + markdown linting) still runs — and branch protection never deadlocks on a required check that never reports. Docs get a fast but real gate: format + lint, none of the slow code jobs.
Mode: standard
Depends on: Phase 8 (the .gitea/workflows/ci.yml it modifies) and Phase 13 (the fast-checks job + format:check step this extends). Independent of Phases 9–12.
Requirements: TBD (promoted from backlog 999.17)
Success Criteria (what must be TRUE):
- A doc-only PR to
main(onlydocs/or*.mdchanged) skips theapiandharnessjobs but still runsfast-checks. - A PR touching code runs
fast-checks,api, andharnessas today; a failure in any blocks the merge. - Branch protection requires
CI / fast-checks+ an always-runningCI / gateaggregate (passes when each heavy job issuccessORskipped) — the directapi/harnessrequirements are dropped so a skipped heavy job never deadlocks the merge. fast-checksruns a markdown linter (markdownlint-cli2) over**/*.md; an introduced markdown-lint violation fails the gate, and the existing markdown baseline passes (violations fixed or rules configured) so the gate starts green.
Pitfalls this phase owns:
- Required-check deadlock — never path-filter a required context directly; a required job that never reports blocks the PR forever. The always-running
gatejob (if: always(), passes onsuccess/skipped) is the only safe gating surface. - Gitea skipped-status quirk — Gitea may not emit a commit-status for a
skippedjob; rely on the always-runninggate, not on markingapi/harnessskipped-but-required. - Prettier vs markdownlint overlap — Prettier already owns markdown formatting; scope markdownlint to content rules (heading increments, no broken/duplicate link refs, list/code-fence conventions) and disable its purely-stylistic rules that fight Prettier (e.g. line-length, list-indent), so the two don't conflict on the same
.md. .planning/*is push-direct, never linted — planning bookkeeping bypasses CI via the Unprotected file pattern, so markdownlint never sees it; scope the lint glob todocs/+ repo-root/app*.mdand exclude.planning/**(and any generated markdown) to avoid a baseline cleanup of churny bookkeeping files.
Plans: 3 plans (3 waves) Plans: Wave 1
- 15-01-PLAN.md — markdownlint-cli2 +
.markdownlint-cli2.jsonc+md:lintscript + fast-checks step + fix 13 baseline violations (SC-4)
Wave 2 (blocked on Wave 1 completion)
- 15-02-PLAN.md — ci.yml:
changes(dorny/paths-filter@v4) + conditional api/harness + always-runninggateaggregate (SC-1/SC-2, SC-3 YAML)
Wave 3 (blocked on Wave 2 completion)
- 15-03-PLAN.md — operator branch-protection checkpoint (require
CI / fast-checks+CI / gate, drop api/harness) + publish.yml comment update (SC-3)
UI hint: no
Phase 16: CI Dependency Audit, Security Checks & Image Hygiene
Goal: The CI pipeline surfaces outdated and vulnerable dependencies, runs a baseline of additional security checks, and enforces a clean dev↔prod boundary in the images it publishes — so the two-person household app doesn't silently rot on stale/CVE-bearing packages, and no dev-only affordance, secret, or family-specific data ever ships in a production image. Extends the existing Gitea CI (Phase 8) workflow with dependency/security/image-hygiene gates rather than standing up a separate pipeline. Absorbs backlog 999.17 (dev/prod image boundary). Mode: standard Depends on: Phase 8 (Gitea CI — adds steps to the existing workflow + publish job; no admin-chain dependency). Independent of Phases 10–12. Requirements: SEC-01 (secret scanning), SEC-02 (static security lint), DEP-01 (vuln audit gate), DEP-02 (outdated advisory), IMG-01 (NODE_ENV+boot-guard), IMG-02 (.dockerignore), IMG-03 (publish image-hygiene assertions), CI-03 (security job + gate wiring)
Candidate scope (to be sharpened in /gsd-discuss-phase 16):
- Outdated dependencies: a CI step that reports dependencies behind their latest (e.g.
pnpm outdated -r), surfaced on the PR. Decide gating vs advisory, and how to handle the pinned-version table in CLAUDE.md (the stack pins exact versions — "outdated" must not fight intentional pins). - Vulnerability audit:
pnpm audit(or equivalent) against the lockfile, failing on a chosen severity threshold (e.g. high/critical). Decide the threshold and an allowlist/waiver mechanism for unfixable transitive advisories. - Additional security checks (user is open to these — pick a sensible baseline, avoid over-build): candidates — secret scanning on the diff (gitleaks/trufflehog), a CodeQL/
eslint-plugin-securitystatic pass, dependency-review on PRs, Dockerfile/image scan (e.g. trivy) of the published image. - Dev/prod boundary definition & enforcement (from 999.17): the
DEV_AUTH_BYPASSconcept (and any dev-only affordance) must be provably confined to local dev — never to production, never baked into published images. Today the guard is runtime-only (NODE_ENV !== 'production' && DEV_AUTH_BYPASS === 'true'inapps/api/src/auth/devBypass.ts); add (a) explicit documentation of what "dev image" vs "shipped image" means, and (b) build-time / boot-time enforcement (aproductionimage refuses to boot — or the build aborts — if dev-bypass is enabled) as defense-in-depth. - No data/secrets in published images (from 999.17): audit the Dockerfile(s) + the Phase 8 publish job (
publish.yml) to confirm.env, dev seed SQL, local DB dumps, encryption keys, OIDC secrets, theDEV_USERseed, and family-specific fixtures are.dockerignored and neverCOPY'd. Add a CI assertion that fails the publish if a dev-bypass code path is active, a forbidden env/secret is present, or personal/seed data is staged into the image context. The dev-stack seed path (DEV_USERid 1 + sample calendar/list data) must be unreachable from the production image/compose. - Noise control: these gates are notorious for flaky/advisory-churn failures; decide blocking-on-merge vs warn-only per check, and where results surface (PR annotation vs job log), mirroring Phase 15's gate-aggregation approach.
Boundary: Extends the existing Gitea CI workflow + publish job; does not remove dev-bypass (still needed for local verification and the Phase 7/8 harness) and does not add a new external service or a runtime dependency to the app. Automated dependency upgrades (e.g. Renovate/Dependabot bots) are a separate concern — decide in discuss whether they're in scope or deferred.
Plans: 6 plans in 2 waves Plans: Wave 1
- 16-01-PLAN.md — Image-hygiene runtime: bake NODE_ENV=production + boot-time refuse-to-boot guard (IMG-01)
- 16-02-PLAN.md — pnpm audit gate + waiver allowlist + advisory-only tiered outdated report (DEP-01, DEP-02)
- 16-03-PLAN.md — Fold eslint-plugin-security into the lint gate as blocking errors + triage (SEC-02)
- 16-04-PLAN.md — gitleaks config + full-history baseline + .dockerignore (SEC-01, IMG-02)
Wave 2 (blocked on Wave 1 completion)
- 16-05-PLAN.md — Add the security job to ci.yml (gitleaks always; audit/outdated code-gated) + gate wiring (CI-03)
- 16-06-PLAN.md — publish.yml static image-hygiene assertion + boot-smoke before push (IMG-03)
UI hint: no
Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|---|---|---|---|---|
| 1. Foundation + Broker Spike | v1.0 | 4/4 | Complete | 2026-06-04 |
| 2. Calendar Display | v1.0 | 5/5 | Complete | 2026-06-05 |
| 3. Event Write-Back + PWA Install | v1.0 | 12/12 | Complete | 2026-06-07 |
| 4. Shared Lists + Live Sync | v1.0 | 7/7 | Complete | 2026-06-09 |
| 5. Web Push Notifications | v1.0 | 8/8 | Complete | 2026-06-10 |
| 6. UX Polish | v1.0 | 6/6 | Complete | 2026-06-10 |
| 7. Mobile Test Harness | v1.1 | 4/4 | Complete | 2026-06-11 |
| 8. Gitea CI | v1.1 | 4/4 | Complete | 2026-06-11 |
| 9. Faster Write-Back | v1.1 | 2/2 | Complete | 2026-06-12 |
| 10. Admin Role & Settings | v1.1 | 0/? | Not started | - |
| 11. Per-Event Reminders | v1.1 | 0/? | Not started | - |
| 12. Initial Setup Wizard | v1.1 | 0/? | Not started | - |
| 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 |
| 14. Desktop E2E Coverage | v1.1 | 1/1 | Complete | 2026-06-12 |
| 15. Doc-Only CI Skip + MD Lint | v1.1 | 3/3 | Complete | 2026-06-12 |
| 16. CI Dep Audit, Sec & Img Hyg | v1.1 | 6/6 | Complete | 2026-06-13 |
Backlog
Phase 999.1: Treat Fastmail as one calendar provider; framework supports adding more providers (BACKLOG)
Goal: [Captured for future planning] Abstract the calendar backend behind a provider interface so Fastmail/CalDAV is one implementation among potentially many. Shipping with a single provider is fine, but the broker, sync, and event-expansion layers should be structured so additional providers (e.g. other CalDAV hosts, Google Calendar, generic ICS feeds) can be added without rework. Captures the "provider" seam as an explicit architectural concern. Requirements: TBD Plans: 6/6 plans complete
Plans:
- TBD (promote with /gsd-review-backlog when ready)
Phase 999.4: Per-event reminder configuration (VALARM authoring + scheduler honors it) (BACKLOG)
Goal: [Captured for future planning] End-to-end per-event reminders — let the user choose when (or whether) to be reminded per event, and make the push scheduler honor that choice instead of a hardcoded lead.
Half A — author the VALARM (event form): The event create/edit form has no UI to set a reminder ("remind me 10 min / 1 hour / 1 day before", or no reminder), so the written .ics carries no VALARM and no reminder can fire — in native clients or via web push. Add a reminder selector (including an explicit "none"), serialize chosen offsets as VALARM (TRIGGER) on write-back, and parse existing VALARMs on read so edits preserve them. Feeds the Phase 5 web-push requirement (push needs reminder data to notify about).
Half B — scheduler honors the provider's value (NEW, surfaced 2026-06-10): Today apps/api/src/broker/reminderScheduler.ts runs a hardcoded 15-minute scan for shared timed events (index.ts:139 "starting in ~15 min"; reminderScheduler header "15-min reminder scan") and never reads the event's actual alarm. So every reminder fires 15 min before regardless of what the event (or the calendar provider) specifies, and an event with no alarm still gets a 15-min push. Change the scheduler to read each event's VALARM TRIGGER (the value written in Half A / set in Fastmail or another native client) and fire at that lead — and fire nothing when the event has no alarm. The current fixed 15-min window/dedup logic (catch-up scan, per-uid exactly-once — see quick 260610-hbu) must be generalized to a variable per-event lead.
Boundary: preserve the reminder scheduler's resilience guarantees (catch-up on a missed tick, per-uid exactly-once dedup). This makes the lead per-event/variable rather than constant; it is not a rewrite of the scan/dedup design.
Severity: medium — feature gap surfaced during Phase 03 Gate 2 testing; Half B surfaced 2026-06-10. Tags: phase-03, phase-05, calendar, write-back, reminders, valarm, push, scheduler, phase-05-dependency. Requirements: TBD Plans: 0 plans
Promoted into v1.1 Phase 11 (Per-Event Reminders) — CAL-13/CAL-14/NOTIF-04/05/06. Backlog entry retained for history.
Plans:
- TBD (promote with /gsd-review-backlog when ready)
Phase 999.10: Admin Settings / Administration section — manage app passwords + designate the shared calendar via UI (BACKLOG)
Goal: [Captured for future planning] Add an in-app Settings/Administration section, gated to an administrator role, for configuration that today requires manual backend/DB steps:
- View/update per-member Fastmail app passwords (stored encrypted via
APP_PASSWORD_ENCRYPTION_KEY, existing crypto path) — rotate or re-enter a member's credential and re-trigger sync. - Designate which synced calendar is the "shared" calendar by toggling
calendars.is_sharedfrom the UI. Today this is a manual DB write: e.g.UPDATE calendars SET is_shared=1 WHERE id=<row>— done by hand on 2026-06-10 to mark the "FamilySync" calendar (id 10) shared after the poller synced it (D-16). The admin should pick the shared calendar from a list of synced collections instead of relying on a backend process. (The poller's upsert already leavesis_shareduntouched, so a UI-set flag persists.)
Context: Motivated by the manual D-16 resolution (2026-06-10). Related: 999.5 (per-member first-login app-password onboarding) — this is the ongoing admin-managed counterpart; and 999.11 (initial setup wizard) — bootstrap-time vs. ongoing config. Tags: admin, settings, calendar, app-passwords, D-16.
Promoted into v1.1 Phase 10 (Admin Role & Settings) — ADMIN-01/ADMIN-02/ADMIN-03. Backlog entry retained for history.
Requirements: TBD Plans: 0 plans
Plans:
- TBD (promote with /gsd-review-backlog when ready)
Phase 999.11: Initial setup wizard — first-run config of env vars, app passwords, DB connection (BACKLOG)
Goal: [Captured for future planning] Add a first-run setup wizard that walks the administrator through defining all bootstrap configuration instead of hand-editing .env / docker-compose.yml:
- App environment variables: OIDC client id/secret/issuer/redirect URI + external URL, session signing secret (
OIDC_AUTH_SECRET),APP_PASSWORD_ENCRYPTION_KEY, and the VAPID keypair (subject + public + private). - MariaDB connection: host/port/user/password/db, with a connectivity test.
- First Fastmail app password for the initial member, encrypted on save.
Wizard should validate inputs before completing — e.g. VAPID private key decodes to 32 bytes AND pairs with the public key, OIDC discovery resolves, DB connects, app-password reaches CalDAV.
Context: Motivated by setup friction observed 2026-06-10 — a VAPID private key truncated on paste into .env silently broke push (setVapidDetails failed — 32 bytes), and DB_HOST / dev overrides must currently be set by hand. A guided + validated wizard would have caught these. Related: 999.10 (ongoing admin Settings) and 999.5 (member onboarding). Tags: onboarding, setup, install, env, vapid, mariadb, oidc.
Promoted into v1.1 Phase 12 (Initial Setup Wizard) — SETUP-01/02/03/04. Backlog entry retained for history.
Requirements: TBD Plans: 0 plans
Plans:
- TBD (promote with /gsd-review-backlog when ready)
Phase 999.12: Assistant-driven mobile-browser UI testing (mobile viewport + authed PWA) (BACKLOG)
Goal: [Captured for future planning] Give the assistant a way to validate UI/UX changes in a mobile browser experience, not just desktop Chromium. Today playwright-cli drives a desktop viewport, and the prod stack enforces OIDC (Authelia) so the authed PWA can't be reached headlessly — which is exactly why a string of mobile-only defects this milestone (silent Android notifications, the dead "How to enable" link, iOS/Android session-cookie persistence, install/standalone behaviour) could only be found by the operator on real devices, not by the assistant.
What this needs (any subset):
- Mobile viewport + UA emulation in the browser harness (e.g. Playwright device descriptors — iPhone/Pixel viewport, touch, mobile user-agent) so layout, tap targets, and responsive behaviour can be checked.
- An authenticated entry path for automated runs so the assistant can reach the real PWA past Authelia — e.g. a reusable saved storage-state/cookie, a test-only bypass on a non-prod host, or driving the Authelia login once and reusing the session. (Note: this overlaps the existing
DEV_AUTH_BYPASS, but that only works on the host-side dev stack, not the prod-mode PWA that has the real service worker. A mobile, authed, SW-enabled target is the gap.) - Optionally: a documented way to point the harness at the Pangolin HTTPS URL with a persisted session, and/or remote-debug a real device.
Boundary: genuinely device-only behaviour (iOS-Safari standalone push, real APNs/FCM delivery, OS notification-channel importance) still needs a human — this item is about everything SHORT of that (responsive layout, tap flows, in-page notification UI states, auth redirects) which a mobile-emulated authed browser could cover but currently can't.
Context: Surfaced 2026-06-10 during Phase 5 UAT — repeated mobile-only bugs were caught only by the operator because the assistant had no mobile, authenticated browser to test in. Related: feedback-playwright-verify (use playwright-cli over manual verification — this extends it to mobile/authed). Tags: testing, playwright, mobile, pwa, oidc, dx.
Promoted into v1.1 Phase 7 (Mobile Test Harness) — TEST-01/TEST-02. v1.1 scopes the
DEV_AUTH_BYPASSdev-build path; the prod-SW authed-mobile target stays deferred. Backlog entry retained for history.
Requirements: TBD Plans: 0 plans
Plans:
- TBD (promote with /gsd-review-backlog when ready)
Phase 999.13: Reduce event write-back latency to the calendar provider (outbox drain) (BACKLOG)
Goal: [Captured for future planning] Calendar create/edit/delete writes are enqueue-only (calendarOutbox, 202 optimistic-accept; D-12/D-05 — no Fastmail call in the route) and flushed to Fastmail by runOutboxDrain on a 15-second setInterval (apps/api/src/broker/outboxWorker.ts). So a change can take up to ~15s to land in Fastmail (and longer to reflect back in the app, which depends on the separate 5-min poller). Reduce that perceived sync delay so edits feel near-immediate.
Options to weigh when picking this up:
- Event-driven drain (preferred): trigger an outbox drain immediately after a successful enqueue (in-process signal, or Redis pub/sub which is already available) so the write fires within ~1s instead of waiting for the next tick — keep the 15s
setIntervalas a fallback/retry sweep. Must preserve the existing per-row etag/412 handling and the rapid-successive-edit ordering (see outboxWorker comments ~L312 — each edit carries its enqueue-time etag). - Shorter interval: simplest, but more idle DB polling; a floor (e.g. 3–5s) trades latency for load.
- Faster read-back too: the user also sees latency from the 5-min poller reflecting the change back. Consider invalidating/short-poll after a local write, or optimistic UI already covering it — confirm whether the perceived delay is the write (15s) or the read-back (5min).
Boundary: the optimistic 202 + outbox durability design (create-before-delete, drain concurrency guard, fresh-etag-before-PUT) must be preserved — this is a latency tune, not a rewrite of the write path.
Context: Surfaced 2026-06-10. Tags: calendar, write-back, outbox, latency, redis, performance.
Promoted into v1.1 Phase 9 (Faster Write-Back) — CAL-15. In-process EventEmitter chosen (not Redis); the drain is single-process by design. Backlog entry retained for history.
Requirements: TBD Plans: 0 plans
Plans:
- TBD (promote with /gsd-review-backlog when ready)
Phase 999.14: Gitea CI — full regression on PR to main + build/publish Docker image (BACKLOG)
Goal: [Captured for future planning] The repo is committed against a self-hosted Gitea instance with a registered Actions runner, but there is no CI yet (no .gitea/workflows/ or .github/workflows/). Two things should run automatically: (1) full regression on every PR targeting main — gating the merge; (2) build the app's Docker image and publish it to the Gitea container registry.
Options / decisions to make when picking this up:
- Test scope: "full regression" = lint + typecheck + unit + the API integration tests. Integration tests need a real MariaDB (see api-integration-test-db) — the workflow must spin up a MariaDB service container, bind it, and set
DB_HOST=127.0.0.1+.envcreds. The PWA build/test also runs. - Monorepo: pnpm workspace (
apps/api,apps/pwa, shared). Cache the pnpm store. - Docker images: only
apps/api/Dockerfileexists today — there is no PWA Dockerfile yet. Decide one image (API) vs. also building/serving the PWA. Tag scheme + when to publish (only on merge tomain? on tags? per-PR?). - Registry auth: push to the Gitea registry using the runner's Gitea-provided token or a dedicated package-write token.
- Gitea Actions are GitHub-Actions-compatible syntax but run on the self-hosted runner — confirm runner labels and available images, and that Actions is enabled, before authoring.
Likely shape: a .gitea/workflows/ci.yml — on: pull_request (to main) → install (pnpm), lint, typecheck, unit, API integration vs. a mariadb service container, PWA build; on: push to main/tag → docker build apps/api/Dockerfile, login, push tagged image.
Context: Promoted from STATE.md pending todo (.planning/todos/pending/2026-06-10-gitea-ci-regression-and-docker-publish.md), surfaced 2026-06-10. Tags: tooling, ci, gitea, docker, mariadb, monorepo.
Promoted into v1.1 Phase 8 (Gitea CI) — CI-01/CI-02. v1.1 also extends CI-01 to run the Phase 7 mobile harness as a UI-regression step (CI brings up the dev stack in the runner). Backlog entry retained for history.
Requirements: TBD Plans: 0 plans
Plans:
- TBD (promote with /gsd-review-backlog when ready)
Phase 999.15: Desktop e2e coverage — add a Desktop Playwright profile + desktop-safe specs (BACKLOG)
Goal: [Captured for future planning] The Playwright harness (apps/pwa/playwright.config.ts) defines only mobile device profiles — iphone (iPhone 14 / WebKit) and pixel (Pixel 7 / Chromium), both with touch and a mobile viewport. The Phase 8 CI regression gate runs pnpm test:e2e, so it currently validates the mobile experience only. Add desktop coverage so the regression gate exercises the desktop layout/flows as well.
Options / decisions to make when picking this up:
- Add a Desktop profile: a new
desktopproject inplaywright.config.ts(e.g.devices['Desktop Chrome'], nohasTouch, wide viewport). Optionally a Desktop WebKit/Safari profile too — but the family's Apple member is already covered on mobile Safari viaiphone; Desktop Chrome is likely sufficient for a shared/wall browser. - Spec-compat pass (the real work): the existing e2e specs were authored for mobile — they may assume touch gestures, a mobile nav/drawer, or mobile-only layout. Each spec needs review/adjustment so it passes (or is appropriately skipped) on a no-touch, wide-viewport desktop. This is harness/spec work, not CI plumbing.
- Gating choice: decide whether desktop runs block the merge immediately, or run advisory (non-blocking) until the specs are confirmed desktop-safe.
Boundary: Phase 8 deliberately reused the Phase 7 harness unchanged (CI owns only stack bring-up + readiness waits, not spec content), which is why this was deferred. Once a Desktop project is added to the config, Phase 8 CI picks it up automatically via pnpm test:e2e — no CI changes needed beyond whatever runtime/wait the desktop profile requires.
Context: Deferred from Phase 8 (Gitea CI) planning, 2026-06-11 — user wants both mobile and desktop validated, but desktop needs a config addition + spec review that is out of Phase 8's CI-plumbing scope. Tags: testing, playwright, e2e, desktop, harness, ci.
Promoted into v1.1 Phase 14 (Desktop E2E Coverage) — 2026-06-11. Backlog entry retained for history.
Requirements: TBD Plans: 0 plans
Plans:
- TBD (promote with /gsd-review-backlog when ready)
Phase 999.16: Wire a real linter (ESLint) so the CI lint gate actually fails on violations (BACKLOG)
Goal: [Captured for future planning] The Phase 8 CI fast-checks job runs pnpm lint, but no linter exists in the repo — the root lint script is pnpm -r --if-present lint, which finds no package-level lint script and exits 0. The lint gate is a hollow placeholder that can never fail. Wire up a real linter so it runs and gates merges on lint violations. (typecheck/tsc already gates type errors meanwhile.)
Options / decisions to make when picking this up:
- Tooling: ESLint flat config (
eslint.config.js) withtypescript-eslint; add React + react-hooks plugins forapps/pwa. Addeslint(+ plugins) as devDeps and alintscript toapps/apiandapps/pwa—pnpm -r --if-present lintthen picks them up automatically, no CI change needed. - Rule strictness: pick a baseline (recommended vs strict-type-checked). Stricter = more upfront violations to fix.
- Violation cleanup (the real work): the first run surfaces existing violations across both apps. Decide per-rule: fix, downgrade to warn, or disable. The gate must end green.
- Gating choice: blocking on merge immediately, or advisory (warn-only) until the codebase is clean.
Boundary: Phase 8 deliberately scoped lint wiring out (CI-plumbing-only); it shipped the gate slot wired to auto-activate once a package lint script lands. This item is that follow-up.
Context: Raised during Phase 8 execution, 2026-06-11 — user noted the --if-present lint step "didn't fix the linter, just made it so it didn't have to exist to proceed" and wants a lint gate that actually fails. Tags: ci, lint, eslint, typescript-eslint, quality, gitea.
Promoted into v1.1 Phase 13 (Real Lint Gate / ESLint) — 2026-06-11. Backlog entry retained for history.
Requirements: TBD Plans: 0 plans
Plans:
- TBD (promote with /gsd-review-backlog when ready)