docs(06): create ux-polish phase plan (6 plans, 2 waves)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Berger
2026-06-10 10:25:41 -04:00
co-authored by Claude Opus 4.8
parent d52acad54c
commit 456121969f
7 changed files with 1086 additions and 4 deletions
+17 -4
View File
@@ -220,7 +220,7 @@ Plans:
**Goal**: Smooth the rough edges surfaced during live use — clearer all-day events, saner event-form date/recurrence behavior, recurring-series editing, and auth-flow polish — so the app feels slick for the non-technical Apple member (hard UX constraint). **Goal**: Smooth the rough edges surfaced during live use — clearer all-day events, saner event-form date/recurrence behavior, recurring-series editing, and auth-flow polish — so the app feels slick for the non-technical Apple member (hard UX constraint).
**Mode:** mvp **Mode:** mvp
**Depends on**: Phase 3 (calendar/event-form polish); Phase 4 for any list-related polish **Depends on**: Phase 3 (calendar/event-form polish); Phase 4 for any list-related polish
**Requirements**: TBD **Requirements**: none (all v1 REQ-IDs complete in Phases 15; this is a polish phase tracked against backlog items 999.2/3/6/7/8/9 and locked decisions D-01..D-13)
**Success Criteria** (what must be TRUE): **Success Criteria** (what must be TRUE):
1. All-day events are visually distinct from timed events at a glance 1. All-day events are visually distinct from timed events at a glance
@@ -229,9 +229,22 @@ Plans:
4. A session that expires mid-use redirects cleanly to sign-in instead of hanging on a generic error 4. A session that expires mid-use redirects cleanly to sign-in instead of hanging on a generic error
5. Unauthenticated cold load shows a neutral "signing you in…" splash — no calendar/"sign-in required" flash before Authelia 5. Unauthenticated cold load shows a neutral "signing you in…" splash — no calendar/"sign-in required" flash before Authelia
**Candidate scope** (promote via `/gsd-review-backlog` at planning): 999.2 (login flash), 999.3 (session-timeout redirect), 999.6 (all-day visual), 999.7 (form end-tracking + all-day-edit off-by-one), 999.8 (recurrence bound), 999.9 (recurring-series edit). 999.4 (reminders) and 999.5 (provider setup) are more feature than polish — decide at planning. **Scope** (promoted from backlog, locked at planning): 999.2 (login flash), 999.3 (session-timeout redirect), 999.6 (all-day visual), 999.7 (form end-tracking + all-day-edit off-by-one), 999.8 (recurrence bound), 999.9 (recurring-series edit). 999.4 (reminders) and 999.5 (provider setup) deferred to milestone 1.1 (D-01/D-02).
**Plans**: 6 plans (2 waves)
Plans:
**Wave 1** *(parallel — exclusive file ownership)*
- [ ] 06-01-PLAN.md — TDD: duration-preserving end-tracking math (computeNewTimedEnd/computeNewAllDayEnd) in eventDateTime.ts (D-04)
- [ ] 06-02-PLAN.md — TDD: RRULE UNTIL/COUNT serialization + Zod acceptance + FREQ-persistence regression (vevent/outboxWorker/events route) (D-06/D-07)
- [ ] 06-03-PLAN.md — TDD: hasRrule on CalendarOccurrence + bounded-expansion lock (expand.ts) (D-06/D-08)
- [ ] 06-04-PLAN.md — Spinner/pulse: global @keyframes pulse + remove redundant spin redefinition (D-13)
- [ ] 06-05-PLAN.md — Auth gating slice: SessionExpiredError + AuthSplash + global QueryCache/MutationCache error handler; client.ts type mirrors (D-10/D-11, + D-06/D-08 type carriers)
**Wave 2** *(blocked on 06-01/02/03/05)*
- [ ] 06-06-PLAN.md — EventForm integration slice: end-tracking wiring + recurrence-bound control + series-edit prompt + all-day pill (D-03/D-04/D-05/D-06/D-07/D-08/D-09/D-12)
**Plans**: TBD
**UI hint**: yes **UI hint**: yes
## Progress ## Progress
@@ -247,7 +260,7 @@ Note: Phase 4 depends only on Phase 1 and can begin as soon as Phase 1 is comple
| 3. Event Write-Back + PWA Install | 12/12 | Complete | 2026-06-07 | | 3. Event Write-Back + PWA Install | 12/12 | Complete | 2026-06-07 |
| 4. Shared Lists + Live Sync | 6/6 | Complete | 2026-06-09 | | 4. Shared Lists + Live Sync | 6/6 | Complete | 2026-06-09 |
| 5. Web Push Notifications | 8/8 | Complete | 2026-06-10 | | 5. Web Push Notifications | 8/8 | Complete | 2026-06-10 |
| 6. UX Polish | 0/? | Not started | - | | 6. UX Polish | 0/6 | Planned | - |
## Backlog ## Backlog
+146
View File
@@ -0,0 +1,146 @@
---
phase: 06-ux-polish
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
- apps/pwa/src/lib/eventDateTime.ts
- apps/pwa/src/lib/eventDateTime.test.ts
autonomous: true
requirements: []
must_haves:
truths:
- "On any start change, the event end preserves its current duration (D-04)"
- "The end never strands behind the start day — at worst it snaps to the same day/+1h (D-04 floor)"
artifacts:
- path: "apps/pwa/src/lib/eventDateTime.ts"
provides: "computeNewTimedEnd + computeNewAllDayEnd pure end-tracking helpers"
contains: "computeNewTimedEnd"
- path: "apps/pwa/src/lib/eventDateTime.test.ts"
provides: "RED-then-GREEN unit coverage for duration preservation + floor rule"
contains: "computeNewTimedEnd (D-04"
key_links:
- from: "apps/pwa/src/lib/eventDateTime.ts"
to: "apps/pwa/src/lib/eventDateTime.test.ts"
via: "vitest unit assertions"
pattern: "computeNewTimedEnd|computeNewAllDayEnd"
---
<objective>
Deliver the duration-preservation math for event end-tracking (D-04) as two pure, fully-tested functions in `apps/pwa/src/lib/eventDateTime.ts`. These are the load-bearing logic behind the 999.7 fix: when a user moves an event's start, the end must follow so the event keeps its duration instead of stranding behind the start and producing absurd multi-month spans.
This plan ships the math ONLY (TDD: tests first). Wiring these helpers into the `EventForm` start `onChange` handlers happens in the EventForm integration slice (Plan 06), which depends on this plan's exported symbols.
Purpose: End-tracking math is deterministic input→output logic — the canonical TDD candidate. Isolating it from the React component keeps the cycle fast and the behavior verifiable without rendering.
Output: `computeNewTimedEnd` and `computeNewAllDayEnd` exported from `eventDateTime.ts`, green under `pnpm --filter @familysync/pwa test`.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-ux-polish/06-RESEARCH.md
@.planning/phases/06-ux-polish/06-PATTERNS.md
@.planning/phases/06-ux-polish/06-UI-SPEC.md
</context>
<artifacts_this_plan_produces>
NEW symbols introduced here (exclude from any drift/convergence check — they did not exist before this phase):
- `computeNewTimedEnd(newStartDate, newStartTime, oldStartDate, oldStartTime, oldEndDate, oldEndTime): { endDate, endTime }` in `apps/pwa/src/lib/eventDateTime.ts`
- `computeNewAllDayEnd(newStartDate, oldStartDate, oldEndDate): string` in `apps/pwa/src/lib/eventDateTime.ts`
- Any private date helpers these need (e.g. `dateDiffDays`, `addDaysISO`, `localDateISO`, `localTimeHHMM`) — add only if not already present in the file; reuse existing local-accessor helpers where they exist.
</artifacts_this_plan_produces>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: RED — failing tests for computeNewTimedEnd / computeNewAllDayEnd</name>
<files>apps/pwa/src/lib/eventDateTime.test.ts</files>
<read_first>
- apps/pwa/src/lib/eventDateTime.test.ts — copy the existing `import { describe, it, expect } from 'vitest'` header and `describe/it/expect` structure (the existing `serializeEventDateTime` block is the exact analog)
- apps/pwa/src/lib/eventDateTime.ts — existing `serializeEventDateTime`, `localWallClockToUtcIso`, and `parseDateTime` local-accessor pattern (RESEARCH §Focus 4; PATTERNS §eventDateTime.ts)
- .planning/phases/06-ux-polish/06-RESEARCH.md §"End-tracking pure functions (TDD target)" — exact signatures + the delta/floor algorithm
- .planning/phases/06-ux-polish/06-UI-SPEC.md §"Surface 4" — the behavior contract (timed: newEnd = newStart + (oldEnd oldStart); floor snaps to +1h timed / same-day all-day)
</read_first>
<behavior>
- computeNewTimedEnd preserves a 1-hour delta: old 09:00→10:00 on a day, new start moved forward → new end is exactly 1h after new start, same date when within the day.
- computeNewTimedEnd preserves a multi-day timed delta (e.g. 26h) when start moves.
- computeNewTimedEnd floor rule: when oldEnd <= oldStart (already-invalid stale state), new end snaps to newStart + 1h (never behind start).
- computeNewAllDayEnd preserves a 0-day span (single-day all-day event) → new inclusive end equals new start date.
- computeNewAllDayEnd preserves a 3-day span → new inclusive end is newStart + 3 days.
- computeNewAllDayEnd floor rule: when oldEnd < oldStart, new end snaps to new start (same day).
</behavior>
<action>
Add a new `describe('computeNewTimedEnd (D-04 — end-tracking)', ...)` block and a `describe('computeNewAllDayEnd (D-04 — all-day end-tracking)', ...)` block alongside the existing tests. Import the two not-yet-existing functions from `./eventDateTime.js`. Write the six cases listed in the behavior block, asserting on returned `endDate` ('YYYY-MM-DD') and `endTime` ('HH:MM') strings. Use concrete dates (e.g. start 2026-06-11) so assertions are exact. Run the suite and CONFIRM RED — the import resolves to undefined and tests fail with a missing-export / call-of-undefined error (NOT a syntax/import-path error). Per CLAUDE.md WR-05: assertions must reflect LOCAL wall-clock dates, never UTC-sliced dates. Commit: `test(06-01): add failing tests for end-tracking duration math`.
</action>
<verify>
<automated>cd apps/pwa && pnpm test -- run lib/eventDateTime 2>&1 | grep -E "computeNewTimedEnd|computeNewAllDayEnd" && echo "RED block present"</automated>
</verify>
<acceptance_criteria>
- eventDateTime.test.ts contains both new `describe` blocks with the six named cases.
- Running the suite shows the new tests FAILING (RED) due to the missing exports, not due to an import-path typo.
- A `test(06-01): ...` commit exists.
</acceptance_criteria>
<done>Six new failing tests describe duration preservation + floor behavior for timed and all-day; RED confirmed and committed.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: GREEN — implement the two end-tracking helpers</name>
<files>apps/pwa/src/lib/eventDateTime.ts</files>
<read_first>
- apps/pwa/src/lib/eventDateTime.ts — `serializeEventDateTime` export style + `parseDateTime` (lines ~107133) local-accessor pattern (getFullYear/getMonth/getDate/getHours/getMinutes) — WR-05 constraint
- .planning/phases/06-ux-polish/06-PATTERNS.md §"apps/pwa/src/lib/eventDateTime.ts" — the exact function bodies to mirror, including the 1h floor and `Math.max(0, dateDiffDays(...))` span
- .planning/phases/06-ux-polish/06-RESEARCH.md §"End-tracking pure functions" — algorithm and the `60 * 60 * 1000` floor constant
</read_first>
<action>
Implement `computeNewTimedEnd` and `computeNewAllDayEnd` exactly per the signatures in RESEARCH §"End-tracking pure functions (TDD target)". Timed: compute `oldStartMs`/`oldEndMs` via `new Date(\`${date}T${time}:00\`).getTime()`, set `deltaMs = oldEndMs > oldStartMs ? oldEndMs - oldStartMs : 60*60*1000` (the 1h floor), then `newEnd = new Date(newStartMs + deltaMs)` and return `{ endDate, endTime }` formatted through LOCAL accessors (do NOT use `toISOString().slice(0,10)` — that returns UTC date; this is the WR-05 trap called out in PATTERNS). All-day: `span = Math.max(0, dateDiffDays(oldStartDate, oldEndDate))`, return `addDaysISO(newStartDate, span)` — the `Math.max(0, …)` is the floor (a stale negative span collapses to same-day). If `dateDiffDays`/`addDaysISO`/`localDateISO`/`localTimeHHMM` helpers are not already in the file, add them as small private functions using local Date accessors. Run the suite to GREEN. Commit: `feat(06-01): implement duration-preserving end-tracking helpers`.
</action>
<verify>
<automated>cd apps/pwa && pnpm test -- run lib/eventDateTime</automated>
</verify>
<acceptance_criteria>
- `computeNewTimedEnd` and `computeNewAllDayEnd` are exported from eventDateTime.ts.
- All six new tests pass; the pre-existing eventDateTime tests still pass (no regression).
- Date formatting uses local accessors only (grep: no `toISOString().slice` in the new helpers).
- A `feat(06-01): ...` commit exists after the `test(06-01): ...` commit (RED→GREEN order).
</acceptance_criteria>
<done>Both helpers implemented; full eventDateTime suite green; RED→GREEN commit order present.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| (none new) | Pure client-side date arithmetic on already-trusted local form state. No network, no untrusted input, no persistence. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-01 | Tampering | computeNewTimedEnd / computeNewAllDayEnd | accept | Pure functions over local strings; no trust boundary crossed. Output is re-validated downstream by the existing serialize/write path (vevent.ts WR-04). UI/logic only — no new attack surface. |
</threat_model>
<verification>
- `cd apps/pwa && pnpm test -- run lib/eventDateTime` is green.
- Git log shows `test(06-01)` before `feat(06-01)`.
- No change to any file outside `eventDateTime.ts` / `eventDateTime.test.ts`.
</verification>
<success_criteria>
- D-04 duration-preservation and floor rules are encoded as passing unit tests.
- Two exported helpers are available for Plan 06 to wire into EventForm.
- No EventForm or write-path file touched (clean ownership for parallel Wave 1).
</success_criteria>
<output>
Create `.planning/phases/06-ux-polish/06-01-SUMMARY.md` when done (RED/GREEN/REFACTOR notes + commit list).
</output>
+165
View File
@@ -0,0 +1,165 @@
---
phase: 06-ux-polish
plan: 02
type: tdd
wave: 1
depends_on: []
files_modified:
- apps/api/src/broker/vevent.ts
- apps/api/src/broker/outboxWorker.ts
- apps/api/src/routes/events.ts
- apps/api/tests/broker/vevent.test.ts
- apps/api/tests/broker/outboxWorker.test.ts
autonomous: true
requirements: []
must_haves:
truths:
- "Scope fence (D-01/D-02): recurrence bounding only — NO VALARM/reminder serialization (999.4) is added to the write path; reminders are deferred to milestone 1.1"
- "A recurring series can be bounded by a repeat-until date (RRULE UNTIL) or an occurrence count (RRULE COUNT) (D-06)"
- "All-day UNTIL serializes as a DATE (YYYYMMDD); timed UNTIL serializes as a UTC DATETIME (YYYYMMDDT235959Z) (D-06, RFC 5545 §3.3.10)"
- "A 'daily' frequency selection persists as FREQ=DAILY end-to-end through the outbox (D-07)"
artifacts:
- path: "apps/api/src/broker/outboxWorker.ts"
provides: "assembleRruleString helper + UNTIL/COUNT assembly wired into the write payload"
contains: "assembleRruleString"
- path: "apps/api/src/routes/events.ts"
provides: "eventFieldsSchema accepts recurrenceUntil + recurrenceCount"
contains: "recurrenceUntil"
- path: "apps/api/tests/broker/vevent.test.ts"
provides: "UNTIL-DATE, UNTIL-DATETIME, COUNT serialization assertions"
contains: "COUNT=5"
key_links:
- from: "apps/api/src/broker/outboxWorker.ts"
to: "apps/api/src/broker/vevent.ts"
via: "assembled rruleString passed to buildVeventString"
pattern: "assembleRruleString|rruleString"
- from: "apps/api/src/routes/events.ts"
to: "apps/api/src/broker/outboxWorker.ts"
via: "recurrenceUntil/recurrenceCount in enqueued payload"
pattern: "recurrenceUntil|recurrenceCount"
---
<objective>
Deliver the server-side recurrence-bounding write path (D-06) and the FREQ-persistence regression lock (D-07). This is the API half of 999.8: the event write path must serialize `RRULE UNTIL`/`COUNT` correctly (value-type-matched to DTSTART per RFC 5545), and a daily selection must round-trip as `FREQ=DAILY`.
The PWA UI control that produces `recurrenceUntil`/`recurrenceCount` is built in Plan 06; the `CreateEventPayload` type fields that carry them are added in Plan 05 (sole owner of `client.ts`). This plan owns the API contract: Zod acceptance + ical.js serialization + tests.
Purpose: RRULE serialization is deterministic ICS-string output — a TDD candidate. Per RESEARCH, adding UNTIL/COUNT does NOT affect per-occurrence duration (that bug is D-04's end-tracking, fixed in Plan 01), so this plan is self-contained on the API side.
Output: `assembleRruleString` in `outboxWorker.ts`, extended Zod schema in `events.ts`, green `vevent.test.ts` + `outboxWorker.test.ts`.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-ux-polish/06-RESEARCH.md
@.planning/phases/06-ux-polish/06-PATTERNS.md
</context>
<artifacts_this_plan_produces>
NEW symbols introduced here (exclude from drift/convergence checks):
- `assembleRruleString(basePreset, until?, count?, allDay?): string` in `apps/api/src/broker/outboxWorker.ts`
- Two new optional fields on `eventFieldsSchema` (`events.ts`) and `outboxPayloadSchema` (`outboxWorker.ts`): `recurrenceUntil` ('YYYY-MM-DD'), `recurrenceCount` (int ≥ 1)
- New test cases in `vevent.test.ts` (COUNT, UNTIL DATE, UNTIL DATETIME) and `outboxWorker.test.ts` (FREQ-persistence regression, bound-assembly)
</artifacts_this_plan_produces>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: RED — failing tests for UNTIL/COUNT serialization + FREQ-persistence regression</name>
<files>apps/api/tests/broker/vevent.test.ts, apps/api/tests/broker/outboxWorker.test.ts</files>
<read_first>
- apps/api/tests/broker/vevent.test.ts — existing `buildVeventString` describe block (the exact test structure to mirror; PATTERNS gives copy-ready cases)
- apps/api/tests/broker/outboxWorker.test.ts — existing outbox-worker test setup (mock DB rows, payload shape) to mirror for the FREQ regression
- apps/api/src/broker/vevent.ts — `RRULE_PRESETS` (lines 4954) + the `ICAL.Recur.fromString` + `rruleProp.setValue` serialization path (lines ~143148)
- .planning/phases/06-ux-polish/06-RESEARCH.md §"Focus 1" + §"Focus 2" + §"Code Examples" — verified ical.js 2.2.1 UNTIL/COUNT output strings; FREQ-persistence diagnosis
- .planning/phases/06-ux-polish/06-PATTERNS.md §"apps/api/tests/broker/vevent.test.ts" — three copy-ready test cases
</read_first>
<behavior>
- buildVeventString with rruleString 'FREQ=WEEKLY;COUNT=5' (timed) → ICS contains `RRULE:FREQ=WEEKLY;COUNT=5`.
- buildVeventString with rruleString 'FREQ=DAILY;UNTIL=20260630' (all-day, isDate) → ICS contains `RRULE:FREQ=DAILY;UNTIL=20260630` and does NOT contain `T235959Z`.
- buildVeventString with rruleString 'FREQ=WEEKLY;UNTIL=20260630T235959Z' (timed) → ICS contains `RRULE:FREQ=WEEKLY;UNTIL=20260630T235959Z`.
- assembleRruleString('FREQ=DAILY', undefined, 5, false) → 'FREQ=DAILY;COUNT=5'.
- assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, true) → 'FREQ=WEEKLY;UNTIL=20260630' (all-day DATE form).
- assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, false) → 'FREQ=WEEKLY;UNTIL=20260630T235959Z' (timed DATETIME form).
- assembleRruleString with BOTH until and count present → COUNT wins, UNTIL omitted (mutual exclusion, RFC 5545 §3.3.10).
- FREQ-persistence regression (D-07): an outbox payload with `recurrence:'daily'` and no bound assembles to an rruleString of exactly `FREQ=DAILY` (NOT weekly/none).
</behavior>
<action>
In `vevent.test.ts`, add the three serialization cases from PATTERNS (COUNT, UNTIL-DATE, UNTIL-DATETIME). In `outboxWorker.test.ts`, add a `describe('assembleRruleString (D-06)')` block importing the not-yet-exported helper, plus a `describe('FREQ persistence (D-07 regression)')` case asserting that a daily-recurrence payload yields `FREQ=DAILY`. Use ical.js output strings VERIFIED in RESEARCH (do not invent formats). Run both suites and CONFIRM RED (missing `assembleRruleString` export + unimplemented UNTIL/COUNT path). Commit: `test(06-02): add failing tests for RRULE UNTIL/COUNT + FREQ persistence`.
</action>
<verify>
<automated>cd apps/api && pnpm test -- run broker/vevent broker/outboxWorker 2>&1 | grep -E "COUNT=5|assembleRruleString|FREQ persistence" && echo "RED present"</automated>
</verify>
<acceptance_criteria>
- New cases exist in both test files with the exact expected ICS/RRULE strings from RESEARCH.
- Suites show the new tests FAILING for the right reason (missing export / unimplemented path), not import errors.
- A `test(06-02): ...` commit exists.
</acceptance_criteria>
<done>UNTIL/COUNT serialization + FREQ-persistence regression tests written and RED; committed.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: GREEN — assembleRruleString + Zod schema acceptance, wired into the write path</name>
<files>apps/api/src/broker/outboxWorker.ts, apps/api/src/routes/events.ts, apps/api/src/broker/vevent.ts</files>
<read_first>
- apps/api/src/broker/outboxWorker.ts — `outboxPayloadSchema` (lines 7183) and the existing RRULE assembly site (lines ~255308: `hasExplicitRecurrence`, `RRULE_PRESETS[fields.recurrence]`, preservedRrule path)
- apps/api/src/routes/events.ts — `eventFieldsSchema` (lines 100109, the `recurrence: z.enum(...)` field) — add the two optional fields here mirroring the `location`/`description` `.optional()` style
- apps/api/src/broker/vevent.ts — confirm the `ICAL.Recur.fromString(params.rruleString)` path (lines ~143148) handles UNTIL/COUNT unchanged (RESEARCH: verified — no vevent.ts logic change needed beyond receiving the assembled string)
- .planning/phases/06-ux-polish/06-PATTERNS.md §"apps/api/src/broker/outboxWorker.ts" — the exact `assembleRruleString` body + the series-edit "strip existing UNTIL/COUNT then re-apply" branch
- .planning/phases/06-ux-polish/06-RESEARCH.md §"Pitfall 1/2/3" — value-type matching, T235959Z trade-off, parse-then-modify (do NOT blindly concatenate onto a rich preserved RRULE)
</read_first>
<action>
Add `recurrenceUntil: z.string().max(10).optional()` and `recurrenceCount: z.number().int().min(1).optional()` to BOTH `eventFieldsSchema` (events.ts) and `outboxPayloadSchema` (outboxWorker.ts), mirroring the existing `.optional()` field style. Implement `assembleRruleString(basePreset, until?, count?, allDay?)` per PATTERNS: COUNT takes precedence (`;COUNT=N`); else UNTIL → all-day emits `;UNTIL=${until.replace(/-/g,'')}` (DATE form `20260630`), timed emits `;UNTIL=${...}T235959Z` (DATETIME UTC). Wire it into the existing assembly site: when an explicit preset is present, assemble `preset + bound`; on series edit where only the bound changes (preserved RRULE present, no new preset), parse the preserved RRULE, STRIP any existing `;(UNTIL|COUNT)=...` via the regex in PATTERNS, then re-apply the new bound — never naive-concatenate onto `FREQ=WEEKLY;BYDAY=...`. Pass the assembled string to `buildVeventString` unchanged. Use the verified `T235959Z` end-of-UTC-day choice for timed UNTIL (RESEARCH A2). Run suites to GREEN. Commit: `feat(06-02): serialize RRULE UNTIL/COUNT and lock FREQ persistence`.
</action>
<verify>
<automated>cd apps/api && pnpm test -- run broker/vevent broker/outboxWorker</automated>
</verify>
<acceptance_criteria>
- `assembleRruleString` is exported and produces the exact strings asserted in Task 1.
- `recurrenceUntil` + `recurrenceCount` are accepted by both Zod schemas (rejecting count < 1 and over-length until strings).
- All new tests pass; the full `vevent` + `outboxWorker` suites still pass (no regression to existing recurrence handling).
- Series-edit bound change strips-then-reapplies (a test or assertion shows `FREQ=WEEKLY;BYDAY=MO` + new UNTIL does not produce a double-UNTIL).
- `feat(06-02): ...` commit follows the `test(06-02): ...` commit (RED→GREEN).
</acceptance_criteria>
<done>UNTIL/COUNT serialize value-type-matched; daily persists as FREQ=DAILY; schemas accept the new fields; suites green; RED→GREEN order present.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → API (POST/PATCH /api/events) | `recurrenceUntil` / `recurrenceCount` are new untrusted inputs crossing into the write path and ultimately into an ICS RRULE string sent to Fastmail CalDAV. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-02 | Tampering | recurrenceUntil/recurrenceCount → RRULE string (events.ts, outboxWorker.ts) | mitigate | Zod `z.string().max(10)` on `recurrenceUntil` + `z.number().int().min(1)` on `recurrenceCount` at the route boundary; `assembleRruleString` only emits digits from a `replace(/-/g,'')` of a length-bounded string; final string is re-parsed by `ICAL.Recur.fromString` which rejects malformed RRULE — no raw passthrough to the ICS. (V5 Input Validation, ASVS L1.) |
| T-06-02b | Tampering | RRULE injection via crafted until value | mitigate | The `.replace(/-/g,'')` plus the fixed `;UNTIL=`/`;COUNT=` templates prevent injecting extra `;`-delimited RRULE parts; `ICAL.Recur.fromString` sanitizes via parse. A date that is not `YYYY-MM-DD` produces a non-date string that ical.js rejects or normalizes — fails closed (event enqueue errors), no silent corruption. |
| T-06-02-SC | Tampering | npm installs | accept | No package installs in this plan (RESEARCH: Package Legitimacy Audit not applicable — zero new deps). |
</threat_model>
<verification>
- `cd apps/api && pnpm test -- run broker/vevent broker/outboxWorker` green.
- `grep -n "recurrenceUntil" apps/api/src/routes/events.ts apps/api/src/broker/outboxWorker.ts` shows the field in both schemas.
- Git log: `test(06-02)` precedes `feat(06-02)`.
</verification>
<success_criteria>
- D-06: bounded recurrence serializes correctly, value-type-matched to DTSTART.
- D-07: daily→FREQ=DAILY regression is locked by an automated test.
- API contract (`recurrenceUntil`/`recurrenceCount`) is live for Plan 06's UI to drive.
</success_criteria>
<output>
Create `.planning/phases/06-ux-polish/06-02-SUMMARY.md` when done (RED/GREEN notes + commits; note any Fastmail UNTIL value-type observation for the verify step).
</output>
+143
View File
@@ -0,0 +1,143 @@
---
phase: 06-ux-polish
plan: 03
type: tdd
wave: 1
depends_on: []
files_modified:
- apps/api/src/broker/expand.ts
- apps/api/tests/broker/expand.test.ts
autonomous: true
requirements: []
must_haves:
truths:
- "Each expanded occurrence exposes hasRrule, true for occurrences of a recurring series and false otherwise (D-08)"
- "A bounded RRULE (e.g. COUNT=3) expands to exactly the bounded number of occurrences within a wide window, each with start→end duration (D-06 verify)"
artifacts:
- path: "apps/api/src/broker/expand.ts"
provides: "hasRrule:boolean field on CalendarOccurrence, populated from event.isRecurring()"
contains: "hasRrule"
- path: "apps/api/tests/broker/expand.test.ts"
provides: "hasRrule true/false assertions + bounded-RRULE occurrence-count assertion"
contains: "hasRrule"
key_links:
- from: "apps/api/src/broker/expand.ts"
to: "apps/pwa/src/api/client.ts (mirror, added in Plan 05)"
via: "CalendarOccurrence.hasRrule is the source-of-truth field the client mirrors"
pattern: "hasRrule"
---
<objective>
Expose `hasRrule` on the server-side `CalendarOccurrence` so the PWA can detect "this occurrence belongs to a recurring series" — the signal that gates the whole-series-edit confirmation prompt (D-08/D-09). Today the field is absent from the type on both sides, so the PWA cannot tell a recurring occurrence from a single event.
This plan owns the SERVER source-of-truth (`expand.ts`). The PWA mirror field on `client.ts`'s `CalendarOccurrence` is added in Plan 05 (sole owner of `client.ts`); the prompt UI that consumes it is built in Plan 06. Per PATTERNS, both interfaces are hand-mirrored — `expand.ts` is authoritative.
This plan also locks D-06's expansion invariant: a bounded RRULE terminates at COUNT/UNTIL and each occurrence's duration derives from DTSTART→DTEND (not the recurrence span) — RESEARCH verified no `expand.ts` logic change is needed for bounding, so this is an assertion to prevent regression.
Purpose: `hasRrule` population is deterministic transform logic over a parsed VEVENT — a TDD candidate. Isolated from the PWA, it is verifiable purely against `expandOccurrences`.
Output: `hasRrule` on `CalendarOccurrence` + population in `expandOccurrences`, green `expand.test.ts`.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-ux-polish/06-RESEARCH.md
@.planning/phases/06-ux-polish/06-PATTERNS.md
</context>
<artifacts_this_plan_produces>
NEW symbols introduced here (exclude from drift/convergence checks):
- `hasRrule: boolean` field added to the `CalendarOccurrence` interface in `apps/api/src/broker/expand.ts`
- New assertions in `apps/api/tests/broker/expand.test.ts` (hasRrule true on recurring, false on non-recurring, bounded-RRULE count)
</artifacts_this_plan_produces>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: RED — failing tests for hasRrule population + bounded expansion count</name>
<files>apps/api/tests/broker/expand.test.ts</files>
<read_first>
- apps/api/tests/broker/expand.test.ts — existing `expandOccurrences` tests + ICS fixtures (mirror the fixture-injection style for a recurring vs non-recurring VEVENT)
- apps/api/src/broker/expand.ts — `CalendarOccurrence` interface (lines 3767), the two `occurrences.push({...})` sites (non-recurring ~241256, recurring ~287302), and `event.isRecurring()` usage (~line 223)
- .planning/phases/06-ux-polish/06-RESEARCH.md §"Focus 3 — hasRrule" + §"Focus 1" (bounded expansion verified: COUNT=3 → 3 occurrences, complete=true)
- .planning/phases/06-ux-polish/06-PATTERNS.md §"apps/api/tests/broker/expand.test.ts" — copy-ready assertion patterns
</read_first>
<behavior>
- expandOccurrences on a recurring VEVENT (has RRULE) → every returned occurrence has `hasRrule === true`.
- expandOccurrences on a non-recurring VEVENT (no RRULE) → the single occurrence has `hasRrule === false`.
- expandOccurrences on a VEVENT with `FREQ=WEEKLY;COUNT=3` over a wide window → exactly 3 occurrences, and each occurrence's (end start) equals the DTSTART→DTEND duration (not the recurrence span).
</behavior>
<action>
Add assertions to (or alongside) the existing recurring-expansion test: assert `occs[0].hasRrule === true` for a recurring fixture and `occs[0].hasRrule === false` for a non-recurring fixture. Add a `FREQ=WEEKLY;COUNT=3` fixture and assert `occs.length === 3` within a multi-month window plus a per-occurrence duration assertion. Run the suite and CONFIRM RED (the `hasRrule` property is absent → TypeScript/runtime undefined on the assertion). Commit: `test(06-03): add failing tests for hasRrule + bounded expansion`.
</action>
<verify>
<automated>cd apps/api && pnpm test -- run broker/expand 2>&1 | grep -E "hasRrule|COUNT" && echo "RED present"</automated>
</verify>
<acceptance_criteria>
- expand.test.ts asserts hasRrule true (recurring) and false (non-recurring), plus the bounded-count case.
- Tests FAIL because `hasRrule` is undefined (not because of fixture/import errors).
- `test(06-03): ...` commit exists.
</acceptance_criteria>
<done>hasRrule + bounded-expansion tests written and RED; committed.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: GREEN — add hasRrule to CalendarOccurrence and populate it</name>
<files>apps/api/src/broker/expand.ts</files>
<read_first>
- apps/api/src/broker/expand.ts — `CalendarOccurrence` interface (3767) and both `occurrences.push({...})` sites; `event.isRecurring()` already called near line 223
- .planning/phases/06-ux-polish/06-PATTERNS.md §"apps/api/src/broker/expand.ts" — capture `const isRecurring = event.isRecurring()` once before the branch; pass `hasRrule: isRecurring` into both push sites
- .planning/phases/06-ux-polish/06-RESEARCH.md §"Pitfall 4" — update server type here; the client mirror is Plan 05's job (do NOT touch client.ts)
</read_first>
<action>
Add `hasRrule: boolean` to the `CalendarOccurrence` interface in `expand.ts`. In `expandOccurrences`, capture `const isRecurring = event.isRecurring()` once before the recurring/non-recurring branch, then add `hasRrule: isRecurring` to each `occurrences.push({...})` (it is `false` in the non-recurring branch, `true` in the recurring branch — using the single captured value keeps them consistent). Do NOT change the DB query or `client.ts` (the client mirror is added atomically in Plan 05). Run the suite to GREEN. Commit: `feat(06-03): expose hasRrule on expanded occurrences`.
</action>
<verify>
<automated>cd apps/api && pnpm test -- run broker/expand</automated>
</verify>
<acceptance_criteria>
- `CalendarOccurrence` in expand.ts includes `hasRrule: boolean`.
- Both push sites set `hasRrule` from the single `isRecurring` capture.
- All new + existing expand tests pass; bounded `COUNT=3` fixture yields exactly 3 occurrences.
- `feat(06-03): ...` commit follows the `test(06-03): ...` commit (RED→GREEN).
</acceptance_criteria>
<done>hasRrule populated; bounded expansion verified; expand suite green; RED→GREEN order present.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| (none new) | Read-side transform of already-cached, already-trusted calendar data. No new input crosses a boundary; `hasRrule` is derived from a parsed VEVENT the server already holds. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-03 | Information Disclosure | hasRrule on CalendarOccurrence | accept | `hasRrule` is a boolean derived from data already returned to the authenticated, access-scoped caller (existing `/api/events` ownership filter unchanged). It reveals no new information beyond "this event recurs", which is already visible from rendered occurrences. No new trust boundary. |
</threat_model>
<verification>
- `cd apps/api && pnpm test -- run broker/expand` green.
- `grep -n "hasRrule" apps/api/src/broker/expand.ts` shows the field on the interface and both push sites.
- `client.ts` untouched by this plan (ownership belongs to Plan 05).
</verification>
<success_criteria>
- D-08: the recurring-series detection signal exists server-side.
- D-06 expansion invariant (bounded count, start→end duration) is locked by test.
- Clean file ownership: only `expand.ts` + its test touched.
</success_criteria>
<output>
Create `.planning/phases/06-ux-polish/06-03-SUMMARY.md` when done (RED/GREEN notes + commits).
</output>
+143
View File
@@ -0,0 +1,143 @@
---
phase: 06-ux-polish
plan: 04
type: execute
wave: 1
depends_on: []
files_modified:
- apps/pwa/src/styles/tokens.css
- apps/pwa/src/components/PushPermissionPrompt.tsx
autonomous: false
requirements: []
must_haves:
truths:
- "Sync indicators actually animate — the SyncStateToast spinner spins and the LiveSyncIndicator reconnecting dot pulses (D-13)"
- "@keyframes pulse exists globally in tokens.css so LiveSyncIndicator's reconnecting dot animates regardless of which components are mounted (D-13)"
artifacts:
- path: "apps/pwa/src/styles/tokens.css"
provides: "global @keyframes pulse (added) alongside the existing @keyframes spin"
contains: "@keyframes pulse"
- path: "apps/pwa/src/components/PushPermissionPrompt.tsx"
provides: "redundant local @keyframes spin <style> block removed"
key_links:
- from: "apps/pwa/src/components/LiveSyncIndicator.tsx"
to: "apps/pwa/src/styles/tokens.css"
via: "animation: 'pulse 1.4s ease-in-out infinite' resolves to the global keyframe"
pattern: "@keyframes pulse"
---
<objective>
Make every sync indicator actually animate (D-13). RESEARCH corrected the original CONTEXT.md assumption: `@keyframes spin` is ALREADY global in `tokens.css` (lines 140147) and loads before any component mounts — so the spinner works. The real bugs are (1) `@keyframes pulse` is MISSING, so `LiveSyncIndicator`'s reconnecting dot (`animation: 'pulse 1.4s ease-in-out infinite'`) never animates, and (2) `PushPermissionPrompt.tsx` carries a redundant local `<style>` redefinition of `@keyframes spin` that should be removed for hygiene.
Purpose: Pure CSS/markup fix — no business logic, so a standard (non-TDD) plan. The animation presence is verified with `playwright-cli` (desktop Chromium) per the CLAUDE.md verification convention, with a grep gate confirming the keyframe is in the stylesheet.
Output: `@keyframes pulse` added to `tokens.css`; redundant `<style>` block removed from `PushPermissionPrompt.tsx`.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-ux-polish/06-RESEARCH.md
@.planning/phases/06-ux-polish/06-PATTERNS.md
@.planning/phases/06-ux-polish/06-UI-SPEC.md
</context>
<artifacts_this_plan_produces>
NEW symbols introduced here (exclude from drift/convergence checks):
- `@keyframes pulse` in `apps/pwa/src/styles/tokens.css` (0%,100% opacity:1 / 50% opacity:0.4)
- Removal of the redundant local `@keyframes spin` `<style>` block in `PushPermissionPrompt.tsx` (deletion, not a new symbol)
</artifacts_this_plan_produces>
<tasks>
<task type="auto">
<name>Task 1: Add @keyframes pulse globally; remove redundant spin redefinition</name>
<files>apps/pwa/src/styles/tokens.css, apps/pwa/src/components/PushPermissionPrompt.tsx</files>
<read_first>
- apps/pwa/src/styles/tokens.css — existing `@keyframes spin` (lines 140147) and `@keyframes shimmer` (~131138); match their format (no vendor prefixes, no `animation-fill-mode` inside the keyframe block)
- apps/pwa/src/components/PushPermissionPrompt.tsx — the redundant local `<style>{` @keyframes spin ... `}</style>` block (lines ~357363) to delete; the inline `animation: 'spin 1s linear infinite'` stays
- apps/pwa/src/components/LiveSyncIndicator.tsx — `animation: 'pulse 1.4s ease-in-out infinite'` (~line 69) — the consumer that needs the new keyframe
- apps/pwa/src/components/SyncStateToast.tsx — `animation: 'spin 1s linear infinite'` (~line 158) — already-working consumer, confirm unchanged
- .planning/phases/06-ux-polish/06-RESEARCH.md §"Focus 5" (the corrected diagnosis) + .planning/phases/06-ux-polish/06-UI-SPEC.md §"Animation Contract" (exact pulse keyframe)
</read_first>
<action>
In `tokens.css`, add a `@keyframes pulse` block (0%,100% opacity 1; 50% opacity 0.4) directly after the existing `@keyframes spin` block, matching the surrounding format. In `PushPermissionPrompt.tsx`, delete ONLY the redundant local `<style>` block that redefines `@keyframes spin` (lines ~357363) — leave the component's inline `animation: 'spin ...'` style and all other markup intact, since the global definition in `tokens.css` already covers it. Do NOT touch `LiveSyncIndicator.tsx` or `SyncStateToast.tsx` (their inline `animation` references are correct and now resolve to global keyframes). Commit: `fix(06-04): add global pulse keyframe and drop redundant spin redefinition`.
</action>
<verify>
<automated>grep -v '^#' apps/pwa/src/styles/tokens.css | grep -c '@keyframes pulse' | grep -qx 1 && ! grep -q '@keyframes spin' apps/pwa/src/components/PushPermissionPrompt.tsx && cd apps/pwa && pnpm test -- run 2>&1 | tail -3</automated>
</verify>
<acceptance_criteria>
- `@keyframes pulse` is present exactly once in tokens.css (grep gate passes).
- No `@keyframes spin` remains in PushPermissionPrompt.tsx (the redundant block is gone).
- The `@keyframes spin` block in tokens.css is unchanged; LiveSyncIndicator.tsx and SyncStateToast.tsx are unmodified.
- Existing PWA test suite still passes (no regression).
</acceptance_criteria>
<done>Global pulse keyframe added; redundant spin redefinition removed; PWA tests green.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: playwright-cli — confirm spinner spins and reconnecting dot pulses</name>
<files>(verification only — no files modified)</files>
<read_first>
- .claude/skills/playwright-cli/SKILL.md — how to drive desktop Chromium and observe computed styles / animation state
- docs/deployment.md §"Running locally (host-side, no Docker)" — the two-terminal dev run command (DEV_AUTH_BYPASS=true) to bring up the PWA for browser checks
- apps/pwa/src/components/SyncStateToast.tsx + apps/pwa/src/components/LiveSyncIndicator.tsx — how to trigger the syncing / reconnecting states
</read_first>
<action>
Verification task (no code changes). Using the playwright-cli skill against desktop Chromium: (1) start the dev stack host-side per docs/deployment.md with DEV_AUTH_BYPASS=true; (2) trigger a sync so SyncStateToast renders its Loader2 spinner and observe it rotating (computed `animationName === 'spin'`, transform changing over time); (3) force LiveSyncIndicator into the reconnecting state (drop the SSE connection) and observe the reconnecting dot's opacity pulsing (`animationName === 'pulse'`, not 'none'); (4) confirm PushPermissionPrompt's spinner still rotates after its local keyframe block was removed. Capture the observed `animationName` for both indicators in the summary. This is a blocking human-verify checkpoint — pause for the operator's confirmation.
</action>
<what-built>
Global `@keyframes pulse` in tokens.css and removal of the redundant local spin keyframe. Both animations now resolve from the global stylesheet for every consumer regardless of mount order.
</what-built>
<how-to-verify>
1. Start the dev stack host-side per docs/deployment.md (DEV_AUTH_BYPASS=true), then open the PWA in desktop Chromium via playwright-cli.
2. Trigger a sync state so SyncStateToast renders its Loader2 spinner; observe the spinner is visibly rotating (computed transform changes over time / animationName === 'spin').
3. Force the LiveSyncIndicator into the reconnecting state (e.g. drop the SSE connection) and observe the reconnecting dot's opacity pulsing (animationName === 'pulse', not 'none').
4. Confirm PushPermissionPrompt's spinner (if surfaced) still rotates after removing its local keyframe block.
</how-to-verify>
<verify>
<human-check>Spinner rotates and reconnecting dot pulses in desktop Chromium; both animationName values are non-'none'.</human-check>
</verify>
<resume-signal>Type "approved" or describe which indicator did not animate.</resume-signal>
<acceptance_criteria>
- SyncStateToast spinner shows a live rotation (animationName 'spin').
- LiveSyncIndicator reconnecting dot shows a live opacity pulse (animationName 'pulse').
- No console error about an undefined keyframe.
</acceptance_criteria>
<done>Both animations verified live in desktop Chromium via playwright-cli.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| (none new) | UI/CSS only. No data, no network, no input, no auth surface touched. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-04 | (n/a) | tokens.css keyframe + style-block deletion | accept | No new trust boundary — purely a CSS keyframe addition and removal of a redundant inline style. No input, no data flow, no auth path affected. |
</threat_model>
<verification>
- `grep -v '^#' apps/pwa/src/styles/tokens.css | grep -c '@keyframes pulse'` returns 1.
- `grep -q '@keyframes spin' apps/pwa/src/components/PushPermissionPrompt.tsx` returns nothing.
- playwright-cli confirms both animations run.
</verification>
<success_criteria>
- D-13: pulse keyframe present globally; reconnecting dot animates; spinner confirmed animating; redundant redefinition removed.
</success_criteria>
<output>
Create `.planning/phases/06-ux-polish/06-04-SUMMARY.md` when done (note the playwright-cli observation of both animations).
</output>
+238
View File
@@ -0,0 +1,238 @@
---
phase: 06-ux-polish
plan: 05
type: execute
wave: 1
depends_on: []
files_modified:
- apps/pwa/src/api/client.ts
- apps/pwa/src/api/client.test.ts
- apps/pwa/src/components/AuthSplash.tsx
- apps/pwa/src/components/CalendarShell.tsx
- apps/pwa/src/main.tsx
- apps/pwa/src/store/calendarStore.ts
autonomous: false
requirements: []
must_haves:
truths:
- "Unauthenticated cold load shows a single neutral 'Signing you in' splash — no calendar shell, skeleton, or 'Sign-in required' flash before Authelia (D-10, success criterion 5)"
- "A session that expires mid-use (401 / opaqueredirect from ANY query or mutation) shows a 'Session expired' interstitial and cleanly redirects to /api/login instead of hanging (D-11, success criterion 4)"
- "Every PWA fetch wrapper detects 401/opaqueredirect and throws a typed SessionExpiredError (D-11)"
artifacts:
- path: "apps/pwa/src/api/client.ts"
provides: "SessionExpiredError class + consistent redirect:'manual' + handleAuthResponse across all fetch wrappers; recurrenceUntil/recurrenceCount on CreateEventPayload; hasRrule on CalendarOccurrence"
contains: "class SessionExpiredError"
- path: "apps/pwa/src/components/AuthSplash.tsx"
provides: "full-screen neutral auth interstitial (loading / redirecting / dead-end states)"
contains: "AuthSplash"
- path: "apps/pwa/src/main.tsx"
provides: "QueryClient wired with QueryCache+MutationCache onError that arms the session-expiry interstitial"
contains: "MutationCache"
key_links:
- from: "apps/pwa/src/main.tsx"
to: "apps/pwa/src/store/calendarStore.ts"
via: "QueryCache/MutationCache onError → setSessionExpired(true) on SessionExpiredError"
pattern: "SessionExpiredError"
- from: "apps/pwa/src/components/CalendarShell.tsx"
to: "apps/pwa/src/components/AuthSplash.tsx"
via: "meQuery.isLoading/isError and sessionExpired flag render AuthSplash instead of calendar/alert"
pattern: "AuthSplash"
---
<objective>
Smooth the entire auth flow (D-10 + D-11) — the security-relevant slice. A single refactor serves both: gate the app render on auth state so nothing paints before Authelia (999.2), and centralize session-expiry detection so a timed-out session redirects cleanly instead of hanging (999.3).
This plan is the SOLE owner of `apps/pwa/src/api/client.ts`. To keep file ownership exclusive across the wave, it also lands the two non-auth type additions other plans depend on (consumed, not edited, elsewhere):
- `recurrenceUntil?` / `recurrenceCount?` on `CreateEventPayload` (D-06 — the API contract is in Plan 02; the EventForm UI in Plan 06 sends these).
- `hasRrule: boolean` on the client mirror of `CalendarOccurrence` (D-08 — server source-of-truth is Plan 03; the series-edit prompt in Plan 06 reads it). Per PATTERNS Pitfall 4, the mirror must match `expand.ts` exactly.
Purpose: Auth gating and session-expiry are the phase's highest-severity items (999.3 is "high"). The typed `SessionExpiredError` detection is pure I/O logic → TDD; the splash/interstitial rendering is glue → standard tasks verified with playwright-cli.
Output: `SessionExpiredError` + consistent `redirect:'manual'` in all fetch wrappers; `AuthSplash` component; gated `CalendarShell`; global QueryCache/MutationCache error handler in `main.tsx`; `sessionExpired` flag in the store.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-ux-polish/06-RESEARCH.md
@.planning/phases/06-ux-polish/06-PATTERNS.md
@.planning/phases/06-ux-polish/06-UI-SPEC.md
@apps/pwa/src/components/SkeletonCalendar.tsx
@apps/pwa/src/lib/loginRedirect.ts
</context>
<artifacts_this_plan_produces>
NEW symbols introduced here (exclude from drift/convergence checks):
- `class SessionExpiredError extends Error` in `apps/pwa/src/api/client.ts`
- `handleAuthResponse(res, label)` helper in `client.ts`
- `recurrenceUntil?: string` + `recurrenceCount?: number` on `CreateEventPayload` (client.ts)
- `hasRrule: boolean` on the client-side `CalendarOccurrence` (client.ts mirror of expand.ts)
- `AuthSplash` component (`apps/pwa/src/components/AuthSplash.tsx`) with `state: 'loading' | 'redirecting' | 'dead-end'`
- `sessionExpired` boolean + `setSessionExpired` action in the Zustand store (`calendarStore.ts`)
- QueryCache/MutationCache `onError` wiring in `main.tsx`
</artifacts_this_plan_produces>
<context_note_tanstack_v5>
RESEARCH flagged the TanStack Query v5 global-error API as an unverified assumption (A3). It is now RESOLVED via Context7 (`/tanstack/query`): in v5 the global handler is supplied by constructing `new QueryCache({ onError })` and `new MutationCache({ onError })` and passing them into `new QueryClient({ queryCache, mutationCache })`. These `onError` callbacks always fire (unlike `defaultOptions.onError`, which was removed). Do NOT use `defaultOptions.onError`. The executor MUST still run one Context7 `query-docs` confirmation against `/tanstack/query` for the exact `QueryCache`/`MutationCache` constructor signature in version 5.101.0 before coding Task 3, then implement per the confirmed API.
</context_note_tanstack_v5>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: TDD — SessionExpiredError detection across all fetch wrappers (client.ts)</name>
<files>apps/pwa/src/api/client.ts, apps/pwa/src/api/client.test.ts</files>
<read_first>
- apps/pwa/src/api/client.ts — `fetchMe` (lines 2853) is the model: `redirect:'manual'` + `if (res.type === 'opaqueredirect' || res.status === 401)`; the wrappers to generalize: `fetchEvents` (106), `createEvent` (173), `updateEvent` (194), `deleteEvent` (218), `fetchSyncStatus` (254), `fetchWritableCalendars` (273); `RecurrencePreset` (130), `CreateEventPayload` (136148), `CalendarOccurrence` (7191)
- apps/pwa/src/api/client.test.ts (or, if thin, apps/pwa/src/lib/loginRedirect.test.ts as the role analog) — how to mock `fetch` to return `{ type:'opaqueredirect', status:0 }` and `{ status:401 }`
- .planning/phases/06-ux-polish/06-RESEARCH.md §"Focus 6 — D-11" + §"Code Examples — D-11 typed error" — the exact `SessionExpiredError` class with `Object.setPrototypeOf`
- .planning/phases/06-ux-polish/06-PATTERNS.md §"apps/pwa/src/api/client.ts" + §"Shared Patterns — Auth detection" — handleAuthResponse helper + per-wrapper application
- apps/api/src/broker/expand.ts (from Plan 03) — the authoritative `CalendarOccurrence.hasRrule` field this client type must mirror exactly
</read_first>
<behavior>
- fetchEvents with a mocked `{ type:'opaqueredirect', status:0 }` response → throws `SessionExpiredError` (instanceof check passes).
- fetchEvents with a mocked `{ status:401 }` response → throws `SessionExpiredError`.
- createEvent / updateEvent / deleteEvent with a mocked 401 → each throws `SessionExpiredError`.
- A normal non-auth error (e.g. 500) → throws a generic Error, NOT SessionExpiredError (so ret/ error UI still distinguishes).
- fetchMe's existing opaqueredirect/401 path now also throws SessionExpiredError (unified) — its existing callers (CalendarShell meQuery.isError) continue to work.
</behavior>
<action>
RED: in client.test.ts add cases mocking opaqueredirect and 401 for `fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent` (and a 500 negative case), asserting `instanceof SessionExpiredError`. Run → RED (no such class / wrappers don't detect). Commit `test(06-05): add failing SessionExpiredError detection tests`.
GREEN: add the `SessionExpiredError` class (with `Object.setPrototypeOf(this, SessionExpiredError.prototype)` per RESEARCH) and a `handleAuthResponse(res, label)` helper that throws `SessionExpiredError` on `opaqueredirect || 401` and a generic Error on other non-ok. Add `redirect:'manual'` + `handleAuthResponse(...)` to EVERY fetch wrapper, mirroring `fetchMe`. Also (same file, same commit — exclusive ownership): add `recurrenceUntil?: string` and `recurrenceCount?: number` to `CreateEventPayload`, and add `hasRrule: boolean` to `CalendarOccurrence` matching the Plan 03 `expand.ts` field exactly (Pitfall 4 — atomic mirror). Run → GREEN. Commit `feat(06-05): centralize session-expiry detection and extend client types`.
</action>
<verify>
<automated>cd apps/pwa && pnpm test -- run api/client</automated>
</verify>
<acceptance_criteria>
- `SessionExpiredError` exported; all six fetch wrappers use `redirect:'manual'` + throw it on 401/opaqueredirect (grep: each wrapper references handleAuthResponse).
- 500 (non-auth) does NOT produce SessionExpiredError.
- `CreateEventPayload` has `recurrenceUntil?` + `recurrenceCount?`; `CalendarOccurrence` has `hasRrule: boolean` matching expand.ts.
- `test(06-05)` precedes `feat(06-05)` (RED→GREEN).
</acceptance_criteria>
<done>Typed session-expiry detection unified across all wrappers; client types extended; client suite green; RED→GREEN order present.</done>
</task>
<task type="auto">
<name>Task 2: AuthSplash component + gate CalendarShell render on auth state (D-10)</name>
<files>apps/pwa/src/components/AuthSplash.tsx, apps/pwa/src/components/CalendarShell.tsx</files>
<read_first>
- apps/pwa/src/components/SkeletonCalendar.tsx — full-screen centered layout + inline-style approach to mirror for AuthSplash (PATTERNS §"No Analog Found")
- apps/pwa/src/components/CalendarShell.tsx — current optimistic render: the `meQuery.isError` "Sign-in required" branch (lines ~220235), `isInitialLoading`/SkeletonCalendar, the `maybeRedirectToLogin()` effect (~197) and `clearLoginRedirect()` effect (~205), import block (~4456)
- apps/pwa/src/lib/loginRedirect.ts — `maybeRedirectToLogin()` / `clearLoginRedirect()` one-shot guard semantics
- .planning/phases/06-ux-polish/06-UI-SPEC.md §"Surface 1" (auth splash states + copy + role="status") and §"Brand Assets — In-App Logo Usage" (lockup on splash) and §"Copywriting Contract" (exact copy: heading "Signing you in", body "Taking you to the sign-in page…", dead-end "Sign-in required. Tap here to try again.")
- .planning/phases/06-ux-polish/06-PATTERNS.md §"apps/pwa/src/components/CalendarShell.tsx" — exact branch replacement
</read_first>
<action>
Create `AuthSplash.tsx`: a full-screen centered column (height:100dvh, `--color-surface` bg) with a Loader2 spinner (24px, `--color-member-0`, global `spin`), heading (18px/600) and body (15px/400, `--color-text-secondary`), `role="status"` + `aria-label="Signing you in"`. Accept a `state` prop driving copy per UI-SPEC Surface 1: `loading`/`redirecting` show the spinner + "Signing you in" / "Taking you to the sign-in page…"; `dead-end` shows "Sign-in required. Tap here to try again." with a tap handler (no spinner) that calls `clearLoginRedirect()` then `maybeRedirectToLogin()`. Render the `logo-lockup.svg` above the spinner only if the asset exists; otherwise omit gracefully (brand assets are a separate concern — do not block on them). In `CalendarShell.tsx`, REPLACE the `meQuery.isError` "Sign-in required" block with: early-return `<AuthSplash state="loading" />` when `meQuery.isLoading` (so no skeleton paints pre-auth), and `<AuthSplash state="redirecting" />` when `meQuery.isError` (the existing `maybeRedirectToLogin()` effect still fires). Keep both existing auth effects unchanged. Reserve the `dead-end` state for the one-shot-guard fall-through (guard already set). Do NOT render CalendarContent/SkeletonCalendar until `meQuery.isSuccess`. Commit `feat(06-05): gate app render behind AuthSplash (no pre-auth flash)`.
</action>
<verify>
<automated>cd apps/pwa && pnpm test -- run components/CalendarShell</automated>
</verify>
<acceptance_criteria>
- `AuthSplash` renders loading/redirecting/dead-end with the exact UI-SPEC copy and `role="status"`.
- CalendarShell returns AuthSplash for isLoading and isError; the "Sign-in required" `role="alert"` block is gone; CalendarContent/skeleton render only on isSuccess.
- Existing CalendarShell tests pass (update any test asserting the old "Sign-in required" alert to assert the splash instead).
</acceptance_criteria>
<done>No calendar/skeleton/alert paints before auth; neutral splash covers loading + redirecting; dead-end reserved for guard fall-through.</done>
</task>
<task type="auto">
<name>Task 3: Global session-expiry handler + interstitial wiring (D-11)</name>
<files>apps/pwa/src/main.tsx, apps/pwa/src/store/calendarStore.ts, apps/pwa/src/components/CalendarShell.tsx</files>
<read_first>
- apps/pwa/src/main.tsx — current `new QueryClient({...})` (lines ~1732) and QueryClientProvider mount
- apps/pwa/src/store/calendarStore.ts — existing Zustand `create(...)` shape to add `sessionExpired`/`setSessionExpired`
- apps/pwa/src/lib/loginRedirect.ts — `clearLoginRedirect()` must run BEFORE `maybeRedirectToLogin()` in the expiry path (re-arm the one-shot guard)
- .planning/phases/06-ux-polish/06-UI-SPEC.md §"Surface 2" — interstitial copy ("Session expired" / "Signing you back in…"), ≤2s before redirect, no dismiss button, 1.5s delay before `window.location.href='/api/login'`
- .planning/phases/06-ux-polish/06-RESEARCH.md §"Focus 6 Part 2" + §"Pitfall 5" and this plan's <context_note_tanstack_v5> — the v5 QueryCache/MutationCache onError API (NOT defaultOptions.onError)
- Context7 `/tanstack/query` — confirm the exact v5.101.0 `QueryCache`/`MutationCache` constructor + `onError` signature before coding (mandatory per planning context)
</read_first>
<action>
Run the Context7 confirmation first, then: add `sessionExpired: boolean` (default false) and `setSessionExpired(v)` to the Zustand store. In `main.tsx`, construct the `QueryClient` with `queryCache: new QueryCache({ onError })` and `mutationCache: new MutationCache({ onError })`, where each `onError(error)` checks `error instanceof SessionExpiredError` and calls `setSessionExpired(true)` (read the store action outside React via the store's `getState`/imperative setter pattern already used in the codebase). In `CalendarShell.tsx` (or the app root above the calendar), when `sessionExpired` is true render `<AuthSplash state="redirecting" />` with the Surface-2 copy ("Session expired" / "Signing you back in…"), and on mount of that state run `clearLoginRedirect()` then schedule `maybeRedirectToLogin()` after ~1.5s. Re-use `AuthSplash` (extend it with the session-expired copy variant rather than creating a second component — keep one interstitial component). In-flight write replay is explicitly OUT (D-11 nice-to-have, deferred per RESEARCH Open Question 3) — surfacing a clean re-auth is sufficient. Commit `feat(06-05): global session-expiry interstitial via QueryCache/MutationCache onError`.
</action>
<verify>
<automated>cd apps/pwa && pnpm test -- run 2>&1 | tail -3</automated>
</verify>
<acceptance_criteria>
- `main.tsx` uses `new QueryCache({onError})` + `new MutationCache({onError})` (NOT `defaultOptions.onError`); both route `SessionExpiredError` to `setSessionExpired(true)`.
- Store exposes `sessionExpired` + `setSessionExpired`.
- When `sessionExpired` is true the app shows the "Session expired / Signing you back in…" interstitial and fires `clearLoginRedirect()` then `maybeRedirectToLogin()` after a short delay.
- Full PWA suite still green.
</acceptance_criteria>
<done>Any query/mutation 401 surfaces the interstitial and cleanly re-auths; one-shot guard re-armed; v5 API confirmed via Context7.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 4: playwright-cli — no pre-auth flash on cold load; clean session-expiry redirect</name>
<files>(verification only — no files modified)</files>
<action>
Verification task (no code changes). Using the playwright-cli skill against desktop Chromium (dev stack host-side per docs/deployment.md, DEV_AUTH_BYPASS=true): (1) cold-load the app with no session cookie and confirm the FIRST painted frame is the neutral "Signing you in" splash — never the calendar shell, SkeletonCalendar, or a "Sign-in required" alert — then it navigates toward /api/login; (2) with an authenticated session, intercept a subsequent /api/events (or a mutation) to return 401/opaque redirect, trigger it, and confirm the "Session expired / Signing you back in…" interstitial appears then redirects within ~2s (no hang, no generic error); (3) confirm the dead-end "Sign-in required. Tap here to try again." state only appears after the one-shot guard has already fired. If any change appears to affect iOS-Safari standalone redirect behavior, flag it for the iOS human checkpoint per 06-VALIDATION.md. This is a blocking human-verify checkpoint — pause for operator confirmation.
</action>
<read_first>
- .claude/skills/playwright-cli/SKILL.md — drive desktop Chromium, clear cookies, intercept/stub responses (force a 401)
- docs/deployment.md §"Running locally (host-side, no Docker)" — dev run command
- apps/pwa/src/components/AuthSplash.tsx + CalendarShell.tsx — the surfaces under test
</read_first>
<what-built>
Auth-gated render: AuthSplash replaces the optimistic calendar/skeleton/alert on cold load; a global QueryCache/MutationCache error handler surfaces a "Session expired" interstitial and redirects on any mid-use 401.
</what-built>
<how-to-verify>
1. Cold load (D-10): in desktop Chromium with no session cookie, load the app via playwright-cli. Observe the FIRST painted frame is the neutral "Signing you in" splash — NOT the calendar shell, NOT the SkeletonCalendar, NOT a "Sign-in required" alert — then it navigates toward /api/login. Capture the sequence to confirm no calendar/alert flash.
2. Session expiry (D-11): with an authenticated session loaded, intercept a subsequent `/api/events` (or a mutation) to return 401 / an opaque redirect, trigger that request, and observe the "Session expired / Signing you back in…" interstitial appears (no hang, no generic "couldn't load events"), followed by navigation to /api/login within ~2s.
3. Confirm the dead-end "Sign-in required. Tap here to try again." state only appears after the one-shot guard has already fired (not on the first attempt).
NOTE: iOS-Safari standalone cold-load/redirect is the documented exception — if any change appears to affect standalone redirect behavior, flag it for the iOS human checkpoint per 06-VALIDATION.md Manual-Only table.
</how-to-verify>
<verify>
<human-check>Cold load shows only the splash (no calendar/skeleton/alert flash); mid-use 401 shows the session-expired interstitial then redirects cleanly.</human-check>
</verify>
<resume-signal>Type "approved" or describe the flash/hang observed.</resume-signal>
<acceptance_criteria>
- No calendar shell, skeleton, or "Sign-in required" alert paints before the redirect on cold load.
- A mid-use 401 produces the interstitial + clean redirect, not a hang or generic error.
</acceptance_criteria>
<done>Cold-load flash eliminated and mid-use session-expiry redirect verified live in desktop Chromium.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser → OIDC IdP (Authelia) | Unauthenticated/expired requests cross to the IdP via a full-page navigation to `/api/login`; the `redirect:'manual'` XHR boundary keeps cross-origin IdP redirects from being silently followed. |
| browser → API (`/api/*`) | Any query/mutation may receive a 401/opaqueredirect when the session has expired; this is the boundary where session state is enforced. |
| client render gate | The point where authenticated calendar content is allowed to paint — must occur only after `meQuery.isSuccess`. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-05-info | Information Disclosure | CalendarShell pre-auth render (D-10) | mitigate | Gate render on `meQuery.isSuccess`; AuthSplash (no app data) is the only thing painted while auth is unknown. Eliminates the 999.2 flash of calendar shell/skeleton — itself a minor disclosure of app structure before auth. (ASVS V2.) |
| T-06-05-redirect | Tampering (open redirect / loop) | maybeRedirectToLogin one-shot guard re-arm (D-11) | mitigate | Redirect target is the fixed internal `/api/login` string — never derived from user input or a `returnTo`/`next` param, so no open-redirect vector. The one-shot `familysync.loginRedirectAttempted` guard prevents a redirect loop; it is re-armed via `clearLoginRedirect()` only on a genuine session-expiry transition (or successful `/api/me`), bounding re-auth attempts to one per expiry. |
| T-06-05-session | Spoofing | SessionExpiredError detection (D-11) | mitigate | Detection is `res.type==='opaqueredirect' || res.status===401` only — it never trusts a response body to decide auth state. Session remains server-enforced via the existing Authelia httpOnly same-origin cookie contract; the client merely reacts to the server's 401/redirect. No token is read or stored client-side. (ASVS V3.) |
| T-06-05-inflight | Repudiation / data loss | in-flight write on expiry | accept | In-flight write replay is deferred (D-11 nice-to-have, RESEARCH Open Question 3). A write that hits an expired session surfaces a clear re-auth instead of silently succeeding; the user re-submits after re-auth. Acceptable for a two-user household; documented, not silent. |
| T-06-05-SC | Tampering | npm installs | accept | No package installs (zero new deps — RESEARCH Package Legitimacy Audit n/a). |
</threat_model>
<verification>
- `cd apps/pwa && pnpm test -- run api/client components/CalendarShell` green; full `pnpm --filter @familysync/pwa test` green.
- `grep -n "class SessionExpiredError" apps/pwa/src/api/client.ts` present; `grep -n "MutationCache" apps/pwa/src/main.tsx` present; `grep -n "defaultOptions" apps/pwa/src/main.tsx` does NOT show an `onError` (v5 correctness).
- `grep -n "hasRrule" apps/pwa/src/api/client.ts` and `recurrenceUntil` present (type mirrors landed).
- playwright-cli: no pre-auth flash; clean mid-use redirect.
</verification>
<success_criteria>
- D-10 (success criterion 5): unauthenticated cold load shows only the neutral splash.
- D-11 (success criterion 4): mid-use session expiry redirects cleanly via a global handler.
- Client type contract for D-06 (payload) and D-08 (hasRrule mirror) is in place for Plan 06.
- `client.ts` ownership is exclusive to this plan (no other Wave-1 plan edits it).
</success_criteria>
<output>
Create `.planning/phases/06-ux-polish/06-05-SUMMARY.md` when done (RED/GREEN notes for Task 1, the confirmed TanStack v5 API used, and the playwright-cli observations).
</output>
+234
View File
@@ -0,0 +1,234 @@
---
phase: 06-ux-polish
plan: 06
type: execute
wave: 2
depends_on:
- 06-01-ux-polish
- 06-02-ux-polish
- 06-03-ux-polish
- 06-05-ux-polish
files_modified:
- apps/pwa/src/components/EventForm.tsx
- apps/pwa/src/components/SeriesEditPrompt.tsx
- apps/pwa/src/styles/index.css
autonomous: false
requirements: []
must_haves:
truths:
- "Scope fence (D-01/D-02): this phase delivers only the six promoted polish items (999.2/3/6/7/8/9); 999.4 reminders/VALARM, 999.5 provider setup, and 999.1 provider abstraction are NOT built here (deferred to milestone 1.1)"
- "Moving an event's start moves its end with it, preserving duration; the end never strands behind the start (D-03/D-04, success criterion 2)"
- "A recurring event can be bounded in the form via 'Ends: Never / On date / After N times' (D-06, success criterion 2)"
- "Editing a recurring occurrence prompts 'Edit recurring series' before saving the whole-series change (D-08/D-09, success criterion 3)"
- "The all-day-edit off-by-one stays fixed — re-editing an all-day event does not grow it by a day (D-05 verify, success criterion 2)"
- "All-day events are visually distinct from timed events at a glance (999.6/D-12, success criterion 1)"
artifacts:
- path: "apps/pwa/src/components/EventForm.tsx"
provides: "start onChange handlers that call computeNewTimedEnd/computeNewAllDayEnd; recurrence-bound control; hasRrule-gated series-edit confirmation"
contains: "computeNewTimedEnd"
- path: "apps/pwa/src/components/SeriesEditPrompt.tsx"
provides: "whole-series edit confirmation sheet/dialog (focus trap, Escape=cancel)"
contains: "Update series"
- path: "apps/pwa/src/styles/index.css"
provides: "Schedule-X all-day chip override (full-width filled pill)"
contains: "sx__all-day-event"
key_links:
- from: "apps/pwa/src/components/EventForm.tsx"
to: "apps/pwa/src/lib/eventDateTime.ts"
via: "start onChange → computeNewTimedEnd / computeNewAllDayEnd"
pattern: "computeNewTimedEnd|computeNewAllDayEnd"
- from: "apps/pwa/src/components/EventForm.tsx"
to: "apps/pwa/src/api/client.ts"
via: "payload carries recurrenceUntil/recurrenceCount; occurrence.hasRrule gates the prompt"
pattern: "recurrenceUntil|recurrenceCount|hasRrule"
---
<objective>
Wire the prepared logic into the event form so the user-facing 999.7/999.8/999.9 fixes are live, and give all-day events their distinct look (999.6). After this plan a real user can: move a start and watch the end follow (D-04), bound a recurring series with "Ends: On date / After N times" (D-06), edit a recurring series behind a confirming prompt (D-08/D-09), and tell all-day from timed events at a glance (999.6) — with the all-day-edit off-by-one staying fixed (D-05).
This is the Wave-2 integration slice. It consumes (does NOT redefine) artifacts from earlier plans: `computeNewTimedEnd`/`computeNewAllDayEnd` (Plan 01), the `recurrenceUntil`/`recurrenceCount` payload fields + `hasRrule` on `CalendarOccurrence` (Plan 05 client types, backed by Plan 02 API contract + Plan 03 server expansion).
Purpose: This is glue + UI (form wiring, a confirmation component, a CSS override) — standard tasks, verified with playwright-cli per CLAUDE.md. The deterministic math/serialization it depends on is already unit-tested in Plans 0103.
Output: EventForm end-tracking handlers + recurrence-bound control + series-edit prompt trigger; `SeriesEditPrompt.tsx`; all-day Schedule-X override in `index.css`.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-ux-polish/06-RESEARCH.md
@.planning/phases/06-ux-polish/06-PATTERNS.md
@.planning/phases/06-ux-polish/06-UI-SPEC.md
@apps/pwa/src/components/DeleteConfirmationDialog.tsx
@.planning/phases/06-ux-polish/06-01-SUMMARY.md
@.planning/phases/06-ux-polish/06-05-SUMMARY.md
</context>
<artifacts_this_plan_produces>
NEW symbols introduced here (exclude from drift/convergence checks):
- Start `onChange` handlers in `EventForm.tsx` that call `computeNewTimedEnd`/`computeNewAllDayEnd`
- `recurrenceBound: 'never'|'until'|'count'`, `recurrenceUntil: string`, `recurrenceCount: number` state + the "Ends" control in `EventForm.tsx`
- `SeriesEditPrompt` component (`apps/pwa/src/components/SeriesEditPrompt.tsx`)
- `.sx__all-day-event` CSS override block in `apps/pwa/src/styles/index.css`
NOTE: `computeNewTimedEnd`, `computeNewAllDayEnd`, `recurrenceUntil`/`recurrenceCount` payload fields, and `hasRrule` are NOT new here — they are consumed from Plans 01/05.
</artifacts_this_plan_produces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: End-tracking wiring + recurrence-bound control in EventForm (D-04, D-06, D-07, D-05 verify)</name>
<files>apps/pwa/src/components/EventForm.tsx, apps/pwa/src/components/EventForm.test.tsx</files>
<read_first>
- apps/pwa/src/components/EventForm.tsx — start date input `onChange` (~line 671) and start time input `onChange` (~line 684); state block (199210); the reset `useEffect` (~232262); the submit handler payload construction (~355370, the `...(isEdit ? {} : { recurrence })` pattern); the recurrence `<select>` (~768772)
- apps/pwa/src/lib/eventDateTime.ts — `computeNewTimedEnd` / `computeNewAllDayEnd` signatures (from Plan 01) and the `exclusiveEndToInclusiveDate` D-05 helper at line ~199 (verify it still pre-fills inclusive on all-day edit)
- apps/pwa/src/api/client.ts — `CreateEventPayload.recurrenceUntil`/`recurrenceCount` (from Plan 05) — the payload fields to send
- .planning/phases/06-ux-polish/06-PATTERNS.md §"apps/pwa/src/components/EventForm.tsx" — exact onChange replacement, new state additions, reset-effect extension, payload extension
- .planning/phases/06-ux-polish/06-UI-SPEC.md §"Surface 4" (end-tracking behavior) + §"Surface 5" (bound control: label "Ends", options Never/On date/After N times, 44px targets, inline validation copy) + §"Copywriting Contract" (exact labels/errors)
</read_first>
<behavior>
- Changing startDate (timed) updates endDate/endTime so the duration is preserved (delegates to computeNewTimedEnd); never lands end before start.
- Changing startDate (all-day) updates endDate preserving the day-span (computeNewAllDayEnd).
- Changing startTime (timed) recomputes end preserving the delta.
- Selecting recurrence ≠ "None" reveals the "Ends" control; "On date" reveals a date input, "After N times" reveals a number input (min 1); "Never" sends neither bound field.
- Submitting with bound="until" sends `recurrenceUntil`; bound="count" sends `recurrenceCount`; neither when recurrence==='none' or bound==='never'.
- Inline validation: count < 1 → "Must be at least 1 occurrence"; until before start → "End date must be after the event starts".
- D-05 regression: opening an existing all-day event pre-fills the inclusive end (no +1 drift); saving twice does not grow the event.
</behavior>
<action>
Replace the bare start date/time `onChange` handlers with handlers that call `computeNewAllDayEnd` (all-day) or `computeNewTimedEnd` (timed) to recompute end, then set start — per PATTERNS §EventForm. Add `recurrenceBound`/`recurrenceUntil`/`recurrenceCount` state alongside the existing state block; extend the reset `useEffect` to reset them to defaults (mirror the `setRecurrence(... ?? 'none')` line). Render the "Ends" control below the recurrence `<select>`, shown only when `recurrence !== 'none'`, using the exact UI-SPEC Surface 5 labels/options and 44px touch targets, with inline `--color-destructive` validation messages. Extend the submit payload to conditionally include `recurrenceUntil` (bound==='until') or `recurrenceCount` (bound==='count') only when `recurrence !== 'none'` — mirror the existing spread-conditional pattern. Do NOT re-implement the D-05 `exclusiveEndToInclusiveDate` pre-fill — leave line ~199 intact and add/keep a test asserting the all-day edit round-trip does not drift. For D-07, confirm the recurrence `<select>` value flows 1:1 to `recurrence` in the payload (the API mapping is locked in Plan 02). Add/extend EventForm.test.tsx cases for end-tracking wiring and bound-field emission where feasible in jsdom. Commit `feat(06-06): wire end-tracking and recurrence-bound control into EventForm`.
</action>
<verify>
<automated>cd apps/pwa && pnpm test -- run components/EventForm</automated>
</verify>
<acceptance_criteria>
- Start `onChange` handlers call `computeNewTimedEnd`/`computeNewAllDayEnd` (grep present in EventForm.tsx).
- The "Ends" control renders only when recurrence ≠ none with the exact UI-SPEC labels and emits `recurrenceUntil`/`recurrenceCount` correctly (and neither when "Never").
- Inline validation messages use the exact UI-SPEC copy.
- `exclusiveEndToInclusiveDate` pre-fill at ~line 199 is unchanged; an all-day edit round-trip test shows no day drift (D-05 holds).
- EventForm test suite green.
</acceptance_criteria>
<done>End auto-tracks start with a floor; recurrence is boundable in the form; all-day edit stays drift-free; D-07 mapping confirmed 1:1.</done>
</task>
<task type="auto">
<name>Task 2: Series-edit confirmation prompt, gated on hasRrule (D-08, D-09)</name>
<files>apps/pwa/src/components/SeriesEditPrompt.tsx, apps/pwa/src/components/EventForm.tsx</files>
<read_first>
- apps/pwa/src/components/DeleteConfirmationDialog.tsx — the existing bottom-sheet(phone)/dialog(desktop) pattern, focus trap, Escape-to-cancel, role="dialog"/aria-modal — the exact analog to mirror
- apps/pwa/src/components/EventForm.tsx — the submit handler (where Save fires) + how `occurrence` is available; the edit-mode branch (`isEdit`)
- apps/pwa/src/api/client.ts — `CalendarOccurrence.hasRrule` (from Plan 05) — the gate signal
- .planning/phases/06-ux-polish/06-UI-SPEC.md §"Surface 6" (layout, ≤767px sheet / ≥768px dialog max-width 480px, focus trap, Escape=Cancel) + §"Copywriting Contract" (heading "Edit recurring series", body "This will update all occurrences of this event.", confirm "Update series" accent-filled, "Cancel" ghost) + §"EventForm primary CTAs" (Edit recurring occurrence CTA = "Update series")
- .planning/phases/06-ux-polish/06-RESEARCH.md §"Focus 3 — Confirmation prompt (D-09)" — render when editMode && occurrence.hasRrule && Save tapped; PUT replaces master VEVENT (no RECURRENCE-ID)
</read_first>
<action>
Create `SeriesEditPrompt.tsx` mirroring `DeleteConfirmationDialog`'s responsive sheet/dialog, focus trap, and Escape-to-cancel, with `role="dialog"`, `aria-modal="true"`, `aria-labelledby` → the heading. Use the exact UI-SPEC Surface 6 copy: heading "Edit recurring series", body "This will update all occurrences of this event.", primary accent-filled "Update series", ghost "Cancel" (NO destructive color — this is an edit). In `EventForm.tsx`, when in edit mode AND `occurrence?.hasRrule === true`, tapping Save opens `SeriesEditPrompt` instead of submitting directly; confirming "Update series" runs the existing edit submit (the same PATCH `/api/events/:uid/edit` that PUTs the master VEVENT wholesale — no RECURRENCE-ID, per D-08); Cancel returns to the form without submitting. For non-recurring or create mode, Save submits directly as today. Set the edit-recurring CTA label to "Update series" per UI-SPEC. Commit `feat(06-06): add whole-series edit confirmation prompt`.
</action>
<verify>
<automated>cd apps/pwa && pnpm test -- run 2>&1 | tail -3</automated>
</verify>
<acceptance_criteria>
- `SeriesEditPrompt` renders the exact UI-SPEC copy with focus trap + Escape=cancel + role="dialog"/aria-modal.
- In edit mode with `occurrence.hasRrule === true`, Save opens the prompt; confirming runs the existing whole-series PATCH; canceling does not submit.
- Non-recurring / create-mode Save behavior is unchanged (no prompt).
- PWA suite green.
</acceptance_criteria>
<done>Recurring-occurrence edits are confirmed via a whole-series prompt; master-VEVENT PUT path unchanged; no per-occurrence edit introduced.</done>
</task>
<task type="auto">
<name>Task 3: All-day visual distinction — Schedule-X override (999.6, D-12)</name>
<files>apps/pwa/src/styles/index.css</files>
<read_first>
- apps/pwa/src/styles/index.css — the existing `.sx__*` override section (the documented Schedule-X selector overrides) to extend with an all-day rule
- apps/pwa/src/lib/hydrateEvents.ts + apps/pwa/src/lib/calendarConfig.ts — how `_familySync.color` / `calendarId` color propagates to Schedule-X chips (so the member color already drives the fill)
- .planning/phases/06-ux-polish/06-UI-SPEC.md §"Surface 3" — treatment contract: all-day = full-width filled rounded pill (border-radius 4px), white label, font-weight 600, `--text-label-size`; override selector `.sx__all-day-event`; timed events keep their existing partial-fill chip; color comes from the existing calendarId color config (no per-event inline override)
</read_first>
<action>
Add a `.sx__all-day-event` override to the Schedule-X section of `index.css` per UI-SPEC Surface 3: render all-day chips as a full-width rounded pill (`border-radius: 4px`), white (`#FFFFFF`) label text, `font-weight: 600`, `font-size: var(--text-label-size)`, with the member color as solid background fill sourced from the existing `calendarId` color config (do NOT add per-event inline styles — the color already propagates via `buildCalendarConfig`). Leave timed-event chip styling untouched so the contract holds: all-day = solid filled pill, timed = partial-fill chip with colored border accent. Keep all existing `.sx__*` rules intact. Commit `feat(06-06): distinct all-day event pill styling`.
</action>
<verify>
<automated>grep -v '^#' apps/pwa/src/styles/index.css | grep -c 'sx__all-day-event' | grep -qx 1 && cd apps/pwa && pnpm test -- run 2>&1 | tail -3</automated>
</verify>
<acceptance_criteria>
- `.sx__all-day-event` override present exactly once with full-width pill + white bold label per UI-SPEC.
- Existing `.sx__*` layout rules unchanged.
- PWA suite green (no test regression from the CSS addition).
</acceptance_criteria>
<done>All-day chips render as distinct filled pills; timed chips unchanged.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 4: playwright-cli — end-tracking, recurrence bound, series-edit prompt, all-day distinction</name>
<files>(verification only — no files modified)</files>
<action>
Verification task (no code changes). Using the playwright-cli skill against desktop Chromium (dev stack host-side per docs/deployment.md, DEV_AUTH_BYPASS=true), exercise the six behaviors: (1) end-tracking — move a timed event's start and confirm the end follows preserving 1h and never lands before start; repeat all-day (day-span preserved); (2) recurrence bound — set Weekly, confirm the "Ends" control appears, pick "On date" and save a bounded series (occurrences stop), then "After N times" N=3 → 3 occurrences, and "Never" stays unbounded; (3) FREQ persistence — create a Daily recurrence and confirm occurrences render daily not weekly; (4) series edit — edit an existing recurring occurrence, tap Save, confirm the focus-trapped "Edit recurring series" prompt (Escape cancels) and that "Update series" applies across occurrences; (5) all-day distinction — confirm all-day events render as full-width filled pills visually distinct from timed chips; (6) all-day no-drift — edit an existing all-day event and save twice, confirming it does not grow by a day. This is a blocking human-verify checkpoint — pause for operator confirmation.
</action>
<read_first>
- .claude/skills/playwright-cli/SKILL.md — drive desktop Chromium, interact with the event form and calendar
- docs/deployment.md §"Running locally (host-side, no Docker)" — dev run command (DEV_AUTH_BYPASS=true)
- .planning/phases/06-ux-polish/06-UI-SPEC.md — Surfaces 3/4/5/6 acceptance behavior
</read_first>
<what-built>
EventForm end-tracking, the "Ends" recurrence-bound control, the whole-series edit confirmation prompt, and the all-day filled-pill visual treatment.
</what-built>
<how-to-verify>
1. End-tracking (D-04): open New Event, set a 1h timed event, then move the start date/time forward — confirm the end follows, preserving 1h, and never lands before the start. Repeat for an all-day event (day-span preserved).
2. Recurrence bound (D-06): set recurrence to Weekly, confirm the "Ends" control appears; pick "On date" and a date, save, and confirm the created series is bounded (occurrences stop at/after the date, not an endless/2-month bar). Try "After N times" with N=3 and confirm 3 occurrences. Confirm "Never" is unbounded as before.
3. FREQ persistence (D-07): create a Daily recurrence and confirm occurrences render daily (not weekly).
4. Series edit (D-08/D-09): open an existing recurring occurrence, edit the title/time, tap Save — confirm the "Edit recurring series" prompt appears (focus-trapped, Escape cancels), confirm "Update series" applies the change across occurrences.
5. All-day distinction (999.6): confirm all-day events render as full-width filled pills visually distinct from timed chips at a glance.
6. All-day edit no-drift (D-05): edit an existing all-day event and save twice — confirm it does not grow by a day.
</how-to-verify>
<verify>
<human-check>End follows start with a floor; recurrence is boundable and FREQ persists; series-edit prompt gates whole-series edits; all-day pills are visually distinct; all-day edits do not drift.</human-check>
</verify>
<resume-signal>Type "approved" or describe which behavior failed.</resume-signal>
<acceptance_criteria>
- All six behaviors above observed correctly in desktop Chromium.
</acceptance_criteria>
<done>The full event-form polish set verified live via playwright-cli.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → API (PATCH /api/events/:uid/edit) | Whole-series edit PUTs the master VEVENT back to Fastmail; the new `recurrenceUntil`/`recurrenceCount` cross here (validated server-side in Plan 02). |
| user input → form state | Recurrence bound date/count are user inputs shaped in the form before submit. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-06-input | Tampering | recurrence bound inputs (EventForm) | mitigate | Form-side validation (count ≥ 1, until ≥ start) plus the authoritative server-side Zod validation from Plan 02 (`recurrenceUntil` max-10, `recurrenceCount` int≥1) — the client check is UX, the server check is the enforcement boundary. Defense in depth; no raw passthrough. (ASVS V5.) |
| T-06-06-series | Tampering | whole-series edit PUT (EventForm → existing /edit route) | mitigate | Reuses the existing edit route's ownership + objectUrl/etag lookup (unchanged from Phase 3) — the prompt only gates the UX; it adds no new privilege. The PUT replaces the master VEVENT for the caller's own event only; access scope is the existing per-user filter. |
| T-06-06-xss | Tampering / XSS | all-day pill label, prompt copy | accept | All-day labels and prompt text render as plain-text JSX children (existing EventForm XSS posture, T-03-15) — no `dangerouslySetInnerHTML`; the CSS override sets presentation only. No new injection surface. |
| T-06-06-SC | Tampering | npm installs | accept | No package installs (zero new deps). |
</threat_model>
<verification>
- `cd apps/pwa && pnpm test -- run components/EventForm` green; full `pnpm --filter @familysync/pwa test` green.
- `grep -n "computeNewTimedEnd\|computeNewAllDayEnd" apps/pwa/src/components/EventForm.tsx` present; `grep -n "recurrenceUntil\|recurrenceCount" apps/pwa/src/components/EventForm.tsx` present.
- `grep -c 'sx__all-day-event' apps/pwa/src/styles/index.css` returns 1.
- playwright-cli verifies all six event-form behaviors.
</verification>
<success_criteria>
- D-03/D-04 (criterion 2): end follows start with a floor.
- D-06 (criterion 2): recurrence is boundable; D-07: FREQ persists.
- D-08/D-09 (criterion 3): whole-series edit behind a confirmation prompt.
- D-05 (criterion 2): all-day edit off-by-one stays fixed.
- 999.6/D-12 (criterion 1): all-day events visually distinct.
</success_criteria>
<output>
Create `.planning/phases/06-ux-polish/06-06-SUMMARY.md` when done (note playwright-cli observations for each behavior).
</output>