# Roadmap: FamilySync

## Milestones

- ✅ **v1.0 MVP** — Phases 1–6 (shipped 2026-06-10) — see [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md)
- 🚧 **v1.1 Operability & Polish** — Phases 7–15 (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

## Phases

<details>
<summary>✅ v1.0 MVP (Phases 1–6) — SHIPPED 2026-06-10</summary>

- [x] Phase 1: Foundation + Broker Spike (4/4 plans) — completed 2026-06-04
- [x] Phase 2: Calendar Display (5/5 plans) — completed 2026-06-05
- [x] Phase 3: Event Write-Back + PWA Install (12/12 plans) — completed 2026-06-07
- [x] Phase 4: Shared Lists + Live Sync (7/7 plans) — completed 2026-06-09
- [x] Phase 5: Web Push Notifications (8/8 plans) — completed 2026-06-10
- [x] Phase 6: UX Polish (6/6 plans) — completed 2026-06-10

Full phase detail archived in [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md).

</details>

### 🚧 v1.1 Operability & Polish (Phases 7–15)

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.

- [x] **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)
- [x] **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)
- [x] **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
- [x] **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)
- [x] **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)
- [x] **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-checks` so docs get a fast format+lint gate (promoted from backlog 999.17) (completed 2026-06-12)

## Phase Details

> v1.0 phase detail (Phases 1–6) is archived in [`milestones/v1.0-ROADMAP.md`](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):

  1. An automated run can load the PWA in a mobile-emulated viewport (device profile + mobile UA + touch) and assert on responsive layout / tap targets.
  2. The automated run reaches the authenticated PWA via the existing `DEV_AUTH_BYPASS` on the host-side dev stack — no manual login and no Authelia/OIDC mocking.
  3. The harness runs repeatably day-over-day without re-capturing any session state (no stale storage-state failures).
  4. 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=true` for 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**

- [x] 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)*

- [x] 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)*

- [x] 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)
- [x] 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):

  1. Opening or updating a PR targeting `main` triggers 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.
  2. 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.
  3. 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.
  4. 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.
  5. On merge to `main`, the API Docker image is built and pushed to the Gitea container registry under a sensible tag.
  6. 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 the `self-hosted` runner before any test or build steps are designed; pin Node 22 explicitly, do not assume `actions/setup-node` works as on GitHub.
  - **MariaDB readiness wait** (Pitfall 11): add an explicit readiness loop (e.g. `healthcheck.sh --connect --innodb_initialized`, NOT `mysqladmin ping` which is removed in MariaDB 11) before any `drizzle-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=true` so the harness reaches the authed PWA exactly as in Phase 7.
  - **--password-stdin** (Pitfall 13): `docker login` via `--password-stdin` with the token piped from a registered Gitea secret (PAT with `write:package`); never `-p $TOKEN` on the command line.
  - Hard constraints: API integration tests need a real MariaDB and live in `apps/api/tests/` (never `src/`); 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**

- [x] 08-01-PLAN.md — Runner probe + operator runner/PAT registration (W0; answers the Docker-vs-host fork)

**Wave 2** *(blocked on Wave 1 completion)*

- [x] 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)*

- [x] 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)*

- [x] 08-04-PLAN.md — ci.yml: publish job (build production image + push :latest + :v1.1-<sha> 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):

  1. 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.
  2. The route handler still returns an optimistic 202 immediately and never makes a CalDAV call inline — the event-driven signal is fire-and-forget.
  3. 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.
  4. No duplicate CalDAV PUTs occur for the same outbox row when the signal and the 15s fallback interval overlap (exactly-once per uid preserved).
  5. The 15s `setInterval` fallback 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 `drainRequested` flag funnelled through the single setInterval-controlled path / the existing `isDraining` guard — never call `runOutboxDrain()` 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: `setInterval` only (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**

- [x] 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)*

- [x] 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):

  1. 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).
  2. 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.
  3. An admin can pick which synced calendar is the shared family calendar from a list, and the `calendars.is_shared` flag updates accordingly (replacing the manual `UPDATE calendars SET is_shared=1` step).
  4. 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.
  5. 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 `requireAdmin` with `.use('*', ...)` inside `adminRouter`, 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 `hook` returns a generic 400 (no Zod `received`/`value` field); no `console.log` of request bodies in `routes/admin*`.
  - Hard constraints: Drizzle **generate+migrate, never push** (false destructive diff on populated MariaDB); reuse `broker/crypto.ts` `encryptPassword` (no changes to crypto); `/api/admin/credentials` and `/api/admin/calendars/:id/shared` are 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):

  1. 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.
  2. 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.
  3. 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.
  4. An event with no reminder set produces no reminder push (no default 15-minute fire).
  5. 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; `outboxPayloadSchema` distinguishes "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 no `VALUE=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 `uid` to compound `uid:dtstartMs` and widen the scan to a variable per-event window so long leads fire and rescheduled events re-fire; keep `eventFieldsSchema` and `outboxPayloadSchema` in sync (IN-03).
  - Hard constraints: `setInterval` only; scheduler reads `reminder_lead_minutes` from the DB (ground truth), not the outbox payload; drop the `isShared`-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):

  1. On a fresh install with nothing configured, the operator reaches a setup wizard (via `GET /api/setup/status` mounted before the OIDC guard) and walks through bootstrap steps instead of editing files by hand.
  2. 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).
  3. 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_KEY` never enter the DB at all.
  4. After completion, the wizard-completing user is promoted to admin (`is_admin`), `app_config.setup_complete` is set, and any further call to a setup endpoint returns 423 Locked.
  5. 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_key` or `app_password_encryption_key`; never log/echo the app password.
  - Hard constraints: `GET /api/setup/status` mounts **before** the OIDC guard (like `/health`); do NOT create `/api/setup/credentials` — reuse the Phase 10 admin routes; Drizzle generate+migrate (any `app_config` seeding 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):

  1. `pnpm lint` runs ESLint across both `apps/api` and `apps/pwa` and exits non-zero on an introduced violation (verified by a deliberate test violation), where today it exits 0 with no linter present.
  2. The CI `fast-checks` lint step blocks a PR to main on lint violations — the gate can now fail.
  3. 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)

- [x] 13-01-PLAN.md — Install ESLint/Prettier deps + flat config + package scripts + prove the gate fails (SC-1)
- [x] 13-02-PLAN.md — Fix all first-run lint violations across both apps, green `pnpm lint` (D-13-05/06)
- [x] 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):

  1. A `desktop` project exists in `playwright.config.ts` (Desktop Chrome, wide viewport, no `hasTouch`).
  2. The existing e2e specs pass (or are explicitly, justifiably skipped) on the desktop profile — touch-gesture / mobile-drawer / mobile-only-layout assumptions are handled.
  3. `pnpm test:e2e` in 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:

- [x] 14-01-PLAN.md — Add the `desktop` Playwright project, desktop-skip the two mobile-only layout assertions (+ D-04 parity), update spec/README docs, and prove `pnpm test:e2e` is 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):

  1. A doc-only PR to `main` (only `docs/` or `*.md` changed) skips the `api` and `harness` jobs but still runs `fast-checks`.
  2. A PR touching code runs `fast-checks`, `api`, and `harness` as today; a failure in any blocks the merge.
  3. Branch protection requires `CI / fast-checks` + an always-running `CI / gate` aggregate (passes when each heavy job is `success` OR `skipped`) — the direct `api`/`harness` requirements are dropped so a skipped heavy job never deadlocks the merge.
  4. `fast-checks` runs 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 `gate` job (`if: always()`, passes on `success`/`skipped`) is the only safe gating surface.
  - **Gitea skipped-status quirk** — Gitea may not emit a commit-status for a `skipped` job; rely on the always-running `gate`, not on marking `api`/`harness` skipped-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 to `docs/` + repo-root/app `*.md` and exclude `.planning/**` (and any generated markdown) to avoid a baseline cleanup of churny bookkeeping files.

**Plans**: 3 plans (3 waves)
Plans:
**Wave 1**

- [x] 15-01-PLAN.md — markdownlint-cli2 + `.markdownlint-cli2.jsonc` + `md:lint` script + fast-checks step + fix 13 baseline violations (SC-4)

**Wave 2** *(blocked on Wave 1 completion)*

- [x] 15-02-PLAN.md — ci.yml: `changes` (dorny/paths-filter@v4) + conditional api/harness + always-running `gate` aggregate (SC-1/SC-2, SC-3 YAML)

**Wave 3** *(blocked on Wave 2 completion)*

- [x] 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

## 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 |

## 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:** 2/2 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 `VALARM`s 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_shared` from 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 leaves `is_shared` untouched, 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_BYPASS` dev-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 `setInterval` as 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` + `.env` creds. The PWA build/test also runs.
- **Monorepo:** pnpm workspace (`apps/api`, `apps/pwa`, shared). Cache the pnpm store.
- **Docker images:** only `apps/api/Dockerfile` exists 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 to `main`? 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 `desktop` project in `playwright.config.ts` (e.g. `devices['Desktop Chrome']`, no `hasTouch`, wide viewport). Optionally a Desktop WebKit/Safari profile too — but the family's Apple member is already covered on mobile Safari via `iphone`; 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`) with `typescript-eslint`; add React + react-hooks plugins for `apps/pwa`. Add `eslint` (+ plugins) as devDeps and a `lint` script to `apps/api` and `apps/pwa` — `pnpm -r --if-present lint` then 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)
