chore: archive phase directories from completed milestones
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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 ~107–133) 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>
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
phase: 06-ux-polish
|
||||
plan: "01"
|
||||
subsystem: pwa/lib
|
||||
tags: [tdd, date-math, event-form, d-04]
|
||||
dependency_graph:
|
||||
requires: []
|
||||
provides:
|
||||
- computeNewTimedEnd (apps/pwa/src/lib/eventDateTime.ts)
|
||||
- computeNewAllDayEnd (apps/pwa/src/lib/eventDateTime.ts)
|
||||
affects:
|
||||
- Plan 06-06 (EventForm wires these helpers into onChange handlers)
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- Local Date accessors (WR-05): getFullYear/getMonth/getDate/getHours/getMinutes — never toISOString().slice
|
||||
- TDD RED→GREEN with Vitest in apps/pwa
|
||||
key_files:
|
||||
created: []
|
||||
modified:
|
||||
- apps/pwa/src/lib/eventDateTime.ts
|
||||
- apps/pwa/src/lib/eventDateTime.test.ts
|
||||
decisions:
|
||||
- WR-05 enforced: all new date formatting uses local accessors; toISOString().slice banned for date strings
|
||||
- Private helpers (localDateISO, localTimeHHMM, dateDiffDays, addDaysISO) added to eventDateTime.ts to support the two exports
|
||||
metrics:
|
||||
duration_minutes: 2
|
||||
completed_date: "2026-06-10"
|
||||
tasks_completed: 2
|
||||
files_changed: 2
|
||||
---
|
||||
|
||||
# Phase 06 Plan 01: End-Tracking Duration Math (D-04) Summary
|
||||
|
||||
**One-liner:** Pure duration-preservation helpers (`computeNewTimedEnd` + `computeNewAllDayEnd`) with 1h/same-day floor rules, TDD RED→GREEN in eventDateTime.ts.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 (RED) | Failing tests for computeNewTimedEnd / computeNewAllDayEnd | `16cdbf3` | eventDateTime.test.ts |
|
||||
| 2 (GREEN) | Implement the two end-tracking helpers | `605f543` | eventDateTime.ts |
|
||||
|
||||
## What Was Built
|
||||
|
||||
Two exported pure functions added to `apps/pwa/src/lib/eventDateTime.ts`:
|
||||
|
||||
- **`computeNewTimedEnd(newStartDate, newStartTime, oldStartDate, oldStartTime, oldEndDate, oldEndTime)`** — preserves a timed event's duration when the start moves. Returns `{ endDate: 'YYYY-MM-DD', endTime: 'HH:MM' }`. If the old end was already behind the old start (stale state), floors to newStart + 1 hour.
|
||||
- **`computeNewAllDayEnd(newStartDate, oldStartDate, oldEndDate)`** — preserves an all-day event's inclusive day-span when the start moves. Returns `'YYYY-MM-DD'`. If the old span was negative (stale state), floors to 0 days (same day as newStart).
|
||||
|
||||
Four private helpers added to the same file: `localDateISO`, `localTimeHHMM`, `dateDiffDays`, `addDaysISO`. All use local Date accessors (WR-05 compliance).
|
||||
|
||||
## Test Coverage
|
||||
|
||||
Six new unit tests in `apps/pwa/src/lib/eventDateTime.test.ts`:
|
||||
|
||||
| Test | Behavior |
|
||||
|------|----------|
|
||||
| preserves a 1-hour timed delta | old 09:00→10:00; new start 11:00 → new end 12:00 |
|
||||
| preserves a multi-day timed delta (26h) | old 08:00→+26h; new start same offset → correct |
|
||||
| floors to 1h when old end was behind start | stale end → snaps to newStart+1h |
|
||||
| preserves a 0-day span (single day all-day) | oldStart=oldEnd → newEnd=newStart |
|
||||
| preserves a 3-day span | newEnd = newStart + 3 days |
|
||||
| floors to same day when old end behind start | negative span → 0 → same day |
|
||||
|
||||
Full suite: **166/166 tests pass**. Pre-existing `serializeEventDateTime` / `localWallClockToUtcIso` tests unaffected.
|
||||
|
||||
## TDD Gate Compliance
|
||||
|
||||
- RED commit (`test(06-01): ...`): `16cdbf3` — 6 tests failing with `TypeError: computeNewTimedEnd is not a function`
|
||||
- GREEN commit (`feat(06-01): ...`): `605f543` — all 166 tests pass
|
||||
- RED→GREEN order confirmed via `git log`
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — plan executed exactly as written.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. Pure client-side date arithmetic on already-trusted local form state. No network boundary, no new attack surface. T-06-01 accepted per threat model.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/pwa/src/lib/eventDateTime.ts` — FOUND (modified)
|
||||
- `apps/pwa/src/lib/eventDateTime.test.ts` — FOUND (modified)
|
||||
- Commit `16cdbf3` — FOUND (RED: test(06-01))
|
||||
- Commit `605f543` — FOUND (GREEN: feat(06-01))
|
||||
- `computeNewTimedEnd` export — FOUND in eventDateTime.ts
|
||||
- `computeNewAllDayEnd` export — FOUND in eventDateTime.ts
|
||||
- No `toISOString().slice` in helper code — CONFIRMED
|
||||
@@ -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 49–54) + the `ICAL.Recur.fromString` + `rruleProp.setValue` serialization path (lines ~143–148)
|
||||
- .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 71–83) and the existing RRULE assembly site (lines ~255–308: `hasExplicitRecurrence`, `RRULE_PRESETS[fields.recurrence]`, preservedRrule path)
|
||||
- apps/api/src/routes/events.ts — `eventFieldsSchema` (lines 100–109, 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 ~143–148) 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>
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
phase: 06-ux-polish
|
||||
plan: "02"
|
||||
subsystem: api/broker
|
||||
tags: [tdd, rrule, recurrence, serialization, ical.js, zod]
|
||||
dependency_graph:
|
||||
requires: []
|
||||
provides:
|
||||
- assembleRruleString helper in apps/api/src/broker/outboxWorker.ts
|
||||
- recurrenceUntil/recurrenceCount fields in outboxPayloadSchema + eventFieldsSchema
|
||||
affects:
|
||||
- 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
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- ical.js ICAL.Recur.fromString + rruleProp.setValue for RRULE serialization
|
||||
- assembleRruleString count-wins-over-until mutual exclusion (RFC 5545 §3.3.10)
|
||||
- Series-edit Pitfall 3: strip UNTIL/COUNT via regex before re-applying new bound
|
||||
key_files:
|
||||
created: []
|
||||
modified:
|
||||
- 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
|
||||
decisions:
|
||||
- "assembleRruleString: COUNT takes precedence over UNTIL (mutual exclusion, RFC 5545 §3.3.10)"
|
||||
- "Timed UNTIL serializes as YYYYMMDDTHHMMSSZ (end-of-UTC-day T235959Z) per RESEARCH Pitfall 2"
|
||||
- "hasExplicitRecurrence check gates assembleRruleString; recurrence:'none' explicitly yields undefined (no RRULE)"
|
||||
- "Series-edit bound-only change: regex strips existing UNTIL/COUNT from preserved RRULE before re-applying new bound"
|
||||
metrics:
|
||||
duration_minutes: 8
|
||||
completed_date: "2026-06-10"
|
||||
tasks_completed: 2
|
||||
files_modified: 4
|
||||
---
|
||||
|
||||
# Phase 06 Plan 02: RRULE UNTIL/COUNT Serialization + FREQ Persistence Summary
|
||||
|
||||
**One-liner:** RRULE UNTIL/COUNT serialization with value-type-matching (DATE vs DATETIME UTC) via `assembleRruleString`, wired into both create + update outbox branches, with a FREQ=DAILY regression lock.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| # | Name | Commit | Type |
|
||||
|---|------|--------|------|
|
||||
| 1 | RED — failing tests for UNTIL/COUNT serialization + FREQ-persistence regression | a59455a | test |
|
||||
| 2 | GREEN — assembleRruleString + Zod schema acceptance, wired into the write path | d2abb91 | feat |
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Task 1: RED
|
||||
Added failing tests to two files:
|
||||
|
||||
**`vevent.test.ts`** — three new serialization assertions confirming ical.js 2.2.1 handles UNTIL/COUNT correctly via the existing `ICAL.Recur.fromString` path:
|
||||
- `FREQ=WEEKLY;COUNT=5` → `RRULE:FREQ=WEEKLY;COUNT=5`
|
||||
- `FREQ=DAILY;UNTIL=20260630` (all-day) → contains `RRULE:FREQ=DAILY;UNTIL=20260630`, does NOT contain `T235959Z`
|
||||
- `FREQ=WEEKLY;UNTIL=20260630T235959Z` (timed) → `RRULE:FREQ=WEEKLY;UNTIL=20260630T235959Z`
|
||||
|
||||
**`outboxWorker.test.ts`** — two new describe blocks:
|
||||
- `assembleRruleString (D-06)`: 6 cases covering COUNT wins, UNTIL DATE/DATETIME, COUNT-wins-over-UNTIL mutual exclusion, base preset unchanged
|
||||
- `FREQ persistence (D-07 regression)`: 1 case asserting daily-recurrence payload emits `RRULE:FREQ=DAILY`
|
||||
|
||||
RED confirmed: `assembleRruleString is not a function` (6 failing tests).
|
||||
|
||||
### Task 2: GREEN
|
||||
|
||||
**`apps/api/src/broker/outboxWorker.ts`:**
|
||||
- Added `recurrenceUntil: z.string().max(10).optional()` and `recurrenceCount: z.number().int().min(1).optional()` to `outboxPayloadSchema` (T-06-02 mitigations)
|
||||
- Implemented and exported `assembleRruleString(basePreset, until?, count?, allDay?)` with JSDoc (D-06)
|
||||
- Wired `assembleRruleString` into both create and update dispatch branches
|
||||
- Fixed precedence: `hasExplicitRecurrence` checked first (covers `recurrence:'none'` → explicitly yields `undefined`); `preservedRrule` only used when no explicit recurrence
|
||||
- Series-edit Pitfall 3: when a bound-only change applies to a preserved RRULE, strips `UNTIL/COUNT` via `/;(UNTIL|COUNT)=[^;]*/g` before re-applying
|
||||
|
||||
**`apps/api/src/routes/events.ts`:**
|
||||
- Added `recurrenceUntil: z.string().max(10).optional()` and `recurrenceCount: z.number().int().min(1).optional()` to `eventFieldsSchema`
|
||||
|
||||
All 39 tests pass. The previously passing CR-01 (`recurrence:'none' wins over _preservedRrule`) was initially broken by the change and auto-fixed (Rule 1 bug: logic precedence error).
|
||||
|
||||
## TDD Gate Compliance
|
||||
|
||||
| Gate | Status |
|
||||
|------|--------|
|
||||
| RED commit (`test(06-02):`) | a59455a — exists, confirmed failing |
|
||||
| GREEN commit (`feat(06-02):`) | d2abb91 — follows RED commit |
|
||||
| Commit order | test(06-02) precedes feat(06-02) — verified via `git log` |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Fixed hasExplicitRecurrence precedence for recurrence:'none'**
|
||||
- **Found during:** Task 2 (GREEN)
|
||||
- **Issue:** Initial implementation used `if (hasExplicitRecurrence && rruleFromPayload)` — when `recurrence:'none'`, `rruleFromPayload` is `undefined`, so the condition was `false`, incorrectly falling through to `else if (preservedRrule)` and emitting an RRULE even though the user explicitly selected 'none'. Broke existing `CR-01: explicit recurrence:'none' wins` test.
|
||||
- **Fix:** Changed to `if (hasExplicitRecurrence)` with an inner ternary: if `rruleFromPayload` is truthy, assemble with bound; otherwise `undefined`. Applied identically to both create and update branches.
|
||||
- **Files modified:** `apps/api/src/broker/outboxWorker.ts`
|
||||
- **Commit:** d2abb91 (folded into GREEN commit)
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
```
|
||||
cd apps/api && pnpm vitest run tests/broker/vevent.test.ts tests/broker/outboxWorker.test.ts
|
||||
Test Files 2 passed (2)
|
||||
Tests 39 passed (39)
|
||||
```
|
||||
|
||||
```
|
||||
grep -n "recurrenceUntil" apps/api/src/routes/events.ts apps/api/src/broker/outboxWorker.ts
|
||||
events.ts:111: recurrenceUntil: z.string().max(10).optional()
|
||||
outboxWorker.ts:84: recurrenceUntil: z.string().max(10).optional()
|
||||
```
|
||||
|
||||
Git log confirms `test(06-02)` precedes `feat(06-02)`.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. All test assertions target exact ICS/RRULE strings verified against ical.js 2.2.1 in RESEARCH. No placeholder data.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
No new threat surface beyond what was planned in T-06-02 / T-06-02b. Both mitigations implemented:
|
||||
- `z.string().max(10)` on `recurrenceUntil` + `z.number().int().min(1)` on `recurrenceCount` at both route and outbox schema boundaries.
|
||||
- `assembleRruleString` uses `.replace(/-/g,'')` (digits only) + fixed templates — no raw passthrough to ICS.
|
||||
- Assembled string passes through `ICAL.Recur.fromString` (parse-rejects malformed RRULE).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
| Item | Status |
|
||||
|------|--------|
|
||||
| SUMMARY.md created | FOUND |
|
||||
| RED commit a59455a | FOUND |
|
||||
| GREEN commit d2abb91 | FOUND |
|
||||
| 39 tests passing | CONFIRMED |
|
||||
| recurrenceUntil in both schemas | CONFIRMED |
|
||||
@@ -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 37–67), the two `occurrences.push({...})` sites (non-recurring ~241–256, recurring ~287–302), 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 (37–67) 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>
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
phase: "06-ux-polish"
|
||||
plan: "03"
|
||||
subsystem: "api/broker"
|
||||
tags: ["tdd", "expand", "hasRrule", "ical", "recurrence", "d-08", "d-06"]
|
||||
dependency_graph:
|
||||
requires:
|
||||
- "apps/api/src/broker/expand.ts (CalendarOccurrence interface)"
|
||||
- "apps/api/tests/fixtures/*.ics (existing fixtures)"
|
||||
provides:
|
||||
- "CalendarOccurrence.hasRrule: boolean (server source-of-truth)"
|
||||
- "weekly-count3.ics test fixture (bounded RRULE, COUNT=3)"
|
||||
- "expand.test.ts hasRrule + bounded-RRULE assertions"
|
||||
affects:
|
||||
- "apps/api/src/broker/expand.ts (CalendarOccurrence consumers — routes/events.ts)"
|
||||
- "apps/pwa/src/api/client.ts (mirror field added in Plan 05)"
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "TDD RED→GREEN: test-only commit followed by implementation commit"
|
||||
- "Capture event.isRecurring() once before branch, pass to both push sites"
|
||||
- "epochMilliseconds (not epochSeconds) for Temporal duration arithmetic with temporal-polyfill"
|
||||
key_files:
|
||||
created:
|
||||
- "apps/api/tests/fixtures/weekly-count3.ics"
|
||||
modified:
|
||||
- "apps/api/src/broker/expand.ts"
|
||||
- "apps/api/tests/broker/expand.test.ts"
|
||||
decisions:
|
||||
- "D-08: hasRrule derived from event.isRecurring() — no DB query change needed (already available on the parsed ICAL.Event)"
|
||||
- "Captured isRecurring once before the non-recurring/recurring branch (single capture pattern from PATTERNS.md)"
|
||||
- "epochMilliseconds used for Temporal duration math — temporal-polyfill returns number not BigInt for this property"
|
||||
- "weekly-count3.ics uses UTC DTSTART/DTEND (no VTIMEZONE needed) for simplicity in the bounded test fixture"
|
||||
metrics:
|
||||
duration: "11m"
|
||||
completed: "2026-06-10"
|
||||
tasks_completed: 2
|
||||
files_modified: 3
|
||||
---
|
||||
|
||||
# Phase 06 Plan 03: hasRrule Server-Side Exposure Summary
|
||||
|
||||
Added `hasRrule: boolean` to the `CalendarOccurrence` interface in `expand.ts` and populated it via `event.isRecurring()` — the server-side signal that gates the whole-series-edit confirmation prompt (D-08).
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| # | Task | Type | Commit | Outcome |
|
||||
|---|------|------|--------|---------|
|
||||
| 1 | RED — failing tests for hasRrule + bounded expansion | TDD test | 593302e | 3 hasRrule failures + duration test confirmed red |
|
||||
| 2 | GREEN — add hasRrule to interface and populate it | TDD impl | 44d336c | 10/10 expand tests pass |
|
||||
|
||||
## What Was Built
|
||||
|
||||
### `apps/api/src/broker/expand.ts`
|
||||
|
||||
Added `hasRrule: boolean` field to `CalendarOccurrence` interface with JSDoc. Captured `const isRecurring = event.isRecurring()` once before the non-recurring/recurring branch. Both `occurrences.push({...})` sites now include `hasRrule: isRecurring` — false in the non-recurring branch, true in the recurring branch.
|
||||
|
||||
### `apps/api/tests/broker/expand.test.ts`
|
||||
|
||||
Added two new `describe` blocks:
|
||||
|
||||
**`hasRrule field — D-08`** (2 tests):
|
||||
- `weekly-dst.ics` (recurring): all occurrences have `hasRrule === true`
|
||||
- `single-duration.ics` (non-recurring): the single occurrence has `hasRrule === false`
|
||||
|
||||
**`Bounded RRULE (COUNT=3) — D-06 invariant`** (3 tests):
|
||||
- `COUNT=3` within a 6-month window returns exactly 3 occurrences
|
||||
- Each bounded occurrence duration = 1 hour from DTSTART→DTEND (not recurrence span)
|
||||
- Bounded occurrences have `hasRrule === true`
|
||||
|
||||
### `apps/api/tests/fixtures/weekly-count3.ics`
|
||||
|
||||
New fixture: `FREQ=WEEKLY;COUNT=3`, `DTSTART:20260601T090000Z`, `DTEND:20260601T100000Z` (1-hour UTC events). Used for the bounded expansion invariant test.
|
||||
|
||||
## TDD Gate Compliance
|
||||
|
||||
- RED commit (`test(06-03): ...`) at `593302e` — tests written first, confirmed failing due to absent `hasRrule` field
|
||||
- GREEN commit (`feat(06-03): ...`) at `44d336c` — implementation added, all 10 tests pass
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [Rule 1 - Bug] Duration test using `epochMilliseconds` instead of `epochSeconds`**
|
||||
- **Found during:** Task 1 test writing
|
||||
- **Issue:** `Temporal.ZonedDateTime.epochSeconds` returns `NaN` in the `temporal-polyfill` package used in the test suite; `epochMilliseconds` returns a regular `number`
|
||||
- **Fix:** Duration assertion uses `endZdt.epochMilliseconds - startZdt.epochMilliseconds` and compares to `3_600_000` (1 hour in ms)
|
||||
- **Files modified:** `apps/api/tests/broker/expand.test.ts`
|
||||
- **Commit:** 593302e (incorporated into RED commit before final form)
|
||||
|
||||
No other deviations. Plan executed as written.
|
||||
|
||||
## Verification
|
||||
|
||||
```
|
||||
npx vitest run tests/broker/expand.test.ts
|
||||
Test Files 1 passed (1)
|
||||
Tests 10 passed (10)
|
||||
```
|
||||
|
||||
```
|
||||
grep -n "hasRrule" apps/api/src/broker/expand.ts
|
||||
68: hasRrule: boolean
|
||||
224: // Capture once — used in both branches to populate hasRrule.
|
||||
261: hasRrule: isRecurring, // always false in the non-recurring branch
|
||||
308: hasRrule: isRecurring, // always true in the recurring branch
|
||||
```
|
||||
|
||||
`client.ts` untouched — mirror field is Plan 05's responsibility.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. `hasRrule` is fully populated from `event.isRecurring()` — no placeholder values.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
No new threat surface. `hasRrule` is a boolean derived from data already returned to the authenticated caller. T-06-03 accepted in plan threat model.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/api/src/broker/expand.ts` exists and contains `hasRrule`
|
||||
- `apps/api/tests/fixtures/weekly-count3.ics` exists
|
||||
- `apps/api/tests/broker/expand.test.ts` contains `hasRrule` assertions
|
||||
- Commits 593302e and 44d336c exist in git log
|
||||
@@ -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 140–147) 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 140–147) and `@keyframes shimmer` (~131–138); 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 ~357–363) 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 ~357–363) — 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>
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
phase: 06-ux-polish
|
||||
plan: "04"
|
||||
subsystem: pwa/styles
|
||||
tags: [css, animation, d-13]
|
||||
dependency_graph:
|
||||
requires: []
|
||||
provides:
|
||||
- "@keyframes pulse (apps/pwa/src/styles/tokens.css)"
|
||||
affects:
|
||||
- LiveSyncIndicator (reconnecting dot now resolves the global pulse keyframe)
|
||||
- PushPermissionPrompt (redundant local spin redefinition removed)
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- Global keyframe resolution: all animation consumers reference tokens.css keyframes, not local <style> blocks
|
||||
key_files:
|
||||
created: []
|
||||
modified:
|
||||
- apps/pwa/src/styles/tokens.css
|
||||
- apps/pwa/src/components/PushPermissionPrompt.tsx
|
||||
decisions:
|
||||
- D-13: @keyframes pulse added globally to tokens.css so the LiveSyncIndicator reconnecting dot animates regardless of component mount order; the redundant local spin redefinition in PushPermissionPrompt.tsx removed for hygiene
|
||||
metrics:
|
||||
duration_minutes: 5
|
||||
completed_date: "2026-06-10"
|
||||
tasks_completed: 2
|
||||
files_changed: 2
|
||||
---
|
||||
|
||||
# Phase 06 Plan 04: Sync-Indicator Animations (D-13) Summary
|
||||
|
||||
**One-liner:** Added global `@keyframes pulse` to tokens.css and removed the redundant local `@keyframes spin` block from PushPermissionPrompt.tsx so both sync-state animations resolve from the stylesheet.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 | Add @keyframes pulse globally; remove redundant spin redefinition | `81f2678` | tokens.css, PushPermissionPrompt.tsx |
|
||||
| 2 (checkpoint) | playwright-cli — confirm spinner spins and reconnecting dot pulses | (verification only) | — |
|
||||
|
||||
## What Was Built
|
||||
|
||||
The plan's corrected diagnosis was that `@keyframes spin` was already global in `tokens.css` (lines 140–147) so the SyncStateToast spinner already worked. The two real bugs were:
|
||||
|
||||
- **Missing `@keyframes pulse`** in `tokens.css` → `LiveSyncIndicator`'s reconnecting dot (`animation: 'pulse 1.4s ease-in-out infinite'`) never animated.
|
||||
- **Redundant local `<style>` block** in `PushPermissionPrompt.tsx` that redefined `@keyframes spin` — harmless but incorrect; removed for hygiene.
|
||||
|
||||
Fix (commit `81f2678`):
|
||||
- Added `@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }` to `tokens.css` directly after `@keyframes spin`, matching the existing block format (no vendor prefixes, no `animation-fill-mode` inside).
|
||||
- Deleted the `<style>` block from `PushPermissionPrompt.tsx`. The inline `animation: 'spin 1s linear infinite'` style on the Loader2 element was left intact — it still resolves to the global keyframe.
|
||||
- `LiveSyncIndicator.tsx` and `SyncStateToast.tsx` were not modified; their inline animation references are correct.
|
||||
|
||||
## Checkpoint Verification (Task 2)
|
||||
|
||||
Verified via playwright-cli against desktop Chromium with `DEV_AUTH_BYPASS=true`:
|
||||
|
||||
- **SyncStateToast Loader2 spinner** — computed `animationName === 'spin'`; transform sampled rotating (1s linear infinite). PASS.
|
||||
- **LiveSyncIndicator reconnecting dot** — computed `animationName === 'pulse'`; opacity oscillating at 1.4s ease-in-out. PASS.
|
||||
- No console errors about undefined keyframes.
|
||||
|
||||
No follow-up fixes were needed after the checkpoint.
|
||||
|
||||
## Residual Device-Only Item
|
||||
|
||||
**CP-04.3 (iOS device-only):** `PushPermissionPrompt`'s spinner renders only inside an installed iOS/standalone PWA — not drivable in desktop Chromium. Code-confirmed: the spinner uses the same inline `animation: 'spin 1s linear infinite'` that resolves to the global `@keyframes spin` in tokens.css. A human/device spot-check during Phase 3 Gate 2 or the go-live deploy is sufficient.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — plan executed exactly as written.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. Pure CSS keyframe addition and redundant inline-style deletion. No data, no network, no auth surface touched. T-06-04 accepted per threat model.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/pwa/src/styles/tokens.css` — FOUND (contains `@keyframes pulse`)
|
||||
- `apps/pwa/src/components/PushPermissionPrompt.tsx` — FOUND (no `@keyframes spin` block)
|
||||
- Commit `81f2678` — FOUND (`fix(06-04): add global pulse keyframe and drop redundant spin redefinition`)
|
||||
- `@keyframes pulse` present exactly once in tokens.css — CONFIRMED
|
||||
- No `@keyframes spin` in PushPermissionPrompt.tsx — CONFIRMED
|
||||
@@ -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 28–53) 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` (136–148), `CalendarOccurrence` (71–91)
|
||||
- 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 ~220–235), `isInitialLoading`/SkeletonCalendar, the `maybeRedirectToLogin()` effect (~197) and `clearLoginRedirect()` effect (~205), import block (~44–56)
|
||||
- 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 ~17–32) 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>
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
phase: 06-ux-polish
|
||||
plan: "05"
|
||||
subsystem: pwa/auth
|
||||
tags: [tdd, auth, session-expiry, d-10, d-11]
|
||||
dependency_graph:
|
||||
requires: []
|
||||
provides:
|
||||
- SessionExpiredError (apps/pwa/src/api/client.ts)
|
||||
- handleAuthResponse (apps/pwa/src/api/client.ts)
|
||||
- AuthSplash component (apps/pwa/src/components/AuthSplash.tsx)
|
||||
- sessionExpired flag (apps/pwa/src/store/calendarStore.ts)
|
||||
- QueryCache/MutationCache onError wiring (apps/pwa/src/main.tsx)
|
||||
- recurrenceUntil/recurrenceCount on CreateEventPayload (client.ts)
|
||||
- hasRrule on CalendarOccurrence client mirror (client.ts)
|
||||
affects:
|
||||
- Plan 06-06 (EventForm consumes recurrenceUntil/recurrenceCount payload fields and occurrence.hasRrule)
|
||||
- CalendarShell (auth-gated render replaces optimistic pre-auth paint)
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- TDD RED→GREEN for typed session-expiry detection (client.ts)
|
||||
- TanStack Query v5 global error handler via QueryCache/MutationCache constructor (NOT defaultOptions.onError)
|
||||
- Auth-gated render gate (meQuery.isSuccess required before CalendarContent paints)
|
||||
- One-shot redirect guard re-arm via clearLoginRedirect() + maybeRedirectToLogin()
|
||||
key_files:
|
||||
created:
|
||||
- apps/pwa/src/components/AuthSplash.tsx
|
||||
modified:
|
||||
- apps/pwa/src/api/client.ts
|
||||
- apps/pwa/src/api/client.test.ts
|
||||
- apps/pwa/src/components/CalendarShell.tsx
|
||||
- apps/pwa/src/main.tsx
|
||||
- apps/pwa/src/store/calendarStore.ts
|
||||
decisions:
|
||||
- D-10: unauthenticated cold load shows only the neutral AuthSplash ("Signing you in") — no calendar shell, skeleton, or pre-auth flash; CalendarContent renders only on meQuery.isSuccess
|
||||
- D-11: mid-use session expiry detected via SessionExpiredError (opaqueredirect || 401) across all fetch wrappers; global QueryCache/MutationCache onError sets sessionExpired flag → AuthSplash "Session expired / Signing you back in…" interstitial → redirect after 1.5s
|
||||
- TanStack v5: QueryCache({onError})/MutationCache({onError}) constructor pattern confirmed; defaultOptions.onError is removed in v5 and was NOT used
|
||||
- One-shot guard: clearLoginRedirect() re-arms the guard before maybeRedirectToLogin() in the session-expiry path (prevents redirect loop)
|
||||
- In-flight write replay deferred (D-11 nice-to-have, RESEARCH Open Question 3)
|
||||
metrics:
|
||||
duration_minutes: 35
|
||||
completed_date: "2026-06-10"
|
||||
tasks_completed: 4
|
||||
files_changed: 6
|
||||
---
|
||||
|
||||
# Phase 06 Plan 05: Auth-Flow Gating + Session-Expiry (D-10/D-11) Summary
|
||||
|
||||
**One-liner:** Typed `SessionExpiredError` centralized across all fetch wrappers (TDD), `AuthSplash` component gating the app render until `meQuery.isSuccess`, and a global `QueryCache`/`MutationCache` `onError` handler that surfaces a "Session expired" interstitial and redirects on any mid-use 401.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 (RED) | Failing SessionExpiredError detection tests | `e5072ff` | client.test.ts |
|
||||
| 1 (GREEN) | Centralize session-expiry detection + extend client types | `d7d4023` | client.ts, client.test.ts |
|
||||
| 2 | AuthSplash component + gate CalendarShell render on auth state | `e7b34a5` | AuthSplash.tsx, CalendarShell.tsx |
|
||||
| 3 | Global session-expiry interstitial via QueryCache/MutationCache onError | `139ef00` | main.tsx, calendarStore.ts, CalendarShell.tsx |
|
||||
| 4 (checkpoint) | playwright-cli — no pre-auth flash; clean session-expiry redirect | (verification only) | — |
|
||||
| Follow-up (RED) | Failing dead-end AuthSplash test for exhausted redirect guard | `36ef7a0` | CalendarShell.test.tsx |
|
||||
| Follow-up (fix) | Make AuthSplash dead-end state reachable + persist redirect guard | `e392c69` | CalendarShell.tsx |
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Task 1: SessionExpiredError + handleAuthResponse (TDD)
|
||||
|
||||
Added to `apps/pwa/src/api/client.ts`:
|
||||
|
||||
- `class SessionExpiredError extends Error` with `Object.setPrototypeOf(this, SessionExpiredError.prototype)` for reliable `instanceof` checks across TypeScript compilation boundaries.
|
||||
- `handleAuthResponse(res, label)` helper: throws `SessionExpiredError` on `res.type === 'opaqueredirect' || res.status === 401`; throws a generic `Error` on other non-ok responses; passes through on ok.
|
||||
- `redirect: 'manual'` added to all six fetch wrappers (`fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`, `fetchSyncStatus`, `fetchWritableCalendars`) — matching the existing `fetchMe` pattern.
|
||||
- Client type additions (exclusive client.ts ownership): `recurrenceUntil?: string` and `recurrenceCount?: number` on `CreateEventPayload` (D-06 payload contract for Plan 06-06); `hasRrule: boolean` on the client-side `CalendarOccurrence` mirror matching `expand.ts` exactly (Pitfall 4 — atomic mirror).
|
||||
|
||||
TDD gate: RED commit (`e5072ff`) — tests failing with "SessionExpiredError is not a constructor". GREEN commit (`d7d4023`) — all client tests pass; 500 responses throw a generic Error, not SessionExpiredError.
|
||||
|
||||
### Task 2: AuthSplash + Gated CalendarShell (D-10)
|
||||
|
||||
Created `apps/pwa/src/components/AuthSplash.tsx`:
|
||||
|
||||
- Full-screen centered column (`height: 100dvh`, `--color-surface` background).
|
||||
- Loader2 spinner (24px, `--color-member-0`, global `spin` keyframe).
|
||||
- `role="status"` + `aria-label="Signing you in"`.
|
||||
- `state: 'loading' | 'redirecting' | 'dead-end'` prop driving copy per UI-SPEC Surface 1:
|
||||
- `loading`: "Signing you in" / Loader2 spinner.
|
||||
- `redirecting`: "Taking you to the sign-in page…" / Loader2 spinner.
|
||||
- `dead-end`: "Sign-in required. Tap here to try again." — tap calls `clearLoginRedirect()` then `maybeRedirectToLogin()`.
|
||||
- Logo lockup omitted gracefully (brand asset not present; no block on that).
|
||||
|
||||
`CalendarShell.tsx` updated:
|
||||
- `meQuery.isLoading` → early-return `<AuthSplash state="loading" />` (no skeleton or calendar paints pre-auth).
|
||||
- `meQuery.isError` → `<AuthSplash state="redirecting" />` (replaces the old `role="alert"` "Sign-in required" block; existing `maybeRedirectToLogin()` effect still fires).
|
||||
- `CalendarContent`/`SkeletonCalendar` render only on `meQuery.isSuccess`.
|
||||
|
||||
### Task 3: Global Session-Expiry Interstitial (D-11)
|
||||
|
||||
`calendarStore.ts`: added `sessionExpired: boolean` (default `false`) + `setSessionExpired(v: boolean)` action.
|
||||
|
||||
`main.tsx`: `QueryClient` constructed with:
|
||||
```ts
|
||||
queryCache: new QueryCache({ onError(error) { if (error instanceof SessionExpiredError) setSessionExpired(true) } }),
|
||||
mutationCache: new MutationCache({ onError(error) { if (error instanceof SessionExpiredError) setSessionExpired(true) } }),
|
||||
```
|
||||
TanStack Query v5 API confirmed via Context7 (`/tanstack/query`): `defaultOptions.onError` was removed in v5; `QueryCache`/`MutationCache` constructor `onError` is the correct path and always fires.
|
||||
|
||||
`CalendarShell.tsx`: when `sessionExpired` is true, renders `<AuthSplash state="redirecting" />` with the Surface-2 copy ("Session expired / Signing you back in…"); on mount schedules `clearLoginRedirect()` then `maybeRedirectToLogin()` after ~1.5s.
|
||||
|
||||
## Checkpoint Verification (Task 4)
|
||||
|
||||
Verified via playwright-cli against desktop Chromium with `DEV_AUTH_BYPASS=true`:
|
||||
|
||||
1. **Cold load (D-10):** First painted frame = neutral "Signing you in" splash (`role=status`). No calendar shell, SkeletonCalendar, or "Sign-in required" alert before the redirect toward `/api/login`. PASS.
|
||||
2. **Mid-use 401 (D-11):** Intercepted `/api/events` returning 401 → "Session expired / Signing you back in…" interstitial appeared → navigated to `/api/login` within ~2s. No hang, no generic error. PASS.
|
||||
|
||||
## Follow-Up Fix After Checkpoint
|
||||
|
||||
The checkpoint surfaced two related issues:
|
||||
|
||||
1. **Dead-end state unreachable:** `CalendarShell` never rendered `AuthSplash state="dead-end"` — the render logic fell through to an empty fragment once the redirect guard was exhausted.
|
||||
2. **One-shot redirect guard persistence:** The guard (`familysync.loginRedirectAttempted`) was cleared during the navigation to `/api/login`, so it was not available to the new page load; a fresh 401 immediately re-triggered the redirect loop.
|
||||
|
||||
Fix (commits `36ef7a0` RED, `e392c69` fix):
|
||||
- `CalendarShell` now renders `<AuthSplash state="dead-end" />` once the redirect guard is exhausted after the interstitial fires.
|
||||
- Guard persistence hardened: `clearLoginRedirect()` is called only at the point the user explicitly taps "Sign-in required. Tap here to try again." — not during the automatic redirect path.
|
||||
|
||||
Re-verified PASS via playwright-cli after fix.
|
||||
|
||||
## Residual Device-Only Item
|
||||
|
||||
**iOS-Safari standalone cold-load/redirect** remains a human/device checkpoint per 06-VALIDATION.md Manual-Only table. The standalone-mode OIDC redirect (no `window.location.href` cross-origin fallback) is not drivable in desktop Chromium.
|
||||
|
||||
## TDD Gate Compliance
|
||||
|
||||
- RED commit (`test(06-05): ...`): `e5072ff` — tests failing with `SessionExpiredError is not a constructor`
|
||||
- GREEN commit (`feat(06-05): ...`): `d7d4023` — all client tests pass
|
||||
- Follow-up RED: `36ef7a0` — failing dead-end guard test
|
||||
- Follow-up fix: `e392c69` — guard and dead-end state corrected; full suite green
|
||||
- RED→GREEN order confirmed via `git log`
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Dead-end AuthSplash state unreachable + redirect guard not persisting**
|
||||
- **Found during:** Task 4 (playwright-cli checkpoint)
|
||||
- **Issue:** CalendarShell never rendered `AuthSplash state="dead-end"` (fall-through to empty fragment); the one-shot redirect guard was cleared during navigation, not on user tap, making the guard unavailable to the landing page on a fresh 401.
|
||||
- **Fix:** CalendarShell now renders the dead-end state once the redirect guard exhausts; guard is cleared only on explicit user tap in the dead-end handler.
|
||||
- **Files modified:** `apps/pwa/src/components/CalendarShell.tsx`
|
||||
- **Commits:** `36ef7a0` (RED), `e392c69` (fix)
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's STRIDE register. T-06-05-info (pre-auth render gate), T-06-05-redirect (one-shot guard prevents redirect loop), T-06-05-session (opaqueredirect || 401 detection only — no token read), T-06-05-inflight (in-flight write replay deferred, documented). No new surfaces introduced.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/pwa/src/components/AuthSplash.tsx` — FOUND (created)
|
||||
- `apps/pwa/src/api/client.ts` — FOUND (`class SessionExpiredError`, `handleAuthResponse`, `hasRrule`, `recurrenceUntil`)
|
||||
- `apps/pwa/src/main.tsx` — FOUND (`MutationCache`, `QueryCache`; no `defaultOptions.onError`)
|
||||
- `apps/pwa/src/store/calendarStore.ts` — FOUND (`sessionExpired`, `setSessionExpired`)
|
||||
- Commit `e5072ff` — FOUND (RED: test(06-05))
|
||||
- Commit `d7d4023` — FOUND (GREEN: feat(06-05))
|
||||
- Commit `e7b34a5` — FOUND (feat(06-05): gate app render)
|
||||
- Commit `139ef00` — FOUND (feat(06-05): global session-expiry interstitial)
|
||||
- Commit `36ef7a0` — FOUND (test(06-05): dead-end guard RED)
|
||||
- Commit `e392c69` — FOUND (fix(06-05): dead-end state reachable)
|
||||
@@ -0,0 +1,230 @@
|
||||
---
|
||||
phase: 06-ux-polish
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["06-01", "06-02", "06-03", "06-05"]
|
||||
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 01–03.
|
||||
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 (199–210); the reset `useEffect` (~232–262); the submit handler payload construction (~355–370, the `...(isEdit ? {} : { recurrence })` pattern); the recurrence `<select>` (~768–772)
|
||||
- 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>
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
phase: 06-ux-polish
|
||||
plan: "06"
|
||||
subsystem: pwa/event-form
|
||||
tags: [event-form, recurrence, all-day, d-04, d-06, d-08, 999.6]
|
||||
dependency_graph:
|
||||
requires:
|
||||
- Plan 06-01 (computeNewTimedEnd/computeNewAllDayEnd)
|
||||
- Plan 06-02 (RRULE UNTIL/COUNT serialization)
|
||||
- Plan 06-03 (hasRrule on CalendarOccurrence server source-of-truth)
|
||||
- Plan 06-05 (recurrenceUntil/recurrenceCount on CreateEventPayload; hasRrule client mirror)
|
||||
provides:
|
||||
- End-tracking start onChange handlers in EventForm (apps/pwa/src/components/EventForm.tsx)
|
||||
- Recurrence-bound "Ends" control in EventForm
|
||||
- SeriesEditPrompt component (apps/pwa/src/components/SeriesEditPrompt.tsx)
|
||||
- Schedule-X all-day pill override (apps/pwa/src/styles/index.css)
|
||||
affects: []
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- Schedule-X CSS override via real v4.6.0 classes (.sx__date-grid-event, .sx__month-grid-event)
|
||||
- Confirmation sheet/dialog mirroring DeleteConfirmationDialog (focus trap, Escape=cancel)
|
||||
- Conditional payload spread for recurrence bound fields (never/until/count)
|
||||
key_files:
|
||||
created:
|
||||
- apps/pwa/src/components/SeriesEditPrompt.tsx
|
||||
modified:
|
||||
- apps/pwa/src/components/EventForm.tsx
|
||||
- apps/pwa/src/components/EventForm.test.tsx
|
||||
- apps/pwa/src/styles/index.css
|
||||
decisions:
|
||||
- Schedule-X all-day CSS: the PLAN assumed `.sx__all-day-event` selector but Schedule-X v4.6.0 does not emit that class; the real selectors are `.sx__date-grid-event` (week/day view) and `.sx__month-grid-event:not(:has(.sx__month-grid-event-time))` (month view); `--sx-color-primary-container` CSS variable also remapped as fallback (follow-up fix 6dbb166 + 5620261)
|
||||
- Event create via real form not exercised: the dev-bypass user (id 1) has no CalDAV credential/calendars — those belong to user 2; server logic + form UI verified via route-mocks and direct DB occurrence inserts (dev-seed gap, not a code defect)
|
||||
metrics:
|
||||
duration_minutes: 45
|
||||
completed_date: "2026-06-10"
|
||||
tasks_completed: 4
|
||||
files_changed: 5
|
||||
---
|
||||
|
||||
# Phase 06 Plan 06: EventForm Integration Slice (999.6/7/8/9) Summary
|
||||
|
||||
**One-liner:** Wired `computeNewTimedEnd`/`computeNewAllDayEnd` into EventForm start handlers, added recurrence-bound "Ends" control, built `SeriesEditPrompt` for whole-series edits, and fixed all-day Schedule-X CSS to target real v4.6.0 classes.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 | End-tracking wiring + recurrence-bound control in EventForm | `9aa15c4` (RED), `cbf5f98` (GREEN) | EventForm.tsx, EventForm.test.tsx |
|
||||
| 2 | Series-edit confirmation prompt, gated on hasRrule | `96ef0b4` | SeriesEditPrompt.tsx, EventForm.tsx |
|
||||
| 3 | All-day visual distinction — Schedule-X override | `883b00b` | index.css |
|
||||
| 4 (checkpoint) | playwright-cli — all six event-form behaviors | (verification only) | — |
|
||||
| Follow-up fix | Target real Schedule-X all-day class for filled pills | `6dbb166` | index.css |
|
||||
| Follow-up fix | Remap primary-family container var for fallback all-day solid | `5620261` | index.css |
|
||||
| Integration fix | Set hasRrule on EventDetailPopover test fixtures | `69e5ae8` | EventDetailPopover.test.tsx |
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Task 1: End-Tracking + Recurrence-Bound Control (D-04, D-06, D-07, D-05 verify)
|
||||
|
||||
`EventForm.tsx` updated:
|
||||
|
||||
- Start date `onChange`: calls `computeNewAllDayEnd` (all-day mode) or `computeNewTimedEnd` (timed mode) to recompute end before setting start — end never strands behind start.
|
||||
- Start time `onChange`: calls `computeNewTimedEnd` to recompute end preserving the delta.
|
||||
- `recurrenceBound: 'never' | 'until' | 'count'`, `recurrenceUntil: string`, `recurrenceCount: number` state added alongside the existing state block; reset `useEffect` extended to reset them.
|
||||
- "Ends" control rendered below the recurrence `<select>`, shown only when `recurrence !== 'none'`, with the exact UI-SPEC Surface 5 labels: Never / On date / After N times, 44px touch targets, inline `--color-destructive` validation messages ("Must be at least 1 occurrence" / "End date must be after the event starts").
|
||||
- Submit payload extended to conditionally spread `recurrenceUntil` (bound=`'until'`) or `recurrenceCount` (bound=`'count'`) — neither sent when `recurrence === 'none'` or bound=`'never'`.
|
||||
- `exclusiveEndToInclusiveDate` pre-fill at the all-day edit reset line left intact; a round-trip test confirms no day-span drift (D-05 holds).
|
||||
|
||||
### Task 2: SeriesEditPrompt (D-08, D-09)
|
||||
|
||||
Created `apps/pwa/src/components/SeriesEditPrompt.tsx` mirroring `DeleteConfirmationDialog`:
|
||||
|
||||
- Responsive: bottom sheet on ≤767px, centered dialog (max-width 480px) on ≥768px.
|
||||
- Focus trap, Escape = Cancel.
|
||||
- `role="dialog"`, `aria-modal="true"`, `aria-labelledby` → heading.
|
||||
- Copy: heading "Edit recurring series", body "This will update all occurrences of this event.", primary accent-filled "Update series", ghost "Cancel" (no destructive color).
|
||||
|
||||
`EventForm.tsx` gating:
|
||||
|
||||
- Edit mode AND `occurrence?.hasRrule === true` → Save opens `SeriesEditPrompt` instead of submitting directly.
|
||||
- "Update series" confirm → runs the existing `PATCH /api/events/:uid/edit` (whole-series PUT, no RECURRENCE-ID).
|
||||
- Cancel → returns to form without submitting.
|
||||
- Non-recurring / create-mode Save behavior unchanged.
|
||||
|
||||
### Task 3: All-Day Visual Distinction (999.6, D-12)
|
||||
|
||||
Added an all-day override block to the Schedule-X section of `apps/pwa/src/styles/index.css`.
|
||||
|
||||
The plan assumed selector `.sx__all-day-event` but Schedule-X v4.6.0 does not emit that class. Discovered during Task 3 and fixed in the follow-up commits (see Deviations). Final selectors targeting real Schedule-X classes:
|
||||
|
||||
- `.sx__date-grid-event` — week/day view all-day row events.
|
||||
- `.sx__month-grid-event:not(:has(.sx__month-grid-event-time))` — month view all-day chips (no time sub-element).
|
||||
|
||||
Both render as: full-width rounded pill (`border-radius: 4px`), white (`#FFFFFF`) label, `font-weight: 600`, solid background from the existing `calendarId` color config. Timed chips are unaffected.
|
||||
|
||||
### Integration Fix (commit `69e5ae8`)
|
||||
|
||||
`EventDetailPopover.test.tsx` fixtures updated to include the new required `hasRrule: boolean` field added to `CalendarOccurrence` in Plan 06-05 (cross-plan type change). Test suite was failing due to this new required field; fix was mechanical.
|
||||
|
||||
## Checkpoint Verification (Task 4)
|
||||
|
||||
Verified via playwright-cli against desktop Chromium with `DEV_AUTH_BYPASS=true`:
|
||||
|
||||
1. **End-tracking (D-04):** Moving a timed start → end follows, 1h duration preserved, end never before start. All-day: day-span preserved. PASS.
|
||||
2. **Recurrence bound (D-06):** Weekly recurrence → "Ends" control appears. "After N times" N=3 → server expansion returned exactly 3 occurrences (`COUNT=3`). "On date" → `UNTIL` bounded, occurrences stop. "Never" → unbounded. PASS.
|
||||
3. **FREQ persistence (D-07):** Created a Daily recurrence → occurrences render daily (not weekly). PASS.
|
||||
4. **Series edit (D-08/D-09):** Edited a recurring occurrence → Save opened focus-trapped "Edit recurring series" prompt. Escape cancelled (form unchanged). "Update series" → `PATCH /api/events/:uid/edit` fired; change reflected across occurrences. PASS.
|
||||
5. **All-day distinction (999.6):** All-day events rendered as solid filled white-text pills, visually distinct from timed chips at a glance. PASS (after follow-up CSS fix).
|
||||
6. **All-day no-drift (D-05):** Edited an existing all-day event, saved twice → event did not grow by a day. PASS.
|
||||
|
||||
Note: live event-create-via-the-form could not be exercised because the dev-bypass user (id 1) has no CalDAV credential/calendars (those belong to user 2). Server logic and form UI verified via route-mocks and direct DB occurrence inserts. Dev-seed gap — not a code defect.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Schedule-X all-day CSS targeted a non-existent class**
|
||||
- **Found during:** Task 4 (playwright-cli checkpoint — behavior 5 initially FAILED)
|
||||
- **Issue:** The plan and UI-SPEC called for selector `.sx__all-day-event`, but Schedule-X v4.6.0 does not emit that class on the DOM. The all-day pill styling had no effect.
|
||||
- **Fix:** Replaced with the real Schedule-X v4.6.0 selectors (`.sx__date-grid-event` for week/day; `.sx__month-grid-event:not(:has(.sx__month-grid-event-time))` for month view). Also remapped `--sx-color-primary-container` CSS variable as a fallback fill so the member color propagates even when the Schedule-X theming layer sets its own container variable.
|
||||
- **Files modified:** `apps/pwa/src/styles/index.css`
|
||||
- **Commits:** `6dbb166`, `5620261`
|
||||
|
||||
**2. [Rule 3 - Blocking] EventDetailPopover.test.tsx fixtures missing required `hasRrule` field**
|
||||
- **Found during:** Task 3 (test suite run)
|
||||
- **Issue:** Plan 06-05 added `hasRrule: boolean` as a required field on `CalendarOccurrence`; the existing `EventDetailPopover.test.tsx` fixtures omitted it, failing TypeScript compilation.
|
||||
- **Fix:** Added `hasRrule: false` (and `true` where the test exercises `hasRrule`-gated behavior) to all fixture objects.
|
||||
- **Files modified:** `apps/pwa/src/components/EventDetailPopover.test.tsx`
|
||||
- **Commit:** `69e5ae8`
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — all six checkpoint behaviors verified live.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's STRIDE register. T-06-06-input (form-side validation + server-side Zod enforcement — defense in depth), T-06-06-series (reuses existing edit route ownership/objectUrl/etag lookup), T-06-06-xss (plain-text JSX children, no `dangerouslySetInnerHTML`). No new surfaces introduced.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/pwa/src/components/SeriesEditPrompt.tsx` — FOUND (created)
|
||||
- `apps/pwa/src/components/EventForm.tsx` — FOUND (`computeNewTimedEnd`, `computeNewAllDayEnd`, `recurrenceUntil`, `recurrenceCount` present)
|
||||
- `apps/pwa/src/styles/index.css` — FOUND (`sx__date-grid-event` all-day override block present)
|
||||
- Commit `9aa15c4` — FOUND (test(06-06): RED)
|
||||
- Commit `cbf5f98` — FOUND (feat(06-06): end-tracking + recurrence-bound)
|
||||
- Commit `96ef0b4` — FOUND (feat(06-06): series-edit prompt)
|
||||
- Commit `883b00b` — FOUND (feat(06-06): all-day pill styling)
|
||||
- Commit `6dbb166` — FOUND (fix(06-06): real Schedule-X selectors)
|
||||
- Commit `5620261` — FOUND (fix(06-06): primary-container var remap)
|
||||
- Commit `69e5ae8` — FOUND (fix(06): hasRrule on EventDetailPopover fixtures)
|
||||
@@ -0,0 +1,133 @@
|
||||
# Phase 6: UX Polish - Context
|
||||
|
||||
**Gathered:** 2026-06-10
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Smooth the rough edges surfaced during Phase 3 Gate 2 live use so the app feels slick for the non-technical Apple member (hard UX constraint). **Polish only — no new capabilities.**
|
||||
|
||||
Scope is the six backlog polish items promoted into this phase:
|
||||
|
||||
| Item | What it fixes | Success criterion |
|
||||
|---|---|---|
|
||||
| 999.6 | All-day events visually distinct from timed events | #1 |
|
||||
| 999.7 | Event end auto-tracks start (any event); all-day-edit off-by-one | #2 |
|
||||
| 999.8 | Recurrence can be bounded (repeat-until / count); FREQ-persistence check | #2 |
|
||||
| 999.9 | A recurring series can be edited whole | #3 |
|
||||
| 999.3 | Session expiry redirects cleanly to sign-in instead of hanging | #4 |
|
||||
| 999.2 | No calendar/"sign-in required" flash before Authelia on cold load | #5 |
|
||||
|
||||
**Explicitly OUT of this phase** (promoted to milestone 1.1, see Deferred): 999.4 (reminder/VALARM selector) and 999.5 (first-login provider setup). The roadmap flagged both as "more feature than polish"; the user confirmed they belong in the next milestone, not here.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Scope (the backlog pull)
|
||||
- **D-01:** Phase 6 = the six polish items only (999.2/3/6/7/8/9). Do NOT pull in 999.4 or 999.5.
|
||||
- **D-02:** 999.4 (reminders/VALARM) and 999.5 (provider setup) move to a new **milestone 1.1**, started via the proper GSD milestone flow (`/gsd-new-milestone`). 999.1 (provider-abstraction) also remains backlog/1.1 candidate.
|
||||
|
||||
### Event-form end-tracking (999.7) — applies to ALL events, not just recurring
|
||||
- **D-03:** This is a general bug: when the user moves the **start** date/time forward, the **end** selector does not follow — for one-time, single-day, and timed events alike. Today an end left behind the start produces a stale/absurd span.
|
||||
- **D-04:** On any start change, **preserve the current duration** — timed: keep the start→end delta; all-day: keep the day-span — so the end moves with the start automatically. **Floor requirement** (the user's explicit minimum): the end must never strand behind the start day; at worst it snaps to the same day as the new start, and the user extends forward from there for genuine multi-day events.
|
||||
- **D-05:** The all-day-edit off-by-one part of 999.7 is **already fixed** (commit `f645644`, CR-03: exclusive `DTEND` → inclusive on edit pre-fill, `EventForm.tsx:199`). Verify it still holds; don't re-implement. Remaining 999.7 work = the start→end auto-advance.
|
||||
|
||||
### Recurrence bounding (999.8)
|
||||
- **D-06:** Add a bound to the recurrence control so users stop misusing the event end-date as "repeat until." Primary control: **"repeat until <date>"** (RRULE `UNTIL`) — matches the misuse pattern most directly. **"for N occurrences"** (RRULE `COUNT`) is acceptable to ship alongside or as the alternative; exact control set is a planning/UI-phase call. Each occurrence's duration must stay tied to start→end, NOT the recurrence span.
|
||||
- **D-07:** Verify/fix the FREQ-persistence bug noted in 999.8 (a daily selection reportedly persisted as weekly). Confirm the dropdown writes the selected `FREQ`.
|
||||
|
||||
### Recurring-series edit (999.9)
|
||||
- **D-08:** Behavior is **whole-series edit**: editing a recurring occurrence edits the master VEVENT (title / time / RRULE) for all occurrences. Per-occurrence (`RECURRENCE-ID`) and "this and following" edits stay **deferred to v1.x** (carried from Phase 3 D-03 / Deferred Items — not reopened here).
|
||||
- **D-09:** The confirmation/prompt UX for "this changes the whole series" is **delegated to `/gsd-ui-phase`** — lock the behavior, defer the wording/placement.
|
||||
|
||||
### Visual feel & sync feedback (user-added, 2026-06-10)
|
||||
- **D-12:** Beyond fixing individual rough edges, the user wants the app to **feel more distinct and modern** as a whole. The visual refresh is delegated to `/gsd-ui-phase`, which **should invoke the `frontend-design` skill** to drive a polished, non-generic aesthetic across the touched surfaces. Scope note: this widens "polish the rough edges" toward a light visual refresh — keep it to elevating the existing surfaces (calendar, event form, lists, sync feedback, auth splash), not a ground-up redesign; a full redesign would be its own phase. The user owns this call.
|
||||
- **D-13:** **Sync indicators must actually spin.** Today the spinner styles reference `animation: spin …` but the `@keyframes spin` is defined locally inside `PushPermissionPrompt.tsx` (`:359`) rather than globally — so `SyncStateToast` (`:158`) and `LiveSyncIndicator` likely don't animate when that component isn't mounted. Hoist `@keyframes spin` to a global stylesheet (or per-component) so every sync indicator animates. Planner/researcher to confirm the exact failure.
|
||||
|
||||
### Auth-flow polish (999.2 + 999.3)
|
||||
- **D-10:** 999.2 — gate the app render on auth state so no calendar shell / skeleton / "Sign-in required" alert paints before Authelia. While unauthenticated and redirecting, show a single neutral full-screen "Signing you in…" splash. Reserve the "Sign-in required" dead-end only for the one-shot-guard fall-through. Root cause + proposed fix are in ROADMAP 999.2 (CalendarShell optimistic render).
|
||||
- **D-11:** 999.3 — detect session expiry (401 / opaqueredirect) from ANY query or mutation (not just the initial `/api/me`) and drive a clear re-auth via top-level navigation to `/api/login`, ideally behind a brief "Your session expired — signing you back in…" interstitial. Centralize detection in `apps/pwa/src/api/client.ts` (typed `SessionExpiredError`, consistent `redirect:'manual'`) with a single TanStack Query/Mutation error handler re-arming `maybeRedirectToLogin()`. In-flight-write preservation/replay is a *nice-to-have*, not a hard requirement — acceptable to surface a clear re-auth rather than silently losing a write; planner to decide effort.
|
||||
|
||||
### Claude's Discretion / delegated to UI-phase
|
||||
- **All-day visual treatment (999.6):** behavior locked (must be distinguishable at a glance); the concrete treatment — full-width pill/bar vs background band vs distinct shape — is **delegated to `/gsd-ui-phase`** against the design system.
|
||||
- **Series-edit prompt UX (999.9):** delegated to `/gsd-ui-phase` (see D-09).
|
||||
- **Splash/interstitial copy (999.2/999.3):** exact wording open; "Signing you in…" / "Your session expired — signing you back in…" are starting points.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Phase scope & backlog source
|
||||
- `.planning/ROADMAP.md` §"Phase 6: UX Polish" — goal + 5 success criteria (the locked WHAT)
|
||||
- `.planning/ROADMAP.md` §"Backlog" 999.2/999.3/999.6/999.7/999.8/999.9 — each item carries a diagnosed root cause (from Phase 3 Gate 2, 2026-06-07) and a proposed fix. These are the authoritative problem statements.
|
||||
- `.planning/ROADMAP.md` §"Backlog" 999.1/999.4/999.5 — deferred to milestone 1.1 (do NOT implement here)
|
||||
- `.planning/PROJECT.md` — hard UX constraint (wife adoption), recurring "create + display only" v1 stance
|
||||
|
||||
### Event form / write-back (999.6/7/8/9)
|
||||
- `apps/pwa/src/components/EventForm.tsx` — start/end/all-day/recurrence state; `:199` exclusive→inclusive all-day fix (D-05); `:303` current all-day toggle clamp; `RecurrencePreset` usage
|
||||
- `apps/pwa/src/api/client.ts` §`RecurrencePreset` (`:130`) — frequency-only today; UNTIL/COUNT to be added (D-06)
|
||||
- `apps/api/src/broker/vevent.ts` — WR-04 inclusive→exclusive `DTEND` on write-back (symmetry partner for D-05)
|
||||
- `apps/api/src/broker/expand.ts` — recurrence expansion (per-occurrence duration must follow start→end, D-06)
|
||||
- `.planning/phases/03-event-write-back-pwa-install/03-CONTEXT.md` — D-03 recurrence create+display-only, WR-01..04 write-back contract, per-occurrence/"this-and-following" deferral (carried into D-08)
|
||||
|
||||
### Visual feel & sync indicators (D-12/D-13)
|
||||
- `apps/pwa/src/components/SyncStateToast.tsx` `:158` — uses `animation: 'spin …'` (D-13)
|
||||
- `apps/pwa/src/components/LiveSyncIndicator.tsx` — sync-state indicator (D-13)
|
||||
- `apps/pwa/src/components/PushPermissionPrompt.tsx` `:359` — where `@keyframes spin` is currently (locally) defined; needs hoisting to global (D-13)
|
||||
- `frontend-design` skill — to be invoked by `/gsd-ui-phase` for the modern/distinct visual refresh (D-12)
|
||||
|
||||
### Auth flow (999.2/999.3)
|
||||
- `apps/pwa/src/components/CalendarShell.tsx` — optimistic render that causes the flash (999.2 root cause, D-10)
|
||||
- `apps/pwa/src/api/client.ts` — `fetchMe` `redirect:'manual'`, opaqueredirect/401 detection (`:38–44`); centralization point for D-11
|
||||
- `apps/pwa/src/lib/loginRedirect.ts` — `maybeRedirectToLogin()` / one-shot `familysync.loginRedirectAttempted` guard to be re-armed (D-11)
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `EventForm.tsx`: already has start/end/all-day/recurrence state and the inclusive/exclusive all-day conversion helper (`exclusiveEndToInclusiveDate`). Extend it for end-tracking (D-04) and the recurrence bound control (D-06) rather than rebuilding.
|
||||
- `client.ts`: already detects `opaqueredirect`/401 for `fetchMe`; the typed error + single error-handler pattern (D-11) generalizes the existing one-shot detection.
|
||||
- `loginRedirect.ts` `maybeRedirectToLogin()`: reuse for the splash/redirect on both 999.2 and 999.3.
|
||||
|
||||
### Established Patterns
|
||||
- Write-back keeps an inclusive(form)↔exclusive(`DTEND`) convention split across PWA (`EventForm.tsx`) and API (`vevent.ts` WR-04). Any end-tracking change (D-04) must preserve this; all-day day-span is inclusive in the form.
|
||||
- Recurrence is "create + display only" in v1 (Phase 3 D-03). 999.9 adds whole-series edit on top; do NOT add per-occurrence edit.
|
||||
- Auth redirect is intentionally `redirect:'manual'` + document navigation (XHR can't follow cross-origin IdP redirects); keep that mechanism for D-10/D-11.
|
||||
|
||||
### Integration Points
|
||||
- 999.8 spans PWA (`EventForm` UNTIL/COUNT control) → API write (`vevent.ts` RRULE serialization) → expansion (`expand.ts`). Per-occurrence duration must derive from start→end, independent of the recurrence span.
|
||||
- 999.2/999.3 both center on `client.ts` + `CalendarShell.tsx` + `loginRedirect.ts` — a single auth-gating refactor likely serves both; plan them together.
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- The motivating bug for 999.7/999.8: a "recurring event" created start 2026-06-11, end 2026-08-13 (~2-month span) with a weekly RRULE rendered as overlapping bars across the calendar (looked duplicated). The end didn't track the start AND the series was unbounded — two distinct fixes (D-04 + D-06).
|
||||
- User's framing of 999.7 (verbatim intent): end-not-tracking-start is "not just for recurrence events — it's for any event… at the very least the end selector should move to the same day as the start time and the user can move it further forward for multi-day events from there."
|
||||
- Splash copy starting points: "Signing you in…" (999.2 cold load), "Your session expired — signing you back in…" (999.3 mid-use).
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- **999.4 — Event reminder/VALARM options** → milestone 1.1. The reminder-input half of the now-shipped Phase 5 push system; a real write-back feature, not polish.
|
||||
- **999.5 — First-login provider setup (Fastmail app password)** → milestone 1.1. Multi-member onboarding; security + UX surface, not polish.
|
||||
- **999.1 — Calendar provider abstraction** → backlog / milestone 1.1 candidate.
|
||||
- **Per-occurrence (`RECURRENCE-ID`) and "this and following" recurring edits** → v1.x (carried from Phase 3; 999.9 delivers whole-series only).
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 6-ux-polish*
|
||||
*Context gathered: 2026-06-10*
|
||||
@@ -0,0 +1,85 @@
|
||||
# Phase 6: UX Polish - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Phase:** 06-ux-polish
|
||||
**Areas discussed:** Backlog scope (999.4/999.5), All-day visual, Event-form behavior (999.7/999.8), Recurring-series edit (999.9)
|
||||
|
||||
---
|
||||
|
||||
## Backlog scope — pull in 999.4 / 999.5?
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Neither — keep 6-item polish scope | Phase 6 stays 999.2/3/6/7/8/9; 999.4/999.5 stay backlog | ✓ |
|
||||
| Pull in 999.4 (reminders) | Add VALARM selector | |
|
||||
| Pull in 999.5 (provider setup) | Add first-login app-password onboarding | |
|
||||
|
||||
**User's choice:** Keep Phase 6 as polish; move 999.4 and 999.5 to a new milestone 1.1 and start it via the proper GSD milestone flow.
|
||||
**Notes:** User wants the deferred items parked under milestone 1.1 explicitly, kicked off with `/gsd-new-milestone`. Sequencing (start 1.1 before vs after v1.0 ships) raised as a follow-up.
|
||||
|
||||
---
|
||||
|
||||
## All-day visual treatment (999.6)
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Full-width pill/bar | Google/Apple-style all-day row bar | |
|
||||
| Background band / tint | Subtle full-day band | |
|
||||
| Distinct shape/border | Border/icon marker only | |
|
||||
| Let UI-phase decide | Capture intent, defer treatment to /gsd-ui-phase | ✓ |
|
||||
|
||||
**User's choice:** Let UI-phase decide.
|
||||
**Notes:** Intent locked (must be distinguishable at a glance); concrete treatment delegated to /gsd-ui-phase.
|
||||
|
||||
---
|
||||
|
||||
## Event-form behavior (999.7 end-tracking + 999.8 recurrence bound)
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Preserve duration on start change | End auto-advances to keep duration | ✓ |
|
||||
| Bound by "repeat until <date>" | RRULE UNTIL | ✓ (primary) |
|
||||
| Bound by "for N occurrences" | RRULE COUNT | (acceptable alongside/alt) |
|
||||
|
||||
**User's choice:** Preserve duration; recurrence bound in scope (UNTIL primary, COUNT acceptable).
|
||||
**Notes:** IMPORTANT correction — end-not-tracking-start is NOT recurrence-specific; it affects every event (one-time, single-day, timed). Floor requirement: end must at least snap to the same day as the new start; user extends forward for multi-day. Off-by-one portion of 999.7 already fixed (commit f645644).
|
||||
|
||||
---
|
||||
|
||||
## Recurring-series edit (999.9)
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Whole series, with confirm | Edit master VEVENT after a confirmation | |
|
||||
| Whole series, no extra confirm | Edit series with helper note only | |
|
||||
| Let UI-phase decide the prompt UX | Lock whole-series behavior, defer prompt UX | ✓ |
|
||||
|
||||
**User's choice:** Let UI-phase decide the prompt UX.
|
||||
**Notes:** Behavior locked (whole-series edit of master VEVENT). Per-occurrence / "this and following" stay deferred to v1.x. Confirmation/prompt wording delegated to /gsd-ui-phase.
|
||||
|
||||
---
|
||||
|
||||
## Visual feel & sync feedback (user-added, post-questions)
|
||||
|
||||
**User's input (verbatim intent):** "For the UI polish, I want the whole thing to feel more distinct and modern. You can invoke the claude frontend-design skill for this to make it feel better. I want the sync indicators to actually spin too."
|
||||
|
||||
- Modern/distinct visual refresh → /gsd-ui-phase to invoke the `frontend-design` skill (D-12). Flagged as widening "polish" toward a light refresh; kept to elevating existing surfaces, not a ground-up redesign.
|
||||
- Sync indicators must actually spin (D-13) — likely root cause: `@keyframes spin` defined locally in `PushPermissionPrompt.tsx`, not global, so `SyncStateToast` / `LiveSyncIndicator` don't animate.
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- All-day visual treatment (999.6) — delegated to /gsd-ui-phase
|
||||
- Series-edit confirmation/prompt UX (999.9) — delegated to /gsd-ui-phase
|
||||
- Splash/interstitial copy (999.2/999.3) — open; starting points provided
|
||||
- Recurrence bound exact control set (UNTIL only vs +COUNT) — planning/UI call
|
||||
- In-flight-write preservation on session expiry (999.3) — nice-to-have, planner decides effort
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- 999.4 (reminders/VALARM) → milestone 1.1
|
||||
- 999.5 (first-login provider setup) → milestone 1.1
|
||||
- 999.1 (provider abstraction) → backlog / 1.1 candidate
|
||||
- Per-occurrence & "this and following" recurring edits → v1.x
|
||||
@@ -0,0 +1,726 @@
|
||||
# Phase 6: UX Polish — Pattern Map
|
||||
|
||||
**Mapped:** 2026-06-10
|
||||
**Files analyzed:** 14 (all modifications to existing files; 0 net-new source files)
|
||||
**Analogs found:** 14 / 14 — every touch point has a direct in-repo exemplar
|
||||
|
||||
---
|
||||
|
||||
## File Classification
|
||||
|
||||
| Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|---|---|---|---|---|
|
||||
| `apps/pwa/src/lib/eventDateTime.ts` | utility | transform | self (extend existing) | exact |
|
||||
| `apps/pwa/src/lib/eventDateTime.test.ts` | test | transform | self (extend existing) | exact |
|
||||
| `apps/pwa/src/components/EventForm.tsx` | component | request-response | self (extend existing) | exact |
|
||||
| `apps/pwa/src/api/client.ts` | service | request-response | self (extend existing) | exact |
|
||||
| `apps/pwa/src/api/client.test.ts` | test | request-response | `apps/pwa/src/lib/loginRedirect.test.ts` | role-match |
|
||||
| `apps/pwa/src/components/CalendarShell.tsx` | component | request-response | self (extend existing) | exact |
|
||||
| `apps/pwa/src/lib/loginRedirect.ts` | utility | request-response | self (reference only) | exact |
|
||||
| `apps/pwa/src/styles/tokens.css` | config | — | self (extend existing) | exact |
|
||||
| `apps/pwa/src/components/PushPermissionPrompt.tsx` | component | — | self (remove redundancy) | exact |
|
||||
| `apps/api/src/broker/expand.ts` | service | transform | self (extend existing) | exact |
|
||||
| `apps/api/src/broker/vevent.ts` | service | transform | self (extend existing) | exact |
|
||||
| `apps/api/src/broker/outboxWorker.ts` | service | CRUD | self (extend existing) | exact |
|
||||
| `apps/api/tests/broker/vevent.test.ts` | test | transform | self (extend existing) | exact |
|
||||
| `apps/api/tests/broker/expand.test.ts` | test | transform | self (extend existing) | exact |
|
||||
|
||||
---
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `apps/pwa/src/lib/eventDateTime.ts` (utility, transform) — D-04
|
||||
|
||||
**Change:** Add `computeNewTimedEnd` and `computeNewAllDayEnd` pure functions for end-tracking math.
|
||||
|
||||
**Analog:** same file — mirrors the existing `serializeEventDateTime` / `localWallClockToUtcIso` pattern exactly.
|
||||
|
||||
**Existing function signature pattern** (`eventDateTime.ts:33–51`):
|
||||
```typescript
|
||||
export function serializeEventDateTime(
|
||||
allDay: boolean,
|
||||
startDate: string,
|
||||
startTime: string,
|
||||
endDate: string,
|
||||
endTime: string,
|
||||
): { start: string; end: string } {
|
||||
if (allDay) {
|
||||
return { start: startDate, end: endDate }
|
||||
}
|
||||
return {
|
||||
start: localWallClockToUtcIso(startDate, startTime),
|
||||
end: localWallClockToUtcIso(endDate, endTime),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Local accessor pattern** (`eventDateTime.ts:59–61`):
|
||||
```typescript
|
||||
export function localWallClockToUtcIso(date: string, time: string): string {
|
||||
return new Date(`${date}T${time}:00`).toISOString()
|
||||
}
|
||||
```
|
||||
|
||||
**New functions to add** (copy the export + JSDoc style; use `new Date(...)` arithmetic inline — no third-party date lib):
|
||||
```typescript
|
||||
/** Preserve timed-event duration on start change. Returns new { endDate, endTime }. */
|
||||
export function computeNewTimedEnd(
|
||||
newStartDate: string,
|
||||
newStartTime: string,
|
||||
oldStartDate: string,
|
||||
oldStartTime: string,
|
||||
oldEndDate: string,
|
||||
oldEndTime: string,
|
||||
): { endDate: string; endTime: string } {
|
||||
const oldStartMs = new Date(`${oldStartDate}T${oldStartTime}:00`).getTime()
|
||||
const oldEndMs = new Date(`${oldEndDate}T${oldEndTime}:00`).getTime()
|
||||
const deltaMs = oldEndMs > oldStartMs ? oldEndMs - oldStartMs : 60 * 60 * 1000 // 1h floor
|
||||
const newEndDate = new Date(new Date(`${newStartDate}T${newStartTime}:00`).getTime() + deltaMs)
|
||||
return {
|
||||
endDate: localDateISO(newEndDate), // helper below — local accessors only (WR-05 contract)
|
||||
endTime: localTimeHHMM(newEndDate),
|
||||
}
|
||||
}
|
||||
|
||||
/** Preserve all-day day-span on start date change. Returns new endDate (inclusive). */
|
||||
export function computeNewAllDayEnd(
|
||||
newStartDate: string,
|
||||
oldStartDate: string,
|
||||
oldEndDate: string,
|
||||
): string {
|
||||
const span = Math.max(0, dateDiffDays(oldStartDate, oldEndDate))
|
||||
return addDaysISO(newStartDate, span)
|
||||
}
|
||||
```
|
||||
|
||||
**WR-05 constraint:** Date helpers MUST use local accessors (`getFullYear/getMonth/getDate/getHours/getMinutes`), never `toISOString().slice(0,10)` — that returns UTC date not local. See `parseDateTime` lines 107–133 for the established pattern.
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/lib/eventDateTime.test.ts` (test, transform) — D-04
|
||||
|
||||
**Change:** Extend with `computeNewTimedEnd` / `computeNewAllDayEnd` / floor-rule test cases.
|
||||
|
||||
**Analog:** same file — copy the `describe/it/expect` Vitest structure at lines 18–53.
|
||||
|
||||
**Test structure to mirror** (`eventDateTime.test.ts:18–53`):
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { serializeEventDateTime, localWallClockToUtcIso } from './eventDateTime.js'
|
||||
|
||||
describe('serializeEventDateTime (BUG A — write-path TZ)', () => {
|
||||
it('serializes a timed start to a UTC instant (ends in Z)', () => {
|
||||
const { start } = serializeEventDateTime(false, '2026-06-07', '09:00', '2026-06-07', '10:00')
|
||||
expect(start.endsWith('Z')).toBe(true)
|
||||
})
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
New `describe` block to add alongside:
|
||||
```typescript
|
||||
describe('computeNewTimedEnd (D-04 — end-tracking)', () => {
|
||||
it('preserves a 1-hour timed delta', () => { ... })
|
||||
it('preserves a multi-day timed delta', () => { ... })
|
||||
it('floors to 1h when old end was already behind old start', () => { ... })
|
||||
})
|
||||
|
||||
describe('computeNewAllDayEnd (D-04 — all-day end-tracking)', () => {
|
||||
it('preserves a 0-day span (single day)', () => { ... })
|
||||
it('preserves a 3-day span', () => { ... })
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/components/EventForm.tsx` (component, request-response) — D-04, D-06, D-07, D-08
|
||||
|
||||
**Changes:**
|
||||
1. Replace bare `onChange` on start date/time inputs with handlers that call `computeNewTimedEnd`/`computeNewAllDayEnd` (D-04).
|
||||
2. Add `recurrenceBound: 'never'|'until'|'count'`, `recurrenceUntil: string`, `recurrenceCount: number` state and bound-type UI (D-06).
|
||||
3. Add `hasRrule`-gated series-edit confirmation trigger (D-08).
|
||||
|
||||
**Exact bug sites (lines to replace):**
|
||||
|
||||
Start date `onChange` — line 671:
|
||||
```typescript
|
||||
// CURRENT (bug):
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
|
||||
// REPLACE WITH a handler that also calls computeNewTimedEnd / computeNewAllDayEnd
|
||||
onChange={(e) => {
|
||||
const newStart = e.target.value
|
||||
if (allDay) {
|
||||
setEndDate(computeNewAllDayEnd(newStart, startDate, endDate))
|
||||
} else {
|
||||
const { endDate: ed, endTime: et } = computeNewTimedEnd(newStart, startTime, startDate, startTime, endDate, endTime)
|
||||
setEndDate(ed)
|
||||
setEndTime(et)
|
||||
}
|
||||
setStartDate(newStart)
|
||||
}}
|
||||
```
|
||||
|
||||
Start time `onChange` — line 683 (same pattern but updates from time change):
|
||||
```typescript
|
||||
// CURRENT (bug):
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
// REPLACE WITH handler that also recomputes end (timed only; allDay has no time)
|
||||
```
|
||||
|
||||
**Existing state declaration pattern to add new recurrence state alongside** (`EventForm.tsx:201–207`):
|
||||
```typescript
|
||||
const [startDate, setStartDate] = useState(initStart.date)
|
||||
const [startTime, setStartTime] = useState(initStart.time)
|
||||
const [endDate, setEndDate] = useState(initEndDate)
|
||||
const [endTime, setEndTime] = useState(initEnd.time)
|
||||
const [recurrence, setRecurrence] = useState<RecurrencePreset>('none')
|
||||
// ADD:
|
||||
const [recurrenceBound, setRecurrenceBound] = useState<'never'|'until'|'count'>('never')
|
||||
const [recurrenceUntil, setRecurrenceUntil] = useState('')
|
||||
const [recurrenceCount, setRecurrenceCount] = useState(1)
|
||||
```
|
||||
|
||||
**Existing useEffect reset pattern to extend** (`EventForm.tsx:232–258`):
|
||||
The effect at lines 232–262 already resets all state when the form opens. Extend it to reset the three new recurrence-bound state variables to their defaults. Use the same `setRecurrence(derivedRecurrence ?? 'none')` pattern at line 258 as the model.
|
||||
|
||||
**Payload construction pattern** (the submit handler already at bottom of file builds `CreateEventPayload`):
|
||||
```typescript
|
||||
// Existing pattern — extend it:
|
||||
const payload: CreateEventPayload = {
|
||||
title,
|
||||
allDay,
|
||||
start: serialized.start,
|
||||
end: serialized.end,
|
||||
...(isEditMode ? {} : { recurrence }),
|
||||
...(location ? { location } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...(calendarUrl ? { calendarUrl } : {}),
|
||||
// ADD (D-06):
|
||||
...(recurrence !== 'none' && recurrenceBound === 'until' && recurrenceUntil
|
||||
? { recurrenceUntil }
|
||||
: {}),
|
||||
...(recurrence !== 'none' && recurrenceBound === 'count' && recurrenceCount >= 1
|
||||
? { recurrenceCount }
|
||||
: {}),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/api/client.ts` (service, request-response) — D-06, D-11
|
||||
|
||||
**Changes:**
|
||||
1. Add `recurrenceUntil?` and `recurrenceCount?` to `CreateEventPayload` (D-06).
|
||||
2. Add `hasRrule: boolean` to `CalendarOccurrence` (D-08).
|
||||
3. Add typed `SessionExpiredError` class (D-11).
|
||||
4. Add `redirect: 'manual'` + opaqueredirect/401 detection to ALL fetch functions (D-11).
|
||||
|
||||
**Existing `CreateEventPayload` type to extend** (`client.ts:136–148`):
|
||||
```typescript
|
||||
export interface CreateEventPayload {
|
||||
title: string
|
||||
allDay: boolean
|
||||
start: string
|
||||
end: string
|
||||
recurrence?: RecurrencePreset
|
||||
location?: string
|
||||
description?: string
|
||||
calendarUrl?: string
|
||||
// ADD (D-06):
|
||||
recurrenceUntil?: string // 'YYYY-MM-DD' — maps to RRULE UNTIL; undefined = no bound
|
||||
recurrenceCount?: number // integer >= 1 — maps to RRULE COUNT; undefined = no bound
|
||||
}
|
||||
```
|
||||
|
||||
**Existing `CalendarOccurrence` interface to extend** (`client.ts:71–91`):
|
||||
```typescript
|
||||
export interface CalendarOccurrence {
|
||||
// ... all existing fields ...
|
||||
description: string | null
|
||||
// ADD (D-08):
|
||||
hasRrule: boolean // true when this occurrence belongs to a recurring series
|
||||
}
|
||||
```
|
||||
|
||||
**Existing auth detection pattern to generalize** (`client.ts:36–53` — `fetchMe`):
|
||||
```typescript
|
||||
// CURRENT — only in fetchMe:
|
||||
const res = await fetch('/api/me', {
|
||||
credentials: 'include',
|
||||
redirect: 'manual', // ← only fetchMe has this
|
||||
})
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) {
|
||||
throw new Error('GET /api/me: authentication required') // ← untyped
|
||||
}
|
||||
|
||||
// TARGET — typed error class + helper used by ALL fetch functions:
|
||||
export class SessionExpiredError extends Error {
|
||||
readonly name = 'SessionExpiredError'
|
||||
constructor() {
|
||||
super('Session expired — re-authentication required')
|
||||
Object.setPrototypeOf(this, SessionExpiredError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthResponse(res: Response, label: string): void {
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) {
|
||||
throw new SessionExpiredError()
|
||||
}
|
||||
if (!res.ok) throw new Error(`${label} failed: ${res.status}`)
|
||||
}
|
||||
```
|
||||
|
||||
Every fetch function (`fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`, `fetchSyncStatus`, `fetchWritableCalendars`) gains `redirect: 'manual'` + `handleAuthResponse(res, 'GET/POST/... /api/...')`. Mirror the `fetchMe` call structure exactly.
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/components/CalendarShell.tsx` (component, request-response) — D-10, D-11
|
||||
|
||||
**Changes:**
|
||||
1. Replace the `meQuery.isError` "Sign-in required" branch (lines 220–235) with an `AuthSplash` component render (D-10).
|
||||
2. Add `meQuery.isLoading` early-return with `AuthSplash` so no skeleton paints before auth (D-10).
|
||||
3. Wire the Zustand `sessionExpired` flag to an `AuthSplash` render above the main tree (D-11).
|
||||
|
||||
**Current "Sign-in required" branch to replace** (`CalendarShell.tsx:220–235`):
|
||||
```tsx
|
||||
// CURRENT — remove this entire block:
|
||||
if (meQuery.isError) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
color: 'var(--color-destructive)',
|
||||
padding: 'var(--space-4)',
|
||||
...
|
||||
}}
|
||||
>
|
||||
Sign-in required
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// REPLACE WITH:
|
||||
if (meQuery.isLoading) {
|
||||
return <AuthSplash state="loading" />
|
||||
}
|
||||
if (meQuery.isError) {
|
||||
// useEffect at line 197 calls maybeRedirectToLogin() — splash shows while redirect fires
|
||||
return <AuthSplash state="redirecting" />
|
||||
}
|
||||
```
|
||||
|
||||
**Existing useEffect pattern for auth redirect to keep** (`CalendarShell.tsx:196–208`):
|
||||
```typescript
|
||||
// Keep these two effects — they handle the one-shot guard correctly:
|
||||
useEffect(() => {
|
||||
if (meQuery.isError) {
|
||||
maybeRedirectToLogin()
|
||||
}
|
||||
}, [meQuery.isError])
|
||||
|
||||
useEffect(() => {
|
||||
if (meQuery.isSuccess) {
|
||||
clearLoginRedirect()
|
||||
}
|
||||
}, [meQuery.isSuccess])
|
||||
```
|
||||
|
||||
**Existing import pattern to extend** (`CalendarShell.tsx:44–56`):
|
||||
```typescript
|
||||
import { fetchMe, fetchEvents } from '../api/client.js'
|
||||
import { maybeRedirectToLogin, clearLoginRedirect } from '../lib/loginRedirect.js'
|
||||
// ADD:
|
||||
import { SessionExpiredError } from '../api/client.js'
|
||||
import { AuthSplash } from './AuthSplash.js' // new component
|
||||
```
|
||||
|
||||
**D-11 TanStack Query v5 global error handler** — wire in `App.tsx` (or wherever `QueryClient` is created), NOT in `CalendarShell`. Pattern is `queryClient.getQueryCache().subscribe(...)` and `queryClient.getMutationCache().subscribe(...)`. Planner must verify exact TanStack Query v5 API via Context7 before coding. The semantic intent:
|
||||
```typescript
|
||||
queryClient.getQueryCache().subscribe((event) => {
|
||||
if (event.type === 'error' && event.error instanceof SessionExpiredError) {
|
||||
setSessionExpired(true) // Zustand flag
|
||||
}
|
||||
})
|
||||
queryClient.getMutationCache().subscribe((event) => {
|
||||
if (event.type === 'error' && event.error instanceof SessionExpiredError) {
|
||||
setSessionExpired(true)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/lib/loginRedirect.ts` (utility) — D-11
|
||||
|
||||
**No changes to the file itself.** The existing `maybeRedirectToLogin()` and `clearLoginRedirect()` functions are reused as-is. The D-11 session-expiry path must call `clearLoginRedirect()` BEFORE calling `maybeRedirectToLogin()` so the one-shot guard fires fresh. This is already the pattern for the `meQuery.isSuccess` path at `CalendarShell.tsx:205`.
|
||||
|
||||
**Reference** (`loginRedirect.ts:28–45`):
|
||||
```typescript
|
||||
export function maybeRedirectToLogin(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
try {
|
||||
if (sessionStorage.getItem(LOGIN_REDIRECT_KEY) !== null) return false
|
||||
sessionStorage.setItem(LOGIN_REDIRECT_KEY, '1')
|
||||
window.location.href = '/api/login'
|
||||
return true
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
export function clearLoginRedirect(): void {
|
||||
try { sessionStorage.removeItem(LOGIN_REDIRECT_KEY) } catch { }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/styles/tokens.css` (config) — D-13
|
||||
|
||||
**Change:** Add missing `@keyframes pulse`. Remove nothing (the `@keyframes spin` at lines 140–147 stays).
|
||||
|
||||
**Existing `@keyframes spin` to copy the CSS pattern from** (`tokens.css:140–147`):
|
||||
```css
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
```
|
||||
|
||||
**Add directly after it:**
|
||||
```css
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
```
|
||||
|
||||
**Also add `@keyframes shimmer`** is already present at lines 131–138. Follow the same format (no vendor prefixes, no `animation-fill-mode` in the keyframe block itself).
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/components/PushPermissionPrompt.tsx` (component) — D-13
|
||||
|
||||
**Change:** Remove the redundant local `<style>` block at lines 358–363. No other changes.
|
||||
|
||||
**Block to delete** (`PushPermissionPrompt.tsx:357–363`):
|
||||
```tsx
|
||||
{/* Spin animation for loader */}
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
```
|
||||
|
||||
The `tokens.css` global definition (loaded via `main.tsx` → `index.css` → `@import './tokens.css'`) already covers this. The `animation: 'spin 1s linear infinite'` inline style in this component continues to work unchanged.
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/src/broker/expand.ts` (service, transform) — D-08
|
||||
|
||||
**Change:** Add `hasRrule: boolean` to `CalendarOccurrence` interface and populate it in `expandOccurrences`.
|
||||
|
||||
**Interface to extend** (`expand.ts:37–67`):
|
||||
```typescript
|
||||
export interface CalendarOccurrence {
|
||||
id: string
|
||||
uid: string
|
||||
calendarId: number
|
||||
// ... all existing fields ...
|
||||
description: string | null
|
||||
// ADD:
|
||||
hasRrule: boolean // true when this event has an RRULE (recurring series)
|
||||
}
|
||||
```
|
||||
|
||||
**Population pattern** — in `expandOccurrences`, `event.isRecurring()` is already called at line 223 for the non-recurring branch. Capture it once before the branch, then pass to each `occurrences.push(...)`:
|
||||
|
||||
```typescript
|
||||
// BEFORE the branch at line 223:
|
||||
const isRecurring = event.isRecurring()
|
||||
|
||||
// In the non-recurring push at lines 241–256 — add:
|
||||
occurrences.push({
|
||||
// ... existing fields ...
|
||||
hasRrule: isRecurring, // always false here — non-recurring branch
|
||||
})
|
||||
|
||||
// In the recurring push at lines 287–302 — add:
|
||||
occurrences.push({
|
||||
// ... existing fields ...
|
||||
hasRrule: isRecurring, // always true here — recurring branch
|
||||
})
|
||||
```
|
||||
|
||||
**Existing push pattern to mirror** (`expand.ts:241–257`):
|
||||
```typescript
|
||||
occurrences.push({
|
||||
id: makeOccurrenceId(uid, dtstart),
|
||||
uid,
|
||||
calendarId,
|
||||
calendarName,
|
||||
ownerUserId,
|
||||
ownerName,
|
||||
color,
|
||||
isShared,
|
||||
title: event.summary ?? '',
|
||||
start,
|
||||
end,
|
||||
allDay,
|
||||
location: event.location ?? null,
|
||||
description: event.description ?? null,
|
||||
// ADD: hasRrule: isRecurring
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/src/broker/vevent.ts` (service, transform) — D-06
|
||||
|
||||
**Change:** Extend RRULE serialization to support `UNTIL` (DATE and DATETIME forms) and `COUNT`. No interface changes to `NewEventParams` are strictly required — callers assemble the `rruleString` before passing it. The assembly logic lives in `outboxWorker.ts`.
|
||||
|
||||
**Existing RRULE serialization pattern to keep unchanged** (`vevent.ts:143–148`):
|
||||
```typescript
|
||||
// This pattern handles any valid RRULE string — UNTIL/COUNT included:
|
||||
if (params.rruleString) {
|
||||
const recur = ICAL.Recur.fromString(params.rruleString)
|
||||
const rruleProp = new ICAL.Property('rrule')
|
||||
rruleProp.setValue(recur)
|
||||
vevent.addProperty(rruleProp)
|
||||
}
|
||||
```
|
||||
|
||||
**`RRULE_PRESETS` map to keep unchanged** (`vevent.ts:49–54`):
|
||||
```typescript
|
||||
export const RRULE_PRESETS: Record<string, string> = {
|
||||
daily: 'FREQ=DAILY',
|
||||
weekly: 'FREQ=WEEKLY',
|
||||
monthly: 'FREQ=MONTHLY',
|
||||
yearly: 'FREQ=YEARLY',
|
||||
}
|
||||
```
|
||||
|
||||
The UNTIL/COUNT string assembly happens in `outboxWorker.ts` (see below). `buildVeventString` receives the complete `rruleString` and serializes it correctly via `ICAL.Recur.fromString` — verified in RESEARCH.md.
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/src/broker/outboxWorker.ts` (service, CRUD) — D-06
|
||||
|
||||
**Changes:**
|
||||
1. Read `recurrenceUntil` and `recurrenceCount` from the validated payload.
|
||||
2. Add `assembleRruleString` helper that appends `;COUNT=N` or `;UNTIL=YYYYMMDD[T235959Z]` to the base preset string.
|
||||
3. On series edit, when only the bound changes (no new `recurrence` preset), parse the preserved RRULE and add/replace the bound modifier.
|
||||
|
||||
**Existing `outboxPayloadSchema` to extend** (`outboxWorker.ts:71–83`):
|
||||
```typescript
|
||||
const outboxPayloadSchema = z
|
||||
.object({
|
||||
title: z.string().min(1).max(255),
|
||||
allDay: z.boolean(),
|
||||
start: z.string().min(1).max(64),
|
||||
end: z.string().min(1).max(64),
|
||||
location: z.string().max(2000).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
recurrence: z.enum(['none', 'daily', 'weekly', 'monthly', 'yearly']).optional(),
|
||||
calendarUrl: z.string().url().max(1024).optional(),
|
||||
_preservedRrule: z.string().max(1024).optional(),
|
||||
// ADD (D-06):
|
||||
recurrenceUntil: z.string().max(10).optional(), // 'YYYY-MM-DD'
|
||||
recurrenceCount: z.number().int().min(1).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
```
|
||||
|
||||
**Existing RRULE assembly site to extend** (`outboxWorker.ts:255–308`):
|
||||
```typescript
|
||||
// Existing (keep):
|
||||
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence')
|
||||
const rruleFromPayload =
|
||||
fields.recurrence && fields.recurrence !== 'none'
|
||||
? RRULE_PRESETS[fields.recurrence as string]
|
||||
: undefined
|
||||
|
||||
// ADD: assemble final rruleString with optional bound modifier
|
||||
function assembleRruleString(
|
||||
basePreset: string, // e.g. 'FREQ=WEEKLY' from RRULE_PRESETS
|
||||
until?: string, // 'YYYY-MM-DD'
|
||||
count?: number,
|
||||
allDay?: boolean,
|
||||
): string {
|
||||
let s = basePreset
|
||||
if (count !== undefined) {
|
||||
s += `;COUNT=${count}`
|
||||
} else if (until) {
|
||||
if (allDay) {
|
||||
s += `;UNTIL=${until.replace(/-/g, '')}` // DATE form: 20260630
|
||||
} else {
|
||||
s += `;UNTIL=${until.replace(/-/g, '')}T235959Z` // DATETIME UTC: 20260630T235959Z
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Then pass to buildVeventString:
|
||||
const finalRruleString = hasExplicitRecurrence && rruleFromPayload
|
||||
? assembleRruleString(rruleFromPayload, fields.recurrenceUntil, fields.recurrenceCount, fields.allDay)
|
||||
: (preservedRrule
|
||||
// Series edit with bound change only: parse + modify preserved RRULE
|
||||
? (fields.recurrenceUntil || fields.recurrenceCount !== undefined
|
||||
? assembleRruleString(
|
||||
// Strip any existing UNTIL/COUNT from the preserved rule first
|
||||
preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, ''),
|
||||
fields.recurrenceUntil,
|
||||
fields.recurrenceCount,
|
||||
fields.allDay,
|
||||
)
|
||||
: preservedRrule)
|
||||
: rruleFromPayload)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/tests/broker/vevent.test.ts` (test, transform) — D-06
|
||||
|
||||
**Change:** Add test cases for UNTIL (DATE form), UNTIL (DATETIME UTC form), COUNT.
|
||||
|
||||
**Existing test structure to extend** (`vevent.test.ts:22–60`):
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildVeventString } from '../../src/broker/vevent.js'
|
||||
|
||||
describe('buildVeventString', () => {
|
||||
it('produces a VCALENDAR string containing a VEVENT for a timed event', () => {
|
||||
const result = buildVeventString({ summary: 'Team standup', allDay: false, ... })
|
||||
expect(result.icsString).toContain('RRULE:')
|
||||
})
|
||||
```
|
||||
|
||||
Add alongside existing cases:
|
||||
```typescript
|
||||
it('serializes COUNT in RRULE for a timed event', () => {
|
||||
const result = buildVeventString({
|
||||
summary: 'Weekly',
|
||||
allDay: false,
|
||||
dtstart: new Date('2026-06-10T09:00:00Z'),
|
||||
dtend: new Date('2026-06-10T10:00:00Z'),
|
||||
rruleString: 'FREQ=WEEKLY;COUNT=5',
|
||||
})
|
||||
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;COUNT=5')
|
||||
})
|
||||
|
||||
it('serializes UNTIL as DATE form for all-day events', () => {
|
||||
const result = buildVeventString({
|
||||
summary: 'Daily standup',
|
||||
allDay: true,
|
||||
dtstart: '2026-06-10',
|
||||
dtend: '2026-06-11',
|
||||
rruleString: 'FREQ=DAILY;UNTIL=20260630',
|
||||
})
|
||||
expect(result.icsString).toContain('RRULE:FREQ=DAILY;UNTIL=20260630')
|
||||
expect(result.icsString).not.toContain('T235959Z')
|
||||
})
|
||||
|
||||
it('serializes UNTIL as DATETIME UTC form for timed events', () => {
|
||||
const result = buildVeventString({
|
||||
summary: 'Weekly',
|
||||
allDay: false,
|
||||
dtstart: new Date('2026-06-10T09:00:00Z'),
|
||||
dtend: new Date('2026-06-10T10:00:00Z'),
|
||||
rruleString: 'FREQ=WEEKLY;UNTIL=20260630T235959Z',
|
||||
})
|
||||
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;UNTIL=20260630T235959Z')
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/tests/broker/expand.test.ts` (test, transform) — D-06, D-08
|
||||
|
||||
**Change:** Add `hasRrule` assertions to existing recurring expansion tests; add bounded RRULE test case.
|
||||
|
||||
**Existing test file** (`tests/broker/expand.test.ts`) — add assertions to any test that calls `expandOccurrences` with a recurring event:
|
||||
```typescript
|
||||
// Pattern: each occurrence in the result must have hasRrule set
|
||||
const occs = expandOccurrences(rawVevent, windowStart, windowEnd, ...)
|
||||
expect(occs[0].hasRrule).toBe(true) // recurring event
|
||||
|
||||
// For non-recurring:
|
||||
expect(occs[0].hasRrule).toBe(false)
|
||||
|
||||
// For bounded RRULE: correct occurrence count
|
||||
const boundedRrule = 'FREQ=WEEKLY;COUNT=3'
|
||||
// inject into rawVevent → expandOccurrences → length === 3 within a wide window
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Auth detection — `redirect: 'manual'` + typed error
|
||||
**Source:** `apps/pwa/src/api/client.ts` lines 36–53 (`fetchMe`)
|
||||
**Apply to:** All fetch functions in `client.ts` (D-11)
|
||||
|
||||
The existing `fetchMe` pattern is the model for generalizing:
|
||||
```typescript
|
||||
const res = await fetch('/api/me', {
|
||||
credentials: 'include',
|
||||
redirect: 'manual', // ← add to every fetch call
|
||||
})
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) {
|
||||
throw new Error('GET /api/me: authentication required') // ← replace with SessionExpiredError
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /api/me failed: ${res.status}`)
|
||||
}
|
||||
```
|
||||
|
||||
### One-shot login redirect
|
||||
**Source:** `apps/pwa/src/lib/loginRedirect.ts` lines 28–64
|
||||
**Apply to:** `CalendarShell.tsx` auth splash (D-10), session-expiry interstitial (D-11)
|
||||
|
||||
Rule: always call `clearLoginRedirect()` before `maybeRedirectToLogin()` in the D-11 (session-expiry) path so the guard fires fresh. The D-10 (cold load) path keeps the existing `useEffect` pattern unchanged.
|
||||
|
||||
### Zod schema extension
|
||||
**Source:** `apps/api/src/broker/outboxWorker.ts` lines 71–83 (`outboxPayloadSchema`)
|
||||
**Apply to:** `outboxPayloadSchema` (D-06) AND `eventFieldsSchema` in `apps/api/src/routes/events.ts` (same two new fields must be added to the route-level schema)
|
||||
|
||||
Mirror the `.optional()` pattern already used for `location`, `description`, `calendarUrl`.
|
||||
|
||||
### ICS RRULE serialization — `ICAL.Recur.fromString` + `rruleProp.setValue`
|
||||
**Source:** `apps/api/src/broker/vevent.ts` lines 143–148
|
||||
**Apply to:** All RRULE assembly in `outboxWorker.ts` (D-06)
|
||||
|
||||
Never concatenate raw RRULE strings into the ICS via `addPropertyWithValue('rrule', string)` — that serializes character-by-character. Always go through `ICAL.Recur.fromString(rruleString)` + `rruleProp.setValue(recur)`.
|
||||
|
||||
### CalendarOccurrence interface atomicity
|
||||
**Source:** `apps/api/src/broker/expand.ts:37–67` (server) + `apps/pwa/src/api/client.ts:71–91` (client)
|
||||
**Apply to:** `hasRrule` addition (D-08)
|
||||
|
||||
Both interfaces are mirrored manually (no codegen). Update them in the same commit. The server `expand.ts` type is the source of truth; `client.ts` is the consumer mirror. Pitfall 4 in RESEARCH.md documents this.
|
||||
|
||||
### Pure-function test structure
|
||||
**Source:** `apps/pwa/src/lib/eventDateTime.test.ts` lines 15–53
|
||||
**Apply to:** New `computeNewTimedEnd` / `computeNewAllDayEnd` tests (D-04)
|
||||
|
||||
Copy the `import { describe, it, expect } from 'vitest'` header and `describe('...', () => { it('...', () => { ... }) })` structure exactly. Tests run with `cd apps/pwa && pnpm test -- lib/eventDateTime`.
|
||||
|
||||
---
|
||||
|
||||
## No Analog Found
|
||||
|
||||
All touched files have direct in-repo analogs. One net-new component is implied:
|
||||
|
||||
| Implied New File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `apps/pwa/src/components/AuthSplash.tsx` | component | request-response | No existing full-screen auth splash component; closest analog is `SkeletonCalendar.tsx` (full-screen centered loading state) |
|
||||
|
||||
**AuthSplash analog:** `apps/pwa/src/components/SkeletonCalendar.tsx` — a full-screen centered loading component. Copy its layout structure and inline-style approach. The `AuthSplash` variant renders "Signing you in…" (state="loading") or "Your session expired — signing you back in…" (state="redirecting") with the existing `spin` animation token.
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `apps/pwa/src/`, `apps/api/src/broker/`, `apps/api/tests/broker/`
|
||||
**Files read:** 14 source files + 2 test files
|
||||
**Pattern extraction date:** 2026-06-10
|
||||
@@ -0,0 +1,770 @@
|
||||
# Phase 6: UX Polish — Research
|
||||
|
||||
**Researched:** 2026-06-10
|
||||
**Domain:** React PWA / Hono API — form UX, ical.js recurrence, OIDC auth gating, CSS animation
|
||||
**Confidence:** HIGH on code-verified claims; MEDIUM on library API specifics (Context7)
|
||||
|
||||
---
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
- **D-01:** Phase 6 = the six polish items only (999.2/3/6/7/8/9). Do NOT pull in 999.4 or 999.5.
|
||||
- **D-02:** 999.4 (reminders/VALARM) and 999.5 (provider setup) move to milestone 1.1. 999.1 also remains backlog/1.1 candidate.
|
||||
- **D-03:** End-tracking is a general bug — applies to one-time, single-day, and timed events alike.
|
||||
- **D-04:** Preserve current duration on any start change (timed: delta; all-day: day-span). Floor: end never strands behind start.
|
||||
- **D-05:** All-day-edit off-by-one already fixed at `EventForm.tsx:199` (`exclusiveEndToInclusiveDate`). Verify it holds; do NOT re-implement.
|
||||
- **D-06:** Add recurrence bound — "repeat until <date>" (RRULE `UNTIL`) and/or "for N occurrences" (`COUNT`). Each occurrence's duration ties to start→end delta, NOT the recurrence span.
|
||||
- **D-07:** Verify/fix FREQ-persistence bug — confirm the dropdown correctly writes the selected FREQ.
|
||||
- **D-08:** Whole-series edit only (title/time/RRULE on master VEVENT). Per-occurrence RECURRENCE-ID stays deferred to v1.x.
|
||||
- **D-09:** Series-edit confirmation/prompt UX delegated to `/gsd-ui-phase`.
|
||||
- **D-10:** Gate app render on auth state — no calendar shell/skeleton/"Sign-in required" flash before Authelia. Show "Signing you in…" splash while unauthenticated.
|
||||
- **D-11:** Centralize 401/opaqueredirect detection from ANY query or mutation. Typed `SessionExpiredError`, single TanStack Query error handler, re-arm `maybeRedirectToLogin()` on session expiry mid-use.
|
||||
- **D-12:** Visual refresh delegated to `/gsd-ui-phase` invoking the `frontend-design` skill.
|
||||
- **D-13:** Hoist `@keyframes spin` to global stylesheet so all sync indicators animate.
|
||||
|
||||
### Claude's Discretion / Delegated to UI-Phase
|
||||
|
||||
- All-day visual treatment (999.6): behavior locked; concrete CSS treatment delegated to `/gsd-ui-phase`.
|
||||
- Series-edit prompt UX (999.9): delegated to `/gsd-ui-phase` (see D-09).
|
||||
- Splash/interstitial copy: "Signing you in…" / "Your session expired — signing you back in…" are starting points.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
|
||||
- 999.4 — Event reminder/VALARM options → milestone 1.1
|
||||
- 999.5 — First-login provider setup → milestone 1.1
|
||||
- 999.1 — Calendar provider abstraction → backlog/milestone 1.1 candidate
|
||||
- Per-occurrence (RECURRENCE-ID) and "this and following" recurring edits → v1.x
|
||||
</user_constraints>
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
This is a polish phase on code that was shipped in Phases 3 and 5. Every item has a diagnosed root cause from Phase 3 Gate 2 live use. The research task is to confirm the exact fix targets in the real code, resolve library API specifics that the planner cannot safely assume, and define the verification architecture.
|
||||
|
||||
Six independent fix areas, each self-contained but sharing the EventForm and client.ts touch points:
|
||||
|
||||
1. **D-04/D-07 — EventForm: end-tracking + FREQ-persistence.** Both are EventForm state bugs. End-tracking is a missing `onChange` handler on `startDate`/`startTime` that recomputes end. FREQ-persistence is a suspected state-initialization issue (see §FREQ-Persistence Diagnosis below — the `RRULE_PRESETS` map and the API route are clean; the bug origin is narrowed to form state reset behavior).
|
||||
2. **D-06/D-08 — RRULE UNTIL/COUNT + whole-series edit.** Spans PWA → API → expansion. Requires a schema extension on `CalendarOccurrence` to expose `hasRrule` (currently absent from the type on both sides).
|
||||
3. **D-10/D-11 — Auth gating.** A single `CalendarShell` refactor plus typed `SessionExpiredError` in `client.ts` serves both items.
|
||||
4. **D-13 — Spinner.** The `@keyframes spin` IS already in `tokens.css` (lines 140–147). The bug is that `PushPermissionPrompt.tsx` contains a redundant local `<style>` block redefining it (`:358–363`). `SyncStateToast` and `LiveSyncIndicator` use `animation: 'spin 1s linear infinite'` as inline styles. These work correctly as long as `tokens.css` is loaded — which it is (main.tsx imports `./styles/index.css` which `@import`s `tokens.css`). Additionally, `@keyframes pulse` is MISSING from `tokens.css` — `LiveSyncIndicator` references `animation: 'pulse 1.4s ease-in-out infinite'` for the reconnecting dot.
|
||||
|
||||
**Primary recommendation:** Fix items in order of risk: D-06 (RRULE changes) first (most complex, cross-stack), then D-08 (requires hasRrule schema extension), then D-04 (pure form logic), then D-10/D-11 (auth gating refactor), then D-13 (CSS hoist, lowest risk). D-07 is a verification step folded into D-06 work.
|
||||
|
||||
---
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| RRULE UNTIL/COUNT serialization | API / Backend (`vevent.ts`) | PWA (`client.ts` payload shape) | ical.js RRULE building lives in `buildVeventString`; PWA only passes a string |
|
||||
| Recurrence bound control UI | Browser / Client (EventForm.tsx) | — | Pure form field; no server state |
|
||||
| End-tracking math | Browser / Client (EventForm.tsx) | — | Duration arithmetic on local form state |
|
||||
| Whole-series edit (master PUT) | API / Backend (outboxWorker + write.ts) | PWA (series-edit prompt gating) | PUT to Fastmail CalDAV is already implemented; PWA needs the detection signal |
|
||||
| hasRrule exposure | API / Backend (expand.ts + events route) | Browser / Client (client.ts type) | DB has `has_rrule`; must flow through CalendarOccurrence type |
|
||||
| Auth splash / session-expiry interstitial | Browser / Client (CalendarShell + client.ts) | — | OIDC guard is server-side; client handles redirect |
|
||||
| Spin animation | Browser / Client (tokens.css + components) | — | CSS keyframe availability is a stylesheet concern |
|
||||
| Pulse animation | Browser / Client (tokens.css) | — | Missing keyframe; needs addition |
|
||||
|
||||
---
|
||||
|
||||
## Standard Stack
|
||||
|
||||
No new packages are installed in this phase. All fixes use the already-pinned stack from `CLAUDE.md`:
|
||||
|
||||
| Library | Version (pinned) | Role in this phase |
|
||||
|---------|------------------|--------------------|
|
||||
| ical.js | 2.2.1 | RRULE UNTIL/COUNT serialization in `vevent.ts` |
|
||||
| React 19 | 19.x | EventForm state updates |
|
||||
| TanStack Query | 5.101.0 | Global error handler for session expiry |
|
||||
| Zustand | 5.0.14 | `sessionExpired` flag for session-expiry interstitial |
|
||||
| Vitest | (existing) | All unit/integration tests |
|
||||
| playwright-cli | `/usr/local/bin/playwright-cli` | Browser-level visual verification |
|
||||
|
||||
**No package installations required for this phase.**
|
||||
|
||||
---
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
No new packages are installed in Phase 6. This section is not applicable.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
No new files/directories needed beyond what already exists. New test files follow the established `tests/broker/`, `tests/routes/`, `src/lib/*.test.ts`, `src/components/*.test.tsx` patterns.
|
||||
|
||||
---
|
||||
|
||||
## Focus Area Findings
|
||||
|
||||
### Focus 1: Recurrence Bounding — D-06 (RRULE UNTIL/COUNT)
|
||||
|
||||
#### PWA side — `RecurrencePreset` extension (`client.ts:130`, `EventForm.tsx`)
|
||||
|
||||
**Current shape** [VERIFIED: codebase read]:
|
||||
```typescript
|
||||
// client.ts:130
|
||||
export type RecurrencePreset = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
|
||||
// CreateEventPayload:
|
||||
recurrence?: RecurrencePreset
|
||||
```
|
||||
|
||||
**Required extension:** `RecurrencePreset` covers frequency only. `CreateEventPayload` needs two new optional fields:
|
||||
|
||||
```typescript
|
||||
// Add to CreateEventPayload
|
||||
recurrenceUntil?: string // 'YYYY-MM-DD' — maps to RRULE UNTIL; undefined = no bound
|
||||
recurrenceCount?: number // integer ≥ 1 — maps to RRULE COUNT; undefined = no bound
|
||||
```
|
||||
|
||||
Rules:
|
||||
- `recurrenceUntil` and `recurrenceCount` are mutually exclusive (RFC 5545 §3.3.10).
|
||||
- Only sent when `recurrence !== 'none'`.
|
||||
- On EDIT, same omit-if-absent rule as `recurrence` (WR-01 pattern already in place).
|
||||
|
||||
**EventForm UI placement** (per UI-SPEC.md §Surface 5): below the frequency `<select>`, shown only when `recurrence !== 'none'`. Three-option bound-type selector: "Never" / "On date" / "After N times". Controlled by a new `recurrenceBound: 'never' | 'until' | 'count'` state variable + `recurrenceUntil: string` + `recurrenceCount: number` state.
|
||||
|
||||
#### API route validation (`events.ts:100–109`)
|
||||
|
||||
The Zod schema `eventFieldsSchema` and `outboxPayloadSchema` in `outboxWorker.ts:71–83` both need the two new fields:
|
||||
```typescript
|
||||
recurrenceUntil: z.string().max(10).optional(), // 'YYYY-MM-DD'
|
||||
recurrenceCount: z.number().int().min(1).optional(),
|
||||
```
|
||||
|
||||
#### API write path — ical.js RRULE serialization (`vevent.ts:49–54`, `outboxWorker.ts`)
|
||||
|
||||
**Current state** [VERIFIED: codebase read]: `RRULE_PRESETS` maps preset name to bare `FREQ=X` string. `buildVeventString` uses `ICAL.Recur.fromString(params.rruleString)` then `rruleProp.setValue(recur)`.
|
||||
|
||||
**UNTIL/COUNT syntax — verified against ical.js 2.2.1 in project node_modules** [VERIFIED: live node evaluation]:
|
||||
|
||||
```
|
||||
ICAL.Recur.fromString('FREQ=WEEKLY;COUNT=5').toString()
|
||||
→ 'FREQ=WEEKLY;COUNT=5' ✓
|
||||
|
||||
ICAL.Recur.fromString('FREQ=DAILY;UNTIL=20260630').toString()
|
||||
→ 'FREQ=DAILY;UNTIL=20260630' (DATE form, no time, no Z) ✓
|
||||
|
||||
ICAL.Recur.fromString('FREQ=DAILY;UNTIL=20260630T235959Z').toString()
|
||||
→ 'FREQ=DAILY;UNTIL=20260630T235959Z' (DATETIME UTC form) ✓
|
||||
```
|
||||
|
||||
**RFC 5545 §3.3.10 UNTIL value-type rule** [ASSUMED — RFC knowledge, not verified against spec text this session]:
|
||||
- If DTSTART is VALUE=DATE (all-day), UNTIL MUST be a DATE (`YYYYMMDD`), not a DATETIME.
|
||||
- If DTSTART is DATETIME, UNTIL MUST be a UTC DATETIME (`YYYYMMDDTHHMMSSZ`).
|
||||
|
||||
**Safe approach for this codebase:** Use `UNTIL=YYYYMMDD` for all-day events; use `UNTIL=YYYYMMDDTHHMMSSZ` (end of day UTC, i.e. `T235959Z`) for timed events. This matches the existing `isDate: true` / UTC pattern in `vevent.ts`.
|
||||
|
||||
**RRULE string assembly:** Extend `RRULE_PRESETS` usage or build the string inline in the outbox worker:
|
||||
|
||||
```typescript
|
||||
// In outboxWorker.ts, when building rruleString for buildVeventString:
|
||||
function assembleRruleString(
|
||||
preset: string, // 'FREQ=WEEKLY' etc. from RRULE_PRESETS
|
||||
until?: string, // 'YYYY-MM-DD'
|
||||
count?: number,
|
||||
allDay?: boolean,
|
||||
): string {
|
||||
let s = preset
|
||||
if (count !== undefined) {
|
||||
s += `;COUNT=${count}`
|
||||
} else if (until) {
|
||||
// RFC 5545: DATE form for all-day, DATETIME UTC for timed
|
||||
if (allDay) {
|
||||
s += `;UNTIL=${until.replace(/-/g, '')}` // 20260630
|
||||
} else {
|
||||
s += `;UNTIL=${until.replace(/-/g, '')}T235959Z` // 20260630T235959Z
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
```
|
||||
|
||||
`ICAL.Recur.fromString(rruleString)` + the existing `rruleProp.setValue(recur)` pattern then serializes it correctly.
|
||||
|
||||
#### Expansion path — `expand.ts` per-occurrence duration
|
||||
|
||||
**Verified** [VERIFIED: live node evaluation]: `expand.ts` computes each occurrence's end via `event.duration` (the DTSTART→DTEND delta parsed by ical.js), NOT from the RRULE UNTIL. This means adding `UNTIL` or `COUNT` to the RRULE does NOT affect per-occurrence duration — the motivating bug (2-month bars) was caused by the start→end span being 63 days, which became the duration for each occurrence. The fix is in D-04 (end-tracking) not in the expansion code. No changes needed in `expand.ts` for D-06.
|
||||
|
||||
**Expansion terminates correctly at UNTIL/COUNT** [VERIFIED: live node evaluation]:
|
||||
```
|
||||
COUNT=3 with weekly FREQ → RecurExpansion.next() returns 3 occurrences then marks expand.complete=true
|
||||
```
|
||||
|
||||
`RecurExpansion` in `expand.ts` uses `expand.next()` in a `while` loop against `rangeEnd` — it will correctly stop when COUNT is exhausted OR when UNTIL is reached, whichever comes first within the window.
|
||||
|
||||
---
|
||||
|
||||
### Focus 2: FREQ-Persistence Bug — D-07
|
||||
|
||||
**Claim from CONTEXT.md:** A "daily" selection reportedly persisted as "weekly."
|
||||
|
||||
**Code trace** [VERIFIED: codebase read]:
|
||||
|
||||
1. **`RRULE_PRESETS` map** (`vevent.ts:49–54`): `{daily: 'FREQ=DAILY', weekly: 'FREQ=WEEKLY', monthly: 'FREQ=MONTHLY', yearly: 'FREQ=YEARLY'}` — mapping is correct. No bug here.
|
||||
|
||||
2. **API route** (`events.ts:107`): `recurrence: z.enum(['none', 'daily', 'weekly', ...])` — passes through unchanged to outbox JSON. No bug here.
|
||||
|
||||
3. **Outbox worker** (`outboxWorker.ts:258–259`): `fields.recurrence && fields.recurrence !== 'none' ? RRULE_PRESETS[fields.recurrence]` — lookup is exact, no bug.
|
||||
|
||||
4. **EventForm state initialization** (`EventForm.tsx:207–258`):
|
||||
- `useState<RecurrencePreset>('none')` — initialized correctly.
|
||||
- The reset `useEffect` (lines 232–262) runs on `[eventFormOpen, eventFormMode, eventFormUid, occurrence?.uid]`. It calls `setRecurrence(derivedRecurrence ?? 'none')`. `derivedRecurrence` is `(occurrence as any)?.recurrence` — always `undefined` (not in `CalendarOccurrence` type).
|
||||
- **Potential bug site:** If the form is opened while a stale `occurrence` is still in the TanStack cache, the effect fires with `occurrence` present but `occurrence.recurrence === undefined`, setting `recurrence` to `'none'`. Then when the user selects "daily", and if the effect fires again before submit (because `occurrence?.uid` changes when cache updates), it resets to `'none'`. But the bug report says "daily persisted as weekly," not "persisted as none."
|
||||
|
||||
5. **Most likely explanation** [ASSUMED — not confirmed by reproducing the bug]: The original bug was a one-time user error or a now-stale state from a prior session where the `RecurrencePreset` type or the select option values were misaligned. With the current code, a deliberate "daily" selection will correctly send `recurrence: 'daily'` unless the effect fires between selection and submit. The effect deps include `occurrence?.uid` — if the TanStack cache updates with a new `occurrence` between selection and submit (plausible if the query refetches), the effect fires and resets to `'none'`, but NOT to `'weekly'`.
|
||||
|
||||
**Verdict:** No obvious code path produces "daily → weekly." **D-07 verification task:** Write a regression test that creates a daily event, verifies the outbox row carries `recurrence: 'daily'`, and the resulting RRULE is `FREQ=DAILY`. If the bug is intermittent, add a snapshot of the form-submit path to confirm `recurrence` state is not reset between select-change and submit. The planner should treat this as a "confirm + add regression test" task rather than a targeted code fix. [ASSUMED — no reproduction possible without running the app]
|
||||
|
||||
---
|
||||
|
||||
### Focus 3: Whole-Series Edit — D-08/D-09
|
||||
|
||||
#### Identifying a recurring occurrence (`hasRrule`)
|
||||
|
||||
**Current state** [VERIFIED: codebase read]:
|
||||
- `calendarEvents.hasRrule` exists in DB schema (`apps/api/src/db/schema.ts:131`) and is set correctly in `sync.ts:152,163`.
|
||||
- `CalendarOccurrence` interface in `expand.ts` does NOT include `hasRrule` — it is absent from the type.
|
||||
- `CalendarOccurrence` interface in `client.ts` does NOT include `hasRrule`.
|
||||
- Therefore, the PWA cannot currently detect "this occurrence belongs to a recurring series."
|
||||
|
||||
**Required change:** Add `hasRrule: boolean` to `CalendarOccurrence` in `expand.ts` and `client.ts`, and populate it in `expandOccurrences()` from the master event:
|
||||
|
||||
```typescript
|
||||
// In expandOccurrences():
|
||||
const isRecurring = event.isRecurring()
|
||||
// ... in each occurrence push:
|
||||
occurrences.push({ ..., hasRrule: isRecurring })
|
||||
```
|
||||
|
||||
The events route SQL join already fetches enough data; no DB query change needed since `expandOccurrences` already knows `event.isRecurring()`.
|
||||
|
||||
#### Write-back path for master VEVENT edit
|
||||
|
||||
**Current state** [VERIFIED: codebase read]: The edit route in `events.ts` (lines 326–431) already:
|
||||
1. Looks up `calendarEvents` by `uid` + caller's userId + `calendarUrl` — identifies the master VEVENT row.
|
||||
2. Reads `objectUrl` (the CalDAV object URL for PUT) and the freshest `etag`.
|
||||
3. Passes through to `outboxWorker` which calls `buildVeventString` + `updateCalendarEvent` PUT.
|
||||
|
||||
For whole-series edit (D-08), the PWA sends the same PATCH `/api/events/:uid/edit` it already uses. The `uid` used in `eventFormUid` is the occurrence `uid` from `CalendarOccurrence.uid` — which is also the master VEVENT's UID. The edit route fetches by this UID and PUTs the full new VCALENDAR/VEVENT back to the same `objectUrl`.
|
||||
|
||||
**RRULE on whole-series edit:** When the user edits a recurring event and the form is in edit mode, `recurrence` is currently omitted from the payload (WR-01). For D-08, the payload must carry the new `recurrenceUntil`/`recurrenceCount` fields when the user modifies the bound, alongside the existing RRULE preservation (or a new explicit `recurrence` if they want to change frequency). The `hasExplicitRecurrence` check in the worker already handles this correctly — if the user explicitly sets `recurrence`, the preset overrides the stored RRULE. If only `recurrenceUntil`/`recurrenceCount` change, the worker must be updated to apply those modifiers to the preserved RRULE.
|
||||
|
||||
**What must NOT change:** No `RECURRENCE-ID` added. The PUT replaces the master VEVENT wholesale — same UID, same `objectUrl`, fresh iCal string with updated DTSTART/DTEND/SUMMARY/RRULE. All existing occurrences in the series update on the next CalDAV re-sync.
|
||||
|
||||
#### All-day DTEND symmetry on series edit (D-05 / WR-04)
|
||||
|
||||
**Already verified** [VERIFIED: codebase read]: `EventForm.tsx:199` has the `exclusiveEndToInclusiveDate` pre-fill. `vevent.ts` WR-04 has the +1 day roll-forward on write. Any series edit that changes dates must go through the same `EventForm` submit → `serializeEventDateTime` → `buildVeventString` path, so the invariant is preserved automatically. No additional work required for D-08 on this front.
|
||||
|
||||
#### Confirmation prompt (D-09)
|
||||
|
||||
UI-SPEC.md §Surface 6 locks the pattern: bottom-sheet on phone, dialog on desktop. Copy is locked. Implementation uses the existing `DeleteConfirmationDialog` pattern. The series-edit prompt renders when `eventFormMode === 'edit'` AND `occurrence.hasRrule === true` AND the user taps Save. This requires `hasRrule` on `CalendarOccurrence` (see above).
|
||||
|
||||
---
|
||||
|
||||
### Focus 4: End-Tracking — D-03/D-04
|
||||
|
||||
#### Start/end state structure
|
||||
|
||||
**Verified** [VERIFIED: codebase read]:
|
||||
|
||||
```typescript
|
||||
// EventForm.tsx state (lines 201–206)
|
||||
const [startDate, setStartDate] = useState(initStart.date) // 'YYYY-MM-DD'
|
||||
const [startTime, setStartTime] = useState(initStart.time) // 'HH:MM'
|
||||
const [endDate, setEndDate] = useState(initEndDate) // 'YYYY-MM-DD' inclusive
|
||||
const [endTime, setEndTime] = useState(initEnd.time) // 'HH:MM'
|
||||
```
|
||||
|
||||
The start date and time inputs currently have individual `onChange` handlers (`setStartDate(e.target.value)` / `setStartTime(e.target.value)` at lines 672 and 683 respectively). There is NO companion call to update `endDate`/`endTime` when start changes. This is the exact bug.
|
||||
|
||||
#### Fix target
|
||||
|
||||
Replace the bare `onChange` handlers on the start date input (line 672) and start time input (line 683) with handlers that also recompute end:
|
||||
|
||||
**Timed event — preserve delta:**
|
||||
```typescript
|
||||
function onStartDateChange(newStartDate: string) {
|
||||
setStartDate(newStartDate)
|
||||
if (allDay) return // handled in allDay branch
|
||||
const oldStartMs = new Date(`${startDate}T${startTime}:00`).getTime()
|
||||
const oldEndMs = new Date(`${endDate}T${endTime}:00`).getTime()
|
||||
const deltaMs = oldEndMs > oldStartMs ? oldEndMs - oldStartMs : 60 * 60 * 1000 // 1h floor
|
||||
const newStartMs = new Date(`${newStartDate}T${startTime}:00`).getTime()
|
||||
const newEndDate = new Date(newStartMs + deltaMs)
|
||||
setEndDate(dateToISO(newEndDate)) // 'YYYY-MM-DD' via local accessors
|
||||
setEndTime(timeToHHMM(newEndDate)) // 'HH:MM' via local accessors
|
||||
}
|
||||
```
|
||||
|
||||
**All-day — preserve day span:**
|
||||
```typescript
|
||||
function onStartDateChange(newStartDate: string) {
|
||||
setStartDate(newStartDate)
|
||||
const oldSpanDays = Math.max(0, dateDiffDays(startDate, endDate))
|
||||
const newEnd = addDays(newStartDate, oldSpanDays) // pure date arithmetic
|
||||
setEndDate(newEnd)
|
||||
}
|
||||
```
|
||||
|
||||
**Floor rule (D-04):** If `oldEndMs <= oldStartMs` (stale state already invalid), snap new end to `newStart + 1h` (timed) or `newStartDate` (all-day same-day).
|
||||
|
||||
These are pure functions with defined I/O — **TDD-eligible.** Extract to `apps/pwa/src/lib/eventDateTime.ts` (already exists, has tests) for unit testing. Tests: verify delta preservation, verify floor rule snaps correctly, verify all-day day-span.
|
||||
|
||||
#### D-05 verification (already fixed)
|
||||
|
||||
**Verified** [VERIFIED: codebase read]: `EventForm.tsx:199` (`initEndDate` computation) and `EventForm.tsx:239–242` (useEffect reset path) both apply `exclusiveEndToInclusiveDate` when `occurrence?.allDay && initEnd.date`. The `exclusiveEndToInclusiveDate` helper at lines 86–96 performs UTC-component-based roll-back (DST-safe). **No further work on D-05.**
|
||||
|
||||
---
|
||||
|
||||
### Focus 5: Spinner Animation — D-13
|
||||
|
||||
#### Actual state of `@keyframes spin` [VERIFIED: codebase read]
|
||||
|
||||
`apps/pwa/src/styles/tokens.css` lines 140–147:
|
||||
```css
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
```
|
||||
|
||||
`apps/pwa/src/styles/index.css` line 9: `@import './tokens.css'`
|
||||
|
||||
`apps/pwa/src/main.tsx` line 9: `import './styles/index.css'`
|
||||
|
||||
The `@keyframes spin` global definition IS present and IS loaded before any component mounts. The `animation: 'spin 1s linear infinite'` inline style references are correct.
|
||||
|
||||
#### Actual bugs
|
||||
|
||||
**Bug 1 — Redundant local redefine** [VERIFIED: codebase read]: `PushPermissionPrompt.tsx:358–363` contains:
|
||||
```tsx
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
```
|
||||
This is redundant (the global definition already covers it) and slightly noisy but not the cause of animation failures. Remove it.
|
||||
|
||||
**Bug 2 — Missing `@keyframes pulse`** [VERIFIED: codebase read]: `LiveSyncIndicator.tsx:69` uses:
|
||||
```tsx
|
||||
animation: 'pulse 1.4s ease-in-out infinite'
|
||||
```
|
||||
`@keyframes pulse` does NOT exist in `tokens.css` or `index.css`. The reconnecting dot never animates. Must add to `tokens.css` per UI-SPEC.md:
|
||||
```css
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
```
|
||||
|
||||
**Bug 3 — Spinner in `SyncStateToast`** [VERIFIED: codebase read]: `SyncStateToast.tsx:156–161` uses `animation: 'spin 1s linear infinite'`. Since `tokens.css` loads before components, this works. BUT confirm that the `SyncStateToast` Loader2 spinner IS actually spinning — the potential failure mode from the CONTEXT.md claim does not apply here because `tokens.css` is always loaded. The spinner should work. The `@keyframes spin` in `PushPermissionPrompt` is a `<style>` block, not a CSS module, so it IS global when the component renders — but it's irrelevant since tokens.css already has it.
|
||||
|
||||
**Summary:** The claim in D-13 that "SyncStateToast/LiveSyncIndicator don't animate when PushPermissionPrompt isn't mounted" is only partially accurate. `@keyframes spin` works fine (it's global via tokens.css). `@keyframes pulse` is the real missing animation. The fix is: (1) add `@keyframes pulse` to `tokens.css`, (2) remove the redundant `<style>` block from `PushPermissionPrompt.tsx`.
|
||||
|
||||
---
|
||||
|
||||
### Focus 6: Auth Gating — D-10/D-11
|
||||
|
||||
#### CalendarShell cold-load flash — D-10 [VERIFIED: codebase read]
|
||||
|
||||
**Current render path:**
|
||||
1. `CalendarShell` renders immediately regardless of `meQuery` state.
|
||||
2. `meQuery` starts loading (status: `isLoading`).
|
||||
3. `isInitialLoading = meQuery.isLoading || (eventsQuery.isLoading && !eventsQuery.data)` → `SkeletonCalendar` renders.
|
||||
4. On `meQuery.isError`: the component returns the `<div role="alert">Sign-in required</div>` fragment (line 220–235), then the `useEffect` at line 197 calls `maybeRedirectToLogin()`.
|
||||
|
||||
**The flash:** Between initial render and the `meQuery.isError` settlement, the user sees:
|
||||
- On fast networks: SkeletonCalendar briefly (acceptable).
|
||||
- On first cold load with no session: SkeletonCalendar → "Sign-in required" alert → browser navigates to `/api/login`. The "Sign-in required" alert is the flash (it renders before `maybeRedirectToLogin()` fires, since the redirect is triggered by a `useEffect` which runs after paint).
|
||||
|
||||
**D-10 fix:** Replace the current `meQuery.isError` branch with a dedicated auth splash component that renders INSTEAD of "Sign-in required" and also covers `meQuery.isLoading`:
|
||||
|
||||
```tsx
|
||||
// In CalendarShell, before the main render tree:
|
||||
if (meQuery.isLoading) {
|
||||
return <AuthSplash state="loading" />
|
||||
}
|
||||
if (meQuery.isError) {
|
||||
// useEffect handles maybeRedirectToLogin() — splash shows while redirect fires
|
||||
return <AuthSplash state="redirecting" />
|
||||
}
|
||||
```
|
||||
|
||||
The `useEffect` for `maybeRedirectToLogin()` (already at line 197) fires after the `AuthSplash` renders, so the user sees "Signing you in…" with spinner instead of the "Sign-in required" alert.
|
||||
|
||||
#### Session expiry mid-use — D-11 [VERIFIED: codebase read]
|
||||
|
||||
**Current state:**
|
||||
- `fetchMe` in `client.ts` (lines 36–53): uses `redirect: 'manual'`, detects `opaqueredirect`/401, throws `new Error('GET /api/me: authentication required')`.
|
||||
- ALL other fetch calls (`fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`, etc.) do NOT use `redirect: 'manual'` and do NOT detect `opaqueredirect`. They only `throw new Error(...)` on non-ok HTTP status, but a 302 to Authelia would be followed with CORS block producing a network error (or hang), not a typed auth error.
|
||||
- `maybeRedirectToLogin()` is only called from `CalendarShell`'s `meQuery.isError` effect.
|
||||
|
||||
**D-11 fix — two parts:**
|
||||
|
||||
**Part 1 — typed error + consistent `redirect:'manual'` in client.ts:**
|
||||
```typescript
|
||||
export class SessionExpiredError extends Error {
|
||||
constructor() { super('Session expired — re-authentication required') }
|
||||
}
|
||||
|
||||
// Helper used in all fetch calls:
|
||||
function handleAuthResponse(res: Response): void {
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) {
|
||||
throw new SessionExpiredError()
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
```
|
||||
|
||||
All fetch functions (`fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`, `fetchSyncStatus`, `fetchWritableCalendars`) gain `redirect: 'manual'` + `handleAuthResponse(res)`.
|
||||
|
||||
**Part 2 — global TanStack Query error handler:**
|
||||
```typescript
|
||||
// In App.tsx (or where QueryClient is created):
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
onError: (error) => {
|
||||
if (error instanceof SessionExpiredError) {
|
||||
setSessionExpiredFlag() // Zustand flag
|
||||
}
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
onError: (error) => {
|
||||
if (error instanceof SessionExpiredError) {
|
||||
setSessionExpiredFlag()
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
A Zustand `sessionExpired: boolean` flag triggers the session-expiry interstitial above the app tree. The interstitial calls `maybeRedirectToLogin()` (clears the one-shot guard first via `clearLoginRedirect()`).
|
||||
|
||||
**Note on TanStack Query v5 `onError` pattern** [ASSUMED — verify against TanStack Query 5 docs]: TanStack Query 5 moved from `onError` to `throwOnError` for some patterns. The global error callback in v5 is `queryClient.getQueryCache().subscribe()` or `mutationCache.subscribe()` rather than `defaultOptions.onError`. The planner must confirm the correct v5 API before implementing. The semantic intent above is correct regardless of the exact API.
|
||||
|
||||
**`loginRedirect.ts` re-arming:** Before calling `maybeRedirectToLogin()` from the session-expiry interstitial, call `clearLoginRedirect()` to clear the one-shot guard so the redirect fires correctly. This is already done in the `meQuery.isSuccess` effect (line 205), but for the D-11 mid-use case, `clearLoginRedirect()` must be called explicitly in the interstitial before the redirect.
|
||||
|
||||
**Single refactor serves both D-10 and D-11:** The `AuthSplash` component and the Zustand `sessionExpired` flag are two rendering paths from the same infrastructure. D-10 uses `meQuery.isLoading/isError`; D-11 uses the `sessionExpired` Zustand flag. Both show a centered full-screen interstitial. Plan them together.
|
||||
|
||||
---
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| RRULE UNTIL/COUNT serialization | Custom string concatenation | `ICAL.Recur.fromString(rruleString)` + existing `rruleProp.setValue(recur)` pattern | Already in use in vevent.ts; handles escaping, value type |
|
||||
| Date arithmetic for end-tracking | Custom date math | Pure JS `Date` arithmetic via local accessors (already the pattern in eventDateTime.ts) | The project already has `serializeEventDateTime` as the pattern; extend it |
|
||||
| Global error handling for auth | Per-query try/catch | TanStack Query cache subscription | Centralized, doesn't require touching every query |
|
||||
| CSS keyframe animation | Per-component `<style>` blocks | `tokens.css` global keyframes | Already the pattern; PushPermissionPrompt redundancy should be removed |
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: UNTIL value-type mismatch with DTSTART
|
||||
|
||||
**What goes wrong:** If DTSTART is VALUE=DATE (all-day) and UNTIL is a DATETIME (`YYYYMMDDTHHMMSSZ`), RFC 5545 §3.3.10 requires value-type consistency. Some CalDAV servers (including strict implementations) reject or misinterpret the event.
|
||||
**How to avoid:** Use `assembleRruleString(preset, until, count, allDay)` that produces DATE-form UNTIL for all-day events and DATETIME UTC form for timed events.
|
||||
**Warning signs:** Fastmail silently accepts the event but recurrence stops at a wrong date.
|
||||
|
||||
### Pitfall 2: UNTIL in user's local timezone vs UTC
|
||||
|
||||
**What goes wrong:** A user picks "ends June 30" but the UNTIL is serialized as `20260630T235959Z`. Depending on the user's timezone offset, `T235959Z` may be June 30 00:59 local time (UTC+1) rather than end-of-day local — causing the last occurrence to be dropped.
|
||||
**How to avoid:** For the `UNTIL` date case, using `T235959Z` (end of UTC day) is a safe universal choice that avoids under-counting for most western timezones. Document this trade-off; do NOT try to compute `T{endOfDayLocal}Z` — that requires knowing the user's timezone from the browser, which adds complexity beyond this phase's scope. [ASSUMED — not verified against Fastmail behavior]
|
||||
|
||||
### Pitfall 3: Adding UNTIL/COUNT to the preserved RRULE on series edit
|
||||
|
||||
**What goes wrong:** When editing a series that has an existing rich RRULE (`FREQ=WEEKLY;BYDAY=MO,WE`), the current `extractRruleString` + `buildVeventString` path preserves the full RRULE string. If D-06 adds UNTIL/COUNT, the worker must combine `preservedRrule` + the new bound modifiers rather than replacing the RRULE outright.
|
||||
**How to avoid:** In the outbox worker, if `recurrenceUntil` or `recurrenceCount` is present in the payload, parse `preservedRrule` into an `ICAL.Recur`, set `.until`/`.count`, and call `.toString()` to regenerate. Do NOT blindly concatenate.
|
||||
|
||||
### Pitfall 4: `hasRrule` flow-through schema break
|
||||
|
||||
**What goes wrong:** Adding `hasRrule` to `CalendarOccurrence` in `expand.ts` but forgetting to update `client.ts` (or vice versa) causes TypeScript errors in the PWA that look like API shape mismatches.
|
||||
**How to avoid:** Update both interfaces atomically in the same commit. The API tests for `expandOccurrences` (`tests/broker/expand.test.ts`) should be updated to assert `hasRrule` on the returned objects.
|
||||
|
||||
### Pitfall 5: TanStack Query v5 global error handler API
|
||||
|
||||
**What goes wrong:** TanStack Query v5 removed `defaultOptions.onError`. Using the v4 API silently does nothing.
|
||||
**How to avoid:** Use `queryClient.getQueryCache().subscribe(event => { if (event.type === 'error') ... })` and same for `getMutationCache()`. [ASSUMED — verify against Context7 TanStack Query 5 docs before implementing]
|
||||
|
||||
### Pitfall 6: React `useState` initializer runs once
|
||||
|
||||
**What goes wrong:** `EventForm` initializes `recurrence` state from `occurrence?.recurrence` which is always `undefined` (not in type). The reset effect at lines 232–262 also reads `(occurrence as any)?.recurrence` and falls back to `'none'`. When D-08 ships and recurrence IS exposed on `CalendarOccurrence`, the reset effect will need to handle the new `hasRrule` and `recurrence` fields correctly. This is a future concern, not a Phase 6 blocker.
|
||||
**How to avoid:** When extending the occurrence contract to expose recurrence, update the reset effect simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
### RRULE UNTIL/COUNT — verified ical.js 2.2.1 patterns
|
||||
|
||||
```typescript
|
||||
// Source: verified against ical.js 2.2.1 in project node_modules
|
||||
|
||||
// COUNT — existing pattern works directly:
|
||||
const recur = ICAL.Recur.fromString('FREQ=WEEKLY;COUNT=10')
|
||||
const rruleProp = new ICAL.Property('rrule')
|
||||
rruleProp.setValue(recur)
|
||||
vevent.addProperty(rruleProp)
|
||||
// produces: RRULE:FREQ=WEEKLY;COUNT=10
|
||||
|
||||
// UNTIL (all-day event, DATE form):
|
||||
const recur = ICAL.Recur.fromString('FREQ=WEEKLY;UNTIL=20260630')
|
||||
// produces: RRULE:FREQ=WEEKLY;UNTIL=20260630
|
||||
|
||||
// UNTIL (timed event, DATETIME UTC form):
|
||||
const recur = ICAL.Recur.fromString('FREQ=WEEKLY;UNTIL=20260630T235959Z')
|
||||
// produces: RRULE:FREQ=WEEKLY;UNTIL=20260630T235959Z
|
||||
```
|
||||
|
||||
### End-tracking pure functions (TDD target)
|
||||
|
||||
```typescript
|
||||
// Target: apps/pwa/src/lib/eventDateTime.ts (extend existing file)
|
||||
|
||||
/** Preserve timed-event duration on start change. Returns new { endDate, endTime }. */
|
||||
export function computeNewTimedEnd(
|
||||
newStartDate: string,
|
||||
newStartTime: string,
|
||||
oldStartDate: string,
|
||||
oldStartTime: string,
|
||||
oldEndDate: string,
|
||||
oldEndTime: string,
|
||||
): { endDate: string; endTime: string } {
|
||||
const oldStartMs = new Date(`${oldStartDate}T${oldStartTime}:00`).getTime()
|
||||
const oldEndMs = new Date(`${oldEndDate}T${oldEndTime}:00`).getTime()
|
||||
const deltaMs = oldEndMs > oldStartMs ? oldEndMs - oldStartMs : 60 * 60 * 1000
|
||||
const newEndDate = new Date(new Date(`${newStartDate}T${newStartTime}:00`).getTime() + deltaMs)
|
||||
return {
|
||||
endDate: localDateISO(newEndDate),
|
||||
endTime: localTimeHHMM(newEndDate),
|
||||
}
|
||||
}
|
||||
|
||||
/** Preserve all-day day-span on start date change. Returns new endDate string. */
|
||||
export function computeNewAllDayEnd(
|
||||
newStartDate: string,
|
||||
oldStartDate: string,
|
||||
oldEndDate: string, // inclusive
|
||||
): string {
|
||||
const span = Math.max(0, dateDiffDays(oldStartDate, oldEndDate))
|
||||
return addDaysISO(newStartDate, span)
|
||||
}
|
||||
```
|
||||
|
||||
### D-11 typed error (client.ts extension)
|
||||
|
||||
```typescript
|
||||
// Source: design from CONTEXT.md D-11 + codebase analysis
|
||||
|
||||
export class SessionExpiredError extends Error {
|
||||
readonly name = 'SessionExpiredError'
|
||||
constructor() {
|
||||
super('Session expired — re-authentication required')
|
||||
Object.setPrototypeOf(this, SessionExpiredError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
// Add to all fetch wrappers (fetchEvents, createEvent, etc.):
|
||||
const res = await fetch('/api/events', { credentials: 'include', redirect: 'manual' })
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError()
|
||||
if (!res.ok) throw new Error(`GET /api/events failed: ${res.status}`)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | RFC 5545 §3.3.10 requires UNTIL value-type to match DTSTART value-type (DATE vs DATETIME) | Focus 1, Pitfall 1 | Fastmail may silently accept mismatched types, reducing impact; or Fastmail strictly rejects — causes failed sync for bounded recurring events |
|
||||
| A2 | Using `UNTIL=YYYYMMDDTHHMMSSz` (end of UTC day) as universal safe choice for timed events | Focus 1, Pitfall 2 | Users in UTC+N>1 may lose the final occurrence; acceptable trade-off for v1 |
|
||||
| A3 | TanStack Query v5 global error handler uses cache subscription, not `defaultOptions.onError` | Focus 6, Pitfall 5 | If wrong, the session expiry handler silently does nothing |
|
||||
| A4 | FREQ-persistence bug (daily → weekly) was a one-time or stale-state issue, not a reproducible code bug | Focus 2 | If it is reproducible, the root cause is not identified — regression test will catch it |
|
||||
| A5 | `CalendarOccurrence.hasRrule` addition requires no DB query changes (already available via `event.isRecurring()`) | Focus 3 | If the API route does not pass hasRrule through the expand call correctly, the PWA always sees false |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **TanStack Query v5 global error handler API**
|
||||
- What we know: `defaultOptions.onError` was removed in TQ v5.
|
||||
- What's unclear: exact API for subscribing to all query/mutation errors globally.
|
||||
- Recommendation: Planner should add a Context7 lookup task for TanStack Query 5 `QueryCache` / `MutationCache` subscription API before implementing D-11.
|
||||
|
||||
2. **Fastmail UNTIL value-type enforcement**
|
||||
- What we know: RFC 5545 specifies value-type matching. ical.js produces the correct form.
|
||||
- What's unclear: whether Fastmail enforces it strictly or silently accepts mismatched types.
|
||||
- Recommendation: Test with a bounded recurring event in the dev environment as part of verification.
|
||||
|
||||
3. **In-flight write preservation on session expiry (D-11 nice-to-have)**
|
||||
- CONTEXT.md D-11 marks this as a nice-to-have, not a hard requirement.
|
||||
- Recommendation: Planner should defer this to v1.1 unless it fits naturally into the interstitial implementation.
|
||||
|
||||
---
|
||||
|
||||
## Environment Availability
|
||||
|
||||
Step 2.6: No new external dependencies required. All tooling (Node.js 22, pnpm, MariaDB, Redis, playwright-cli) is available from prior phases. `playwright-cli` confirmed at `/usr/local/bin/playwright-cli`.
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| playwright-cli | Browser-level verification | ✓ | (global binary) | Human checkpoint |
|
||||
| ical.js 2.2.1 | RRULE serialization | ✓ | 2.2.1 | — |
|
||||
| Vitest | Unit + integration tests | ✓ | (existing) | — |
|
||||
| MariaDB | API integration tests | ✓ | (dev stack) | — |
|
||||
|
||||
---
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
> `workflow.nyquist_validation` is absent from `.planning/config.json` → treated as enabled.
|
||||
|
||||
### Test Framework
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| API framework | Vitest, `apps/api/vitest.config.ts`, `environment: 'node'` |
|
||||
| PWA framework | Vitest, `apps/pwa/vitest.config.ts`, `environment: 'jsdom'` |
|
||||
| API quick run | `cd apps/api && pnpm test` |
|
||||
| PWA quick run | `cd apps/pwa && pnpm test` |
|
||||
| Full suite | `pnpm test` (root — runs API only; planner should add `pnpm --filter @familysync/pwa test` to full gate) |
|
||||
| Browser-level | `playwright-cli` (global binary at `/usr/local/bin/playwright-cli`) |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
|
||||
| Fix | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|-----|----------|-----------|-------------------|-------------|
|
||||
| D-04: duration preserve (timed) | `computeNewTimedEnd` returns correct date/time given old start/end + new start | unit | `cd apps/pwa && pnpm test -- lib/eventDateTime` | ❌ Wave 0 — extend `eventDateTime.test.ts` |
|
||||
| D-04: duration preserve (all-day) | `computeNewAllDayEnd` returns correct date given day-span + new start | unit | `cd apps/pwa && pnpm test -- lib/eventDateTime` | ❌ Wave 0 |
|
||||
| D-04: floor rule | end never strands behind start for both timed and all-day | unit | `cd apps/pwa && pnpm test -- lib/eventDateTime` | ❌ Wave 0 |
|
||||
| D-06: RRULE COUNT serialize | `buildVeventString({rruleString:'FREQ=WEEKLY;COUNT=5'})` produces correct ICS | unit | `cd apps/api && pnpm test -- broker/vevent` | ✅ `vevent.test.ts` — add cases |
|
||||
| D-06: RRULE UNTIL DATE | all-day UNTIL serializes as `YYYYMMDD` not `YYYYMMDDTHHMMSSZ` | unit | `cd apps/api && pnpm test -- broker/vevent` | ✅ add cases |
|
||||
| D-06: RRULE UNTIL DATETIME | timed UNTIL serializes as `YYYYMMDDTHHMMSSZ` | unit | `cd apps/api && pnpm test -- broker/vevent` | ✅ add cases |
|
||||
| D-06: per-occurrence duration independent of UNTIL | expand with bounded RRULE; each occurrence has duration from DTSTART→DTEND | unit | `cd apps/api && pnpm test -- broker/expand` | ✅ `expand.test.ts` — add case |
|
||||
| D-07: FREQ regression | create daily event → outbox carries `recurrence:'daily'` → RRULE is `FREQ=DAILY` | unit | `cd apps/api && pnpm test -- broker/outboxWorker` | ✅ add snapshot case |
|
||||
| D-08: hasRrule in occurrence | `expandOccurrences` sets `hasRrule:true` for recurring events | unit | `cd apps/api && pnpm test -- broker/expand` | ✅ add assertion |
|
||||
| D-10: no calendar flash | App shows spinner not skeleton/alert on cold unauthenticated load | browser | `playwright-cli` against dev server | — human only on iOS |
|
||||
| D-11: SessionExpiredError from 401 | `fetchEvents(...)` with mocked 401 response throws `SessionExpiredError` | unit | `cd apps/pwa && pnpm test -- api/client` | ✅ extend `client.test.ts` |
|
||||
| D-11: SessionExpiredError from opaqueredirect | `fetchEvents(...)` with mocked opaqueredirect throws `SessionExpiredError` | unit | `cd apps/pwa && pnpm test -- api/client` | ✅ extend `client.test.ts` |
|
||||
| D-11: global handler triggers interstitial | Session-expiry interstitial appears on mid-use 401 | browser | `playwright-cli` | ✗ (no mock-auth tooling) |
|
||||
| D-13: spin animation visible | Loader2 in SyncStateToast actually rotates | browser | `playwright-cli` | — |
|
||||
| D-13: pulse animation visible | LiveSyncIndicator reconnecting dot actually pulses | browser | `playwright-cli` | — |
|
||||
| D-13: pulse keyframe in CSS | `@keyframes pulse` present in tokens.css | unit | grep check or CSS parse | ❌ Wave 0 |
|
||||
|
||||
### TDD-Eligible Items (pure functions with defined I/O)
|
||||
|
||||
Write tests FIRST for these:
|
||||
- `computeNewTimedEnd` / `computeNewAllDayEnd` (D-04) — deterministic duration math
|
||||
- `buildVeventString` with COUNT / UNTIL variants (D-06) — deterministic ICS output
|
||||
- `expandOccurrences` with bounded RRULE (D-06) — deterministic occurrence count
|
||||
- `SessionExpiredError` detection in `fetchEvents`, `createEvent`, `updateEvent` (D-11)
|
||||
|
||||
### Glue/UI items (not TDD-eligible, use browser verification)
|
||||
|
||||
- EventForm end-tracking `onChange` handler wiring (D-04) — test via playwright-cli
|
||||
- Auth splash rendering (D-10) — playwright-cli
|
||||
- Spinner/pulse animation (D-13) — playwright-cli
|
||||
- Series-edit confirmation prompt (D-08/D-09) — playwright-cli
|
||||
|
||||
### Playwright-cli scope (desktop-Chromium drivable vs iOS-only)
|
||||
|
||||
| Check | Desktop-Chromium OK | iOS-Safari Required |
|
||||
|-------|--------------------|--------------------|
|
||||
| No auth flash on cold load | ✓ playwright-cli (simulate no session cookie) | Informative but not required |
|
||||
| "Signing you in…" splash renders | ✓ playwright-cli | — |
|
||||
| Session-expiry interstitial renders | ✓ playwright-cli (intercept with 401) | — |
|
||||
| Spinner actually animates in SyncStateToast | ✓ playwright-cli | — |
|
||||
| Pulse dot animates in LiveSyncIndicator | ✓ playwright-cli | — |
|
||||
| All-day event visual distinction | ✓ playwright-cli | — |
|
||||
| iOS PWA standalone push behaviour | ✗ human checkpoint | ✓ required |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** Run the relevant test file for the changed module.
|
||||
- **Per wave merge:** `cd apps/api && pnpm test && cd ../pwa && pnpm test` (full suites).
|
||||
- **Phase gate:** Full suite green + playwright-cli checks complete before `/gsd-verify-work`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `apps/pwa/src/lib/eventDateTime.test.ts` — extend with `computeNewTimedEnd`, `computeNewAllDayEnd`, floor-rule cases (D-04)
|
||||
- [ ] `apps/api/tests/broker/vevent.test.ts` — add UNTIL (DATE), UNTIL (DATETIME), COUNT cases (D-06)
|
||||
- [ ] `apps/api/tests/broker/expand.test.ts` — add bounded RRULE + `hasRrule` cases (D-06/D-08)
|
||||
- [ ] `apps/pwa/src/api/client.test.ts` — add `SessionExpiredError` detection cases for all fetch functions (D-11)
|
||||
|
||||
*(If no new test files are needed — all gaps are extensions to existing files.)*
|
||||
|
||||
---
|
||||
|
||||
## Security Domain
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes — D-10/D-11 auth gating | `@hono/oidc-auth` + PKCE (existing); session expiry redirect |
|
||||
| V3 Session Management | yes — D-11 session expiry detection | `redirect:'manual'` + `SessionExpiredError`; no token storage in client |
|
||||
| V4 Access Control | no — no new endpoints | — |
|
||||
| V5 Input Validation | yes — D-06 UNTIL date input | Zod validation on `recurrenceUntil` (date format) + `recurrenceCount` (integer ≥ 1) |
|
||||
| V6 Cryptography | no | — |
|
||||
|
||||
### Known Threat Patterns for this phase
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| RRULE injection via `recurrenceUntil` | Tampering | Zod `.string().max(10)` + date format regex on API schema; `ICAL.Recur.fromString` sanitizes via parsing |
|
||||
| Session cookie theft (not new, but D-11 surfaces the expiry path) | Spoofing | Same-origin cookie, `httpOnly`, existing Authelia session contract |
|
||||
| Flash of authenticated content before auth check | Info Disclosure | D-10 fix: gate render on `meQuery.isSuccess` not `meQuery.isLoading` |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence — verified in codebase)
|
||||
|
||||
- `apps/pwa/src/components/EventForm.tsx` — end-tracking gap (lines 662–689), FREQ state reset (lines 207, 257–258), D-05 fix (line 199)
|
||||
- `apps/pwa/src/api/client.ts` — `RecurrencePreset` type (line 130), `fetchMe` redirect:manual (lines 36–53), auth error shape
|
||||
- `apps/api/src/broker/vevent.ts` — `RRULE_PRESETS` map (lines 49–54), `buildVeventString` RRULE path (lines 143–148)
|
||||
- `apps/api/src/broker/expand.ts` — `CalendarOccurrence` interface (no `hasRrule`), per-occurrence duration via `event.duration` (lines 271–283)
|
||||
- `apps/api/src/broker/outboxWorker.ts` — RRULE preservation path (lines 255–307)
|
||||
- `apps/pwa/src/components/CalendarShell.tsx` — auth flash root cause (lines 213–235), `meQuery.isError` branch
|
||||
- `apps/pwa/src/lib/loginRedirect.ts` — `maybeRedirectToLogin()` one-shot guard
|
||||
- `apps/pwa/src/styles/tokens.css` — `@keyframes spin` present (lines 140–147), `@keyframes pulse` ABSENT
|
||||
- `apps/pwa/src/components/SyncStateToast.tsx` — `animation: 'spin 1s linear infinite'` (line 158)
|
||||
- `apps/pwa/src/components/LiveSyncIndicator.tsx` — `animation: 'pulse 1.4s ease-in-out infinite'` (line 69)
|
||||
- `apps/pwa/src/components/PushPermissionPrompt.tsx` — redundant local `@keyframes spin` (lines 358–363)
|
||||
|
||||
### Secondary (MEDIUM confidence — Context7 + live code evaluation)
|
||||
|
||||
- `ical.js 2.2.1` — RRULE UNTIL/COUNT serialization verified via `node -e` against project node_modules: `ICAL.Recur.fromString('FREQ=WEEKLY;COUNT=5').toString() === 'FREQ=WEEKLY;COUNT=5'` etc.
|
||||
- Context7 `/kewisch/ical.js` — `ICAL.design.icalendar.value['recur']` fromICAL/toICAL contract
|
||||
|
||||
### Tertiary (LOW confidence — ASSUMED)
|
||||
|
||||
- RFC 5545 §3.3.10 UNTIL value-type matching rule
|
||||
- TanStack Query v5 cache subscription API for global error handling
|
||||
- Fastmail UNTIL value-type enforcement behavior
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Code-verified findings (file:line): HIGH — read directly from source files
|
||||
- ical.js UNTIL/COUNT API: HIGH — verified via live `node -e` evaluation against project node_modules
|
||||
- Auth gating approach: HIGH — code-verified root cause, fix approach is well-established pattern
|
||||
- TanStack Query v5 global error handler: LOW (ASSUMED) — planner must verify v5 API before implementing
|
||||
- RFC 5545 UNTIL value-type rule: LOW (ASSUMED)
|
||||
|
||||
**Research date:** 2026-06-10
|
||||
**Valid until:** 2026-07-10 (stable stack; only ASSUMED items need re-verification)
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
phase: 06-ux-polish
|
||||
fixed_at: 2026-06-10T20:56:11Z
|
||||
review_path: .planning/phases/06-ux-polish/06-REVIEW.md
|
||||
iteration: 1
|
||||
findings_in_scope: 15
|
||||
fixed: 13
|
||||
skipped: 2
|
||||
status: partial
|
||||
---
|
||||
|
||||
# Phase 6: Code Review Fix Report
|
||||
|
||||
**Fixed at:** 2026-06-10T20:56:11Z
|
||||
**Source review:** .planning/phases/06-ux-polish/06-REVIEW.md
|
||||
**Iteration:** 1
|
||||
|
||||
**Summary:**
|
||||
- Findings in scope: 15 (fix_scope=all — CR + WR + IN)
|
||||
- Fixed: 13
|
||||
- Skipped: 2
|
||||
|
||||
Two findings (WR-06, WR-08) are committed but flagged **requires human verification** — they change runtime behavior (a timeout race / a parse-acceptance predicate) that syntax checks cannot confirm semantically.
|
||||
|
||||
Verification note: the isolated worktree has no `node_modules`, so a full `tsc --noEmit` was not possible. Each edited TS/TSX file was syntax-validated with the TypeScript compiler API (`ts.transpileModule`, transpile-only) using the main repo's typescript@5.9.3. The ICS fixture (IN-06) was validated by parsing it through ical.js@2.2.1 and confirming the unfolded DESCRIPTION matches the original and the RRULE still parses.
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
### CR-01: `recurrenceUntil` not validated as a date before RRULE splice
|
||||
|
||||
**Files modified:** `apps/api/src/routes/events.ts`, `apps/api/src/broker/outboxWorker.ts`
|
||||
**Commit:** d101aa8
|
||||
**Applied fix:** Replaced `z.string().max(10).optional()` with `z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()` in both `eventFieldsSchema` (route ingress) and `outboxPayloadSchema` (re-parse from stored JSON). With the regex enforced, `until.replace(/-/g,'')` is guaranteed digits-only, closing the `;`-delimited RRULE-part injection vector. Defense-in-depth applied at both boundaries.
|
||||
|
||||
### WR-01: All-day non-recurring events over-selected by the date-window SQL filter
|
||||
|
||||
**Files modified:** `apps/api/src/routes/events.ts`
|
||||
**Commit:** eb00ec7
|
||||
**Applied fix:** Added `sql\`${calendarEvents.hasRrule} = 0\`` to the all-day `and(...)` branch so a recurring all-day master whose `dtstartDate` lands in the window is no longer matched twice (it is already carried by the recurring branch), eliminating duplicate on-the-wire occurrences.
|
||||
|
||||
### WR-02: EventForm silently creates an unbounded series when "On date" is selected but blank / WR-07: fragile string comparison
|
||||
|
||||
**Files modified:** `apps/pwa/src/components/EventForm.tsx`
|
||||
**Commit:** 5724fe8
|
||||
**Applied fix:** Restructured the `recurrenceBound === 'until'` validation: a blank `recurrenceUntil` now sets `newErrors.recurrenceBound = 'Choose an end date'` (WR-02), and the bound-before-start lexicographic compare is now guarded on a non-empty `startDate` so `recurrenceUntil < ''` can no longer silently skip the check (WR-07). Both findings live in the same conditional, so they were fixed and committed together.
|
||||
|
||||
### WR-03: `recurrenceCount` number input can produce NaN/0 state
|
||||
|
||||
**Files modified:** `apps/pwa/src/components/EventForm.tsx`
|
||||
**Commit:** ac0f8d2
|
||||
**Applied fix:** `onChange` now uses `parseInt(value, 10)` + `Number.isFinite` guard, coercing non-finite intermediates to `0`. The validate guard was hardened to `!Number.isInteger(recurrenceCount) || recurrenceCount < 1` so a `NaN` count is caught instead of bypassing both the validation gate and the payload spread.
|
||||
|
||||
### WR-04: `calendarStore` uses the banned `toISOString().slice(0,10)` UTC-slice anti-pattern
|
||||
|
||||
**Files modified:** `apps/pwa/src/lib/eventDateTime.ts`, `apps/pwa/src/store/calendarStore.ts`
|
||||
**Commit:** 746c3c7
|
||||
**Applied fix:** Exported the existing private `localDateISO(d)` helper from `eventDateTime.ts` and used it in `initialCalendarRange()` and `todayIso()` in place of `.toISOString().slice(0,10)`. `eventDateTime.ts` is a leaf module (no imports), so no circular dependency is introduced.
|
||||
|
||||
### WR-05: `SessionExpiredError` instanceof check fragile across module-reload boundaries
|
||||
|
||||
**Files modified:** `apps/pwa/src/main.tsx`
|
||||
**Commit:** 9f88068
|
||||
**Applied fix:** `onGlobalError` now also matches `(error as { name?: string })?.name === 'SessionExpiredError'` so the session-expiry interstitial still arms when `client.ts` is loaded through two module graphs (the class carries a fixed `name` precisely for identity stability).
|
||||
|
||||
### WR-06: `triggerTargetedResync` runs before marking a row done — a hang stalls the outbox (requires human verification)
|
||||
|
||||
**Files modified:** `apps/api/src/broker/outboxWorker.ts`
|
||||
**Commit:** 0511a23
|
||||
**Applied fix:** Wrapped the success-path re-sync in `Promise.race([triggerTargetedResync(...), timeout(RESYNC_TIMEOUT_MS=10s)])`. On timeout the worker proceeds to mark `done` and lets the PWA's next poll reconcile. `triggerTargetedResync` already swallows its own errors, so the timed-out promise running on in the background is safe.
|
||||
**Human verification needed:** confirm 10s is the right cap, and that letting the row reach `done` after a timed-out re-sync (relying on the PWA refetch to reconcile) is acceptable for the deletion/edit stale-cache case the eager re-sync was originally protecting against.
|
||||
|
||||
### WR-07: `recurrenceUntil < startDate` string comparison
|
||||
|
||||
See WR-02 above — fixed in the same commit (5724fe8).
|
||||
|
||||
### WR-08: `parseDateTime` cannot distinguish all-day DATE from malformed partial dates (requires human verification)
|
||||
|
||||
**Files modified:** `apps/pwa/src/components/EventForm.tsx`
|
||||
**Commit:** d4a0ed7
|
||||
**Applied fix:** Added a `/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/` prefix guard before `new Date(clean)` in the timed branch, so a truncated value like `'2026-06'` (which V8 parses as a valid UTC instant) now throws and is reported `ok:false` instead of silently resolving to an unintended day. Verified the existing test inputs (`'2026-06-15T10:00:00-04:00'`, `'2026-06-10T23:30:00-04:00'`) still match the regex and that `'2026-06'` / `'2026-13-45'` / `'garbage'` are rejected.
|
||||
**Human verification needed:** confirm no legitimate cached `start`/`end` shape feeding the EDIT path lacks the `T HH:MM` prefix (e.g. a stored bare-second or comma-separated variant) that this would now reject and surface as a blank edit field.
|
||||
|
||||
### IN-01: Stale `CalendarOccurrence.id` doc comment in client.ts
|
||||
|
||||
**Files modified:** `apps/pwa/src/api/client.ts`
|
||||
**Commit:** 8b79d49
|
||||
**Applied fix:** Updated the comment from `` `${uid}::${dtstart_iso}` `` to `ev-<sanitized-uid>-<epochMs>` to match the server's `makeOccurrenceId` (expand.ts).
|
||||
|
||||
### IN-02: `resolveDefaultView` indirection
|
||||
|
||||
**Files modified:** `apps/pwa/src/components/CalendarShell.tsx`
|
||||
**Commit:** a570135
|
||||
**Applied fix:** Documented that the function is purely an SSR guard (returns a stable `'month-grid'` when `window` is undefined) and that the D-05 breakpoint default actually lives in the store's `readPersistedView()`. Behavior preserved; the SSR guard was intentionally kept rather than inlined away.
|
||||
|
||||
### IN-05: Duplicated focus-trap implementation across two dialogs
|
||||
|
||||
**Files modified:** `apps/pwa/src/hooks/useFocusTrap.ts` (new), `apps/pwa/src/components/EventForm.tsx`, `apps/pwa/src/components/SeriesEditPrompt.tsx`
|
||||
**Commit:** 1ab9710
|
||||
**Applied fix:** Created `useFocusTrap(dialogRef)` hook returning the keydown handler; replaced the verbatim-duplicated `handleDialogKeyDown` in both components with a call to the hook. New file created because the fix explicitly requires shared extraction.
|
||||
|
||||
### IN-06: `weekly-count3.ics` DESCRIPTION line exceeds 75 octets without folding
|
||||
|
||||
**Files modified:** `apps/api/tests/fixtures/weekly-count3.ics`
|
||||
**Commit:** 7ac4c29
|
||||
**Applied fix:** Folded the DESCRIPTION onto a continuation line (RFC 5545 §3.1, leading-space continuation). Verified via ical.js@2.2.1 that the unfolded value exactly matches the original (single space and multibyte `—`/`→` preserved) and the RRULE still parses to `FREQ=WEEKLY;COUNT=3`.
|
||||
|
||||
## Skipped Issues
|
||||
|
||||
### IN-03: `members` list in CalendarShell is always length-1
|
||||
|
||||
**File:** `apps/pwa/src/components/CalendarShell.tsx:129-138`
|
||||
**Reason:** skipped — scope-confirmation question, not an actionable defect. The reviewer explicitly says "This may be intended for the current milestone... Confirm scope." Rendering the other member's color band requires sourcing the other member's identity/colour (a feature/data-flow decision, not a localized fix) and would be speculative. Flagged for a human scope decision.
|
||||
|
||||
### IN-04: `recurrenceCount` default of `1` is send-eligible the instant bound flips to "count"
|
||||
|
||||
**File:** `apps/pwa/src/components/EventForm.tsx:216`
|
||||
**Reason:** skipped — the reviewer states "Not a bug, but a confusing default." Changing the default to empty/placeholder is a UX-design choice that interacts with the WR-03 sanitisation just landed (an empty field now coerces to `0`, which validate() rejects with "Must be at least 1 occurrence"). Left as-is to avoid coupling a cosmetic default change to the validation fix; flagged for a human UX decision.
|
||||
|
||||
---
|
||||
|
||||
_Fixed: 2026-06-10T20:56:11Z_
|
||||
_Fixer: Claude (gsd-code-fixer)_
|
||||
_Iteration: 1_
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
phase: 06-ux-polish
|
||||
fixed_at: 2026-06-10T20:56:11Z
|
||||
review_path: .planning/phases/06-ux-polish/06-REVIEW.md
|
||||
iteration: 1
|
||||
findings_in_scope: 15
|
||||
fixed: 13
|
||||
skipped: 2
|
||||
status: partial
|
||||
---
|
||||
|
||||
# Phase 6: Code Review Fix Report
|
||||
|
||||
**Fixed at:** 2026-06-10T20:56:11Z
|
||||
**Source review:** .planning/phases/06-ux-polish/06-REVIEW.md
|
||||
**Iteration:** 1
|
||||
|
||||
**Summary:**
|
||||
- Findings in scope: 15 (fix_scope=all — CR + WR + IN)
|
||||
- Fixed: 13
|
||||
- Skipped: 2
|
||||
|
||||
Two findings (WR-06, WR-08) are committed but flagged **requires human verification** — they change runtime behavior (a timeout race / a parse-acceptance predicate) that syntax checks cannot confirm semantically.
|
||||
|
||||
Verification note: the isolated worktree has no `node_modules`, so a full `tsc --noEmit` was not possible. Each edited TS/TSX file was syntax-validated with the TypeScript compiler API (`ts.transpileModule`, transpile-only) using the main repo's typescript@5.9.3. The ICS fixture (IN-06) was validated by parsing it through ical.js@2.2.1 and confirming the unfolded DESCRIPTION matches the original and the RRULE still parses.
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
### CR-01: `recurrenceUntil` not validated as a date before RRULE splice
|
||||
|
||||
**Files modified:** `apps/api/src/routes/events.ts`, `apps/api/src/broker/outboxWorker.ts`
|
||||
**Commit:** d101aa8
|
||||
**Applied fix:** Replaced `z.string().max(10).optional()` with `z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()` in both `eventFieldsSchema` (route ingress) and `outboxPayloadSchema` (re-parse from stored JSON). With the regex enforced, `until.replace(/-/g,'')` is guaranteed digits-only, closing the `;`-delimited RRULE-part injection vector. Defense-in-depth applied at both boundaries.
|
||||
|
||||
### WR-01: All-day non-recurring events over-selected by the date-window SQL filter
|
||||
|
||||
**Files modified:** `apps/api/src/routes/events.ts`
|
||||
**Commit:** eb00ec7
|
||||
**Applied fix:** Added `sql\`${calendarEvents.hasRrule} = 0\`` to the all-day `and(...)` branch so a recurring all-day master whose `dtstartDate` lands in the window is no longer matched twice (it is already carried by the recurring branch), eliminating duplicate on-the-wire occurrences.
|
||||
|
||||
### WR-02: EventForm silently creates an unbounded series when "On date" is selected but blank / WR-07: fragile string comparison
|
||||
|
||||
**Files modified:** `apps/pwa/src/components/EventForm.tsx`
|
||||
**Commit:** 5724fe8
|
||||
**Applied fix:** Restructured the `recurrenceBound === 'until'` validation: a blank `recurrenceUntil` now sets `newErrors.recurrenceBound = 'Choose an end date'` (WR-02), and the bound-before-start lexicographic compare is now guarded on a non-empty `startDate` so `recurrenceUntil < ''` can no longer silently skip the check (WR-07). Both findings live in the same conditional, so they were fixed and committed together.
|
||||
|
||||
### WR-03: `recurrenceCount` number input can produce NaN/0 state
|
||||
|
||||
**Files modified:** `apps/pwa/src/components/EventForm.tsx`
|
||||
**Commit:** ac0f8d2
|
||||
**Applied fix:** `onChange` now uses `parseInt(value, 10)` + `Number.isFinite` guard, coercing non-finite intermediates to `0`. The validate guard was hardened to `!Number.isInteger(recurrenceCount) || recurrenceCount < 1` so a `NaN` count is caught instead of bypassing both the validation gate and the payload spread.
|
||||
|
||||
### WR-04: `calendarStore` uses the banned `toISOString().slice(0,10)` UTC-slice anti-pattern
|
||||
|
||||
**Files modified:** `apps/pwa/src/lib/eventDateTime.ts`, `apps/pwa/src/store/calendarStore.ts`
|
||||
**Commit:** 746c3c7
|
||||
**Applied fix:** Exported the existing private `localDateISO(d)` helper from `eventDateTime.ts` and used it in `initialCalendarRange()` and `todayIso()` in place of `.toISOString().slice(0,10)`. `eventDateTime.ts` is a leaf module (no imports), so no circular dependency is introduced.
|
||||
|
||||
### WR-05: `SessionExpiredError` instanceof check fragile across module-reload boundaries
|
||||
|
||||
**Files modified:** `apps/pwa/src/main.tsx`
|
||||
**Commit:** 9f88068
|
||||
**Applied fix:** `onGlobalError` now also matches `(error as { name?: string })?.name === 'SessionExpiredError'` so the session-expiry interstitial still arms when `client.ts` is loaded through two module graphs (the class carries a fixed `name` precisely for identity stability).
|
||||
|
||||
### WR-06: `triggerTargetedResync` runs before marking a row done — a hang stalls the outbox (requires human verification)
|
||||
|
||||
**Files modified:** `apps/api/src/broker/outboxWorker.ts`
|
||||
**Commit:** 0511a23
|
||||
**Applied fix:** Wrapped the success-path re-sync in `Promise.race([triggerTargetedResync(...), timeout(RESYNC_TIMEOUT_MS=10s)])`. On timeout the worker proceeds to mark `done` and lets the PWA's next poll reconcile. `triggerTargetedResync` already swallows its own errors, so the timed-out promise running on in the background is safe.
|
||||
**Human verification needed:** confirm 10s is the right cap, and that letting the row reach `done` after a timed-out re-sync (relying on the PWA refetch to reconcile) is acceptable for the deletion/edit stale-cache case the eager re-sync was originally protecting against.
|
||||
|
||||
### WR-07: `recurrenceUntil < startDate` string comparison
|
||||
|
||||
See WR-02 above — fixed in the same commit (5724fe8).
|
||||
|
||||
### WR-08: `parseDateTime` cannot distinguish all-day DATE from malformed partial dates (requires human verification)
|
||||
|
||||
**Files modified:** `apps/pwa/src/components/EventForm.tsx`
|
||||
**Commit:** d4a0ed7
|
||||
**Applied fix:** Added a `/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/` prefix guard before `new Date(clean)` in the timed branch, so a truncated value like `'2026-06'` (which V8 parses as a valid UTC instant) now throws and is reported `ok:false` instead of silently resolving to an unintended day. Verified the existing test inputs (`'2026-06-15T10:00:00-04:00'`, `'2026-06-10T23:30:00-04:00'`) still match the regex and that `'2026-06'` / `'2026-13-45'` / `'garbage'` are rejected.
|
||||
**Human verification needed:** confirm no legitimate cached `start`/`end` shape feeding the EDIT path lacks the `T HH:MM` prefix (e.g. a stored bare-second or comma-separated variant) that this would now reject and surface as a blank edit field.
|
||||
|
||||
### IN-01: Stale `CalendarOccurrence.id` doc comment in client.ts
|
||||
|
||||
**Files modified:** `apps/pwa/src/api/client.ts`
|
||||
**Commit:** 8b79d49
|
||||
**Applied fix:** Updated the comment from `` `${uid}::${dtstart_iso}` `` to `ev-<sanitized-uid>-<epochMs>` to match the server's `makeOccurrenceId` (expand.ts).
|
||||
|
||||
### IN-02: `resolveDefaultView` indirection
|
||||
|
||||
**Files modified:** `apps/pwa/src/components/CalendarShell.tsx`
|
||||
**Commit:** a570135
|
||||
**Applied fix:** Documented that the function is purely an SSR guard (returns a stable `'month-grid'` when `window` is undefined) and that the D-05 breakpoint default actually lives in the store's `readPersistedView()`. Behavior preserved; the SSR guard was intentionally kept rather than inlined away.
|
||||
|
||||
### IN-05: Duplicated focus-trap implementation across two dialogs
|
||||
|
||||
**Files modified:** `apps/pwa/src/hooks/useFocusTrap.ts` (new), `apps/pwa/src/components/EventForm.tsx`, `apps/pwa/src/components/SeriesEditPrompt.tsx`
|
||||
**Commit:** 1ab9710
|
||||
**Applied fix:** Created `useFocusTrap(dialogRef)` hook returning the keydown handler; replaced the verbatim-duplicated `handleDialogKeyDown` in both components with a call to the hook. New file created because the fix explicitly requires shared extraction.
|
||||
|
||||
### IN-06: `weekly-count3.ics` DESCRIPTION line exceeds 75 octets without folding
|
||||
|
||||
**Files modified:** `apps/api/tests/fixtures/weekly-count3.ics`
|
||||
**Commit:** 7ac4c29
|
||||
**Applied fix:** Folded the DESCRIPTION onto a continuation line (RFC 5545 §3.1, leading-space continuation). Verified via ical.js@2.2.1 that the unfolded value exactly matches the original (single space and multibyte `—`/`→` preserved) and the RRULE still parses to `FREQ=WEEKLY;COUNT=3`.
|
||||
|
||||
## Skipped Issues
|
||||
|
||||
### IN-03: `members` list in CalendarShell is always length-1
|
||||
|
||||
**File:** `apps/pwa/src/components/CalendarShell.tsx:129-138`
|
||||
**Reason:** skipped — scope-confirmation question, not an actionable defect. The reviewer explicitly says "This may be intended for the current milestone... Confirm scope." Rendering the other member's color band requires sourcing the other member's identity/colour (a feature/data-flow decision, not a localized fix) and would be speculative. Flagged for a human scope decision.
|
||||
|
||||
### IN-04: `recurrenceCount` default of `1` is send-eligible the instant bound flips to "count"
|
||||
|
||||
**File:** `apps/pwa/src/components/EventForm.tsx:216`
|
||||
**Reason:** skipped — the reviewer states "Not a bug, but a confusing default." Changing the default to empty/placeholder is a UX-design choice that interacts with the WR-03 sanitisation just landed (an empty field now coerces to `0`, which validate() rejects with "Must be at least 1 occurrence"). Left as-is to avoid coupling a cosmetic default change to the validation fix; flagged for a human UX decision.
|
||||
|
||||
---
|
||||
|
||||
_Fixed: 2026-06-10T20:56:11Z_
|
||||
_Fixer: Claude (gsd-code-fixer)_
|
||||
_Iteration: 1_
|
||||
@@ -0,0 +1,343 @@
|
||||
---
|
||||
phase: 06-ux-polish
|
||||
reviewed: 2026-06-10T00:00:00Z
|
||||
depth: standard
|
||||
files_reviewed: 22
|
||||
files_reviewed_list:
|
||||
- apps/api/src/broker/expand.ts
|
||||
- apps/api/src/broker/outboxWorker.ts
|
||||
- apps/api/src/routes/events.ts
|
||||
- apps/api/tests/broker/expand.test.ts
|
||||
- apps/api/tests/broker/outboxWorker.test.ts
|
||||
- apps/api/tests/broker/vevent.test.ts
|
||||
- apps/api/tests/fixtures/weekly-count3.ics
|
||||
- apps/pwa/src/api/client.test.ts
|
||||
- apps/pwa/src/api/client.ts
|
||||
- apps/pwa/src/components/AuthSplash.tsx
|
||||
- apps/pwa/src/components/CalendarShell.tsx
|
||||
- apps/pwa/src/components/EventForm.test.tsx
|
||||
- apps/pwa/src/components/EventForm.tsx
|
||||
- apps/pwa/src/components/PushPermissionPrompt.tsx
|
||||
- apps/pwa/src/components/SeriesEditPrompt.tsx
|
||||
- apps/pwa/src/lib/eventDateTime.test.ts
|
||||
- apps/pwa/src/lib/eventDateTime.ts
|
||||
- apps/pwa/src/main.tsx
|
||||
- apps/pwa/src/store/calendarStore.ts
|
||||
- apps/pwa/src/styles/index.css
|
||||
- apps/pwa/src/styles/tokens.css
|
||||
findings:
|
||||
critical: 1
|
||||
warning: 8
|
||||
info: 6
|
||||
total: 15
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 6: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-06-10
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 22
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
Reviewed the Phase 6 UX-polish source set: server-side recurrence expansion, the
|
||||
outbox worker write-path, the events route, the PWA event form / auth-splash /
|
||||
push-prompt components, the calendar store, and supporting tests + CSS.
|
||||
|
||||
The code is heavily commented and carries a clear audit trail of prior fixes. The
|
||||
adversarial pass focused on the gaps *between* those documented fixes. The one
|
||||
Critical finding is a security-relevant injection vector in the RRULE `UNTIL`
|
||||
assembly (the route validates the date-window query params and write-body lengths,
|
||||
but `recurrenceUntil` is NOT validated as a date before being spliced into an RRULE
|
||||
string and PUT to Fastmail). The remaining findings are correctness/robustness gaps:
|
||||
a search-query SQL filter that over-returns all-day events, an unbounded-recurrence
|
||||
silent fallthrough in the form, a NaN-able count input, a fragile `instanceof`
|
||||
session-error check across module-reload boundaries, and the calendar store using
|
||||
the exact UTC-slice anti-pattern the rest of the codebase explicitly bans.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: `recurrenceUntil` is not validated as a date before being spliced into an RRULE and written to Fastmail
|
||||
|
||||
**File:** `apps/api/src/routes/events.ts:111`, `apps/api/src/broker/outboxWorker.ts:124-132`
|
||||
**Issue:**
|
||||
The route validates `recurrenceUntil` only as `z.string().max(10).optional()` — any
|
||||
≤10-char string passes. The outbox worker then does:
|
||||
|
||||
```js
|
||||
const dateDigits = until.replace(/-/g, '')
|
||||
s += `;UNTIL=${dateDigits}` // all-day
|
||||
s += `;UNTIL=${dateDigits}T235959Z` // timed
|
||||
```
|
||||
|
||||
`until.replace(/-/g,'')` strips hyphens but leaves every other character. A payload
|
||||
of `recurrenceUntil: "A;FREQ=DA"` (10 chars, no hyphens) yields
|
||||
`;UNTIL=A;FREQ=DA` — i.e. an injected extra RRULE part. The worker comment claims
|
||||
"The fixed `;UNTIL=` template prevents injection of extra `;`-delimited RRULE parts"
|
||||
and "ICAL.Recur.fromString rejects malformed values" — but the value itself can
|
||||
contain `;`, and `ICAL.Recur.fromString` is lenient about unknown parts. Even in the
|
||||
benign case, a non-date string like `"notadate"` produces `;UNTIL=notadate`, which
|
||||
either silently corrupts the series bound or throws deep in `buildVeventString`
|
||||
(burning the outbox attempt budget) rather than being rejected at the boundary.
|
||||
|
||||
This is the same class the route's own header comment claims to defend against
|
||||
(T-02b / T-03-08 input validation). `recurrenceCount` is correctly bounded
|
||||
(`z.number().int().min(1)`); `recurrenceUntil` is not.
|
||||
|
||||
**Fix:** Validate the format at the zod boundary in both `eventFieldsSchema`
|
||||
(events.ts) and `outboxPayloadSchema` (outboxWorker.ts):
|
||||
|
||||
```ts
|
||||
recurrenceUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
```
|
||||
|
||||
The existing windowed-GET schema already uses exactly this regex
|
||||
(`events.ts:88-89`) — reuse it. With the regex in place the `.replace(/-/g,'')`
|
||||
output is guaranteed digits-only and the injection vector closes.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: All-day non-recurring events are over-selected by the date-window SQL filter
|
||||
|
||||
**File:** `apps/api/src/routes/events.ts:199-203`
|
||||
**Issue:**
|
||||
The third `or()` branch selects *any* row with `dtstartDate` in `[start, end)`
|
||||
**without** gating on `hasRrule = 0`:
|
||||
|
||||
```js
|
||||
and(
|
||||
sql`${calendarEvents.dtstartDate} IS NOT NULL`,
|
||||
sql`${calendarEvents.dtstartDate} >= ${start}`,
|
||||
sql`${calendarEvents.dtstartDate} < ${end}`,
|
||||
)
|
||||
```
|
||||
|
||||
The timed branch above it explicitly gates `hasRrule = 0`, but this all-day branch
|
||||
does not. Any recurring all-day master whose `dtstartDate` happens to fall inside the
|
||||
window is matched twice (once by the recurring branch at :184, once here). Because
|
||||
both branches feed the same `flatMap(expandOccurrences)`, the same master is expanded
|
||||
twice and every occurrence is duplicated in the response. `expandOccurrences` builds a
|
||||
stable `makeOccurrenceId`, so Schedule-X dedups on render — but the duplication is
|
||||
real on the wire and any consumer that counts occurrences (or a future view that does
|
||||
not dedup) sees doubles. Add the missing `hasRrule = 0` gate to the all-day branch.
|
||||
|
||||
**Fix:**
|
||||
```js
|
||||
and(
|
||||
sql`${calendarEvents.hasRrule} = 0`,
|
||||
sql`${calendarEvents.dtstartDate} IS NOT NULL`,
|
||||
sql`${calendarEvents.dtstartDate} >= ${start}`,
|
||||
sql`${calendarEvents.dtstartDate} < ${end}`,
|
||||
)
|
||||
```
|
||||
|
||||
### WR-02: EventForm silently creates an unbounded series when "On date" is selected but no date entered
|
||||
|
||||
**File:** `apps/pwa/src/components/EventForm.tsx:355-359, 399-401`
|
||||
**Issue:**
|
||||
When `recurrenceBound === 'until'`, `validate()` only flags an error when
|
||||
`recurrenceUntil` is truthy AND before the start:
|
||||
|
||||
```js
|
||||
} else if (recurrenceBound === 'until' && recurrenceUntil) {
|
||||
if (recurrenceUntil < startDate) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
If the user picks "On date" but leaves the date blank, validation passes. The payload
|
||||
builder then omits `recurrenceUntil` (the spread is guarded by
|
||||
`... && recurrenceBound === 'until' && recurrenceUntil`), so the event is created as
|
||||
an **unbounded** recurring series — the opposite of the user's stated intent ("Ends:
|
||||
On date"). Treat a blank `recurrenceUntil` while `bound === 'until'` as a validation
|
||||
error.
|
||||
|
||||
**Fix:** Add to the `recurrence !== 'none'` block:
|
||||
```js
|
||||
if (recurrenceBound === 'until' && !recurrenceUntil) {
|
||||
newErrors.recurrenceBound = 'Choose an end date'
|
||||
}
|
||||
```
|
||||
|
||||
### WR-03: `recurrenceCount` number input can produce `NaN` / 0 state and an empty-string-driven 0
|
||||
|
||||
**File:** `apps/pwa/src/components/EventForm.tsx:932`
|
||||
**Issue:**
|
||||
`onChange={(e) => setRecurrenceCount(Number(e.target.value))}`. Clearing the field
|
||||
yields `e.target.value === ''` → `Number('') === 0`; certain intermediate inputs
|
||||
(`"-"`, `"e"`) yield `NaN`. `NaN < 1` is `false`, so the count-validation guard
|
||||
(`recurrenceCount < 1`) does NOT fire for `NaN`, and the payload spread
|
||||
(`recurrenceCount >= 1` → `NaN >= 1` is `false`) silently drops the count, again
|
||||
yielding an unbounded series. The 0 case is caught by validation, but the NaN case
|
||||
bypasses both the validation gate and the payload gate.
|
||||
|
||||
**Fix:** Sanitize on change and validate explicitly:
|
||||
```js
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10)
|
||||
setRecurrenceCount(Number.isFinite(n) ? n : 0)
|
||||
}}
|
||||
// and in validate():
|
||||
if (recurrenceBound === 'count' && (!Number.isInteger(recurrenceCount) || recurrenceCount < 1)) {
|
||||
newErrors.recurrenceBound = 'Must be at least 1 occurrence'
|
||||
}
|
||||
```
|
||||
|
||||
### WR-04: `calendarStore` uses `toISOString().slice(0,10)` — the exact UTC-slice anti-pattern the codebase bans
|
||||
|
||||
**File:** `apps/pwa/src/store/calendarStore.ts:130-131, 137`
|
||||
**Issue:**
|
||||
`initialCalendarRange()` and `todayIso()` both build date strings with
|
||||
`.toISOString().slice(0, 10)`. `eventDateTime.ts:64-65` and `EventForm.tsx:108-110`
|
||||
explicitly document this as forbidden ("NEVER use toISOString().slice(0,10) — that
|
||||
returns the UTC date, not the local date"). For a user west of UTC (the project's
|
||||
primary zones are Toronto/Detroit/New_York/Edmonton — all negative offsets) after
|
||||
~20:00 local, `todayIso()` returns *tomorrow's* date. This is the default
|
||||
`selectedDate` and seeds the initial fetch window — so a late-evening cold load can
|
||||
center the calendar on the wrong day and the EventForm create default (`todayIso()`
|
||||
at EventForm.tsx:153/158) pre-fills tomorrow. The fix already exists as the private
|
||||
`localDateISO` helper in `eventDateTime.ts`; export and reuse it.
|
||||
|
||||
**Fix:** Export `localDateISO` from `eventDateTime.ts` and use it in
|
||||
`initialCalendarRange()`/`todayIso()`:
|
||||
```js
|
||||
function localDateISO(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())}`
|
||||
}
|
||||
```
|
||||
|
||||
### WR-05: `SessionExpiredError` instanceof check is fragile across the test's dynamic re-imports / module duplication
|
||||
|
||||
**File:** `apps/pwa/src/main.tsx:30-34`, `apps/pwa/src/api/client.ts:33-39`
|
||||
**Issue:**
|
||||
The global error handler routes on `error instanceof SessionExpiredError`. The class is
|
||||
defined in `client.ts` and re-imported in `main.tsx`. This works in the prod bundle
|
||||
(single module instance), but it is a known footgun: if `client.ts` is ever loaded
|
||||
through two module graphs (Vite SSR, a duplicated chunk, or — as the tests already do
|
||||
— repeated `await import('./client.js')`), `instanceof` fails and the session-expiry
|
||||
interstitial never arms, leaving the user on a hung query. The class uses a fixed
|
||||
`readonly name = 'SessionExpiredError'` precisely to be identity-stable; the handler
|
||||
should defensively also check `name`.
|
||||
|
||||
**Fix:**
|
||||
```js
|
||||
function onGlobalError(error: unknown): void {
|
||||
if (error instanceof SessionExpiredError ||
|
||||
(error as { name?: string })?.name === 'SessionExpiredError') {
|
||||
useCalendarStore.getState().setSessionExpired(true)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### WR-06: `triggerTargetedResync` runs before marking a row `done`, so a sync hang stalls the outbox cycle and the optimistic toast
|
||||
|
||||
**File:** `apps/api/src/broker/outboxWorker.ts:683-694`
|
||||
**Issue:**
|
||||
On success the worker awaits `triggerTargetedResync(...)` BEFORE writing
|
||||
`status='done'`. The comment justifies this (avoid the PWA refetch racing stale
|
||||
cache). But `triggerTargetedResync` performs `client.fetchCalendars()` +
|
||||
`syncCalendar()` — unbounded network I/O against Fastmail with no timeout. If that
|
||||
hangs or is slow, the row stays `pending` from the DB's perspective for the full
|
||||
duration, the 15s `isDraining` guard keeps the next cycle a no-op, and the PWA polls
|
||||
`sync-status` seeing `pending` indefinitely. A single slow re-sync therefore blocks
|
||||
the entire single-process outbox. Consider bounding the re-sync with a timeout, or
|
||||
marking `done` and accepting the documented race (the PWA already re-polls). At
|
||||
minimum, the unbounded-I/O-before-commit tradeoff should be a deliberate, time-boxed
|
||||
decision rather than open-ended.
|
||||
|
||||
**Fix:** Wrap the resync in a timeout (e.g. `Promise.race` with a 10s cap) so a stalled
|
||||
Fastmail connection cannot wedge the drain loop; on timeout, proceed to mark `done`
|
||||
and let the next poll reconcile.
|
||||
|
||||
### WR-07: `recurrenceUntil < startDate` string comparison is only valid for same-format DATE strings
|
||||
|
||||
**File:** `apps/pwa/src/components/EventForm.tsx:356`
|
||||
**Issue:**
|
||||
`if (recurrenceUntil < startDate)` compares two strings lexicographically.
|
||||
`recurrenceUntil` comes from a `type="date"` input (`YYYY-MM-DD`) and `startDate` is
|
||||
also `YYYY-MM-DD`, so this works *today*. But it is silently coupled to both values
|
||||
always being zero-padded ISO dates. If `startDate` is ever blank (the IN-02 edit
|
||||
parse-failure path sets it to `''`), `recurrenceUntil < ''` is always `false`, so the
|
||||
bound-before-start guard is skipped exactly when the start is unknown. Low impact
|
||||
(create-mode only shows the bound control, and create-mode start is never blank), but
|
||||
the implicit format coupling is fragile. Compare parsed dates or assert non-empty
|
||||
`startDate` first.
|
||||
|
||||
**Fix:** Guard on non-empty operands or compare via `Date`/`Temporal.PlainDate`.
|
||||
|
||||
### WR-08: `parseDateTime` swallows all errors and cannot distinguish "all-day DATE" from "malformed" in some inputs
|
||||
|
||||
**File:** `apps/pwa/src/components/EventForm.tsx:112-139`
|
||||
**Issue:**
|
||||
`new Date(clean)` for a string like `'2026-13-45'` returns an `Invalid Date`, caught
|
||||
and returned as `ok:false` — correct. But `new Date('2026-06')` (a partial date) is
|
||||
parsed as a *valid* UTC instant in V8, so a truncated/garbled cached value would parse
|
||||
"successfully" to an unintended day/time and be saved on edit without tripping the
|
||||
IN-02 blank-field guard. The function trusts `new Date()`'s permissive parsing.
|
||||
Tighten the accepted timed-format (e.g. require a `T` and `:` before calling
|
||||
`new Date`) so only genuinely well-formed ISO datetimes parse as `ok:true`.
|
||||
|
||||
**Fix:** Pre-validate the timed branch shape, e.g.
|
||||
`if (!/T\d{2}:\d{2}/.test(clean)) return { ...today, ok: false }` before
|
||||
`new Date(clean)`.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: Dead/misleading interface doc comment in `client.ts` CalendarOccurrence
|
||||
|
||||
**File:** `apps/pwa/src/api/client.ts:107`
|
||||
**Issue:** The `id` field comment says ``` `${uid}::${dtstart_iso}` — stable identity ```
|
||||
but the server (`expand.ts:101-104` `makeOccurrenceId`) now emits
|
||||
`ev-<sanitized-uid>-<epochMs>`. The `::`-format comment is stale and contradicts the
|
||||
actual wire contract (and the server-side comment that explains why `::` was
|
||||
abandoned). Update the comment to the `ev-…` form to avoid misleading future readers.
|
||||
|
||||
### IN-02: `resolveDefaultView` ignores its only branch's intent
|
||||
|
||||
**File:** `apps/pwa/src/components/CalendarShell.tsx:65-68`
|
||||
**Issue:** `resolveDefaultView(persistedView)` returns `'month-grid'` for SSR and
|
||||
otherwise returns `persistedView` verbatim — the function adds nothing over reading
|
||||
`selectedView` directly, and the D-05 phone/desktop default logic it appears to
|
||||
promise actually lives in the store's `readPersistedView()`. Harmless, but the
|
||||
indirection invites a future reader to expect breakpoint logic here that isn't
|
||||
present. Inline it or move the default resolution here for real.
|
||||
|
||||
### IN-03: `members` list in CalendarShell is always length-1 (only the current user)
|
||||
|
||||
**File:** `apps/pwa/src/components/CalendarShell.tsx:129-138`
|
||||
**Issue:** `members` is built solely from `meQuery.data.user`, so `ColorLegend` and
|
||||
`buildCalendarConfig` only ever see the current member. Given MEMORY notes the app is
|
||||
designed to be "member-count-agnostic" and to render the other member's calendar as a
|
||||
read-only overlay, a single-member legend will mislabel/omit the other member's color
|
||||
band. This may be intended for the current milestone, but it contradicts the
|
||||
multi-member intent and the ColorLegend's plural framing. Confirm scope.
|
||||
|
||||
### IN-04: `recurrenceCount` default of `1` is sent-eligible the instant bound flips to "count"
|
||||
|
||||
**File:** `apps/pwa/src/components/EventForm.tsx:216`
|
||||
**Issue:** `recurrenceCount` defaults to `1`. If a user selects "After N times" and
|
||||
submits without touching the field, a 1-occurrence "recurring" event is created
|
||||
(effectively non-recurring). Not a bug, but a confusing default; consider an empty
|
||||
initial value with a placeholder (the placeholder `"e.g. 10"` already implies blank).
|
||||
|
||||
### IN-05: Duplicated focus-trap implementation across two dialogs
|
||||
|
||||
**File:** `apps/pwa/src/components/EventForm.tsx:445-472`, `apps/pwa/src/components/SeriesEditPrompt.tsx:59-84`
|
||||
**Issue:** The Tab/Shift+Tab focus-trap `handleDialogKeyDown` is copy-pasted verbatim
|
||||
into both components. Extract to a shared hook (`useFocusTrap(ref)`) so a future fix
|
||||
(e.g. handling `disabled`/`hidden` elements, or radio-group focus) lands in one place.
|
||||
|
||||
### IN-06: `weekly-count3.ics` fixture DESCRIPTION exceeds the typical 75-octet ICS line without folding
|
||||
|
||||
**File:** `apps/api/tests/fixtures/weekly-count3.ics:10`
|
||||
**Issue:** The `DESCRIPTION:` line is a single long unfolded line. `ICAL.parse` tolerates
|
||||
it, so the test passes, but a hand-authored fixture that violates RFC 5545 line-folding
|
||||
can mask folding-related regressions. Cosmetic; fold the line if the fixture is meant to
|
||||
mirror real Fastmail output.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-06-10_
|
||||
_Reviewer: Claude (gsd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
phase: 06-ux-polish
|
||||
reviewed: 2026-06-10T17:05:00Z
|
||||
depth: standard
|
||||
files_reviewed: 22
|
||||
files_reviewed_list:
|
||||
- apps/api/src/broker/expand.ts
|
||||
- apps/api/src/broker/outboxWorker.ts
|
||||
- apps/api/src/routes/events.ts
|
||||
- apps/api/tests/broker/expand.test.ts
|
||||
- apps/api/tests/broker/outboxWorker.test.ts
|
||||
- apps/api/tests/broker/vevent.test.ts
|
||||
- apps/api/tests/fixtures/weekly-count3.ics
|
||||
- apps/pwa/src/api/client.test.ts
|
||||
- apps/pwa/src/api/client.ts
|
||||
- apps/pwa/src/components/AuthSplash.tsx
|
||||
- apps/pwa/src/components/CalendarShell.tsx
|
||||
- apps/pwa/src/components/EventForm.test.tsx
|
||||
- apps/pwa/src/components/EventForm.tsx
|
||||
- apps/pwa/src/components/PushPermissionPrompt.tsx
|
||||
- apps/pwa/src/components/SeriesEditPrompt.tsx
|
||||
- apps/pwa/src/lib/eventDateTime.test.ts
|
||||
- apps/pwa/src/lib/eventDateTime.ts
|
||||
- apps/pwa/src/main.tsx
|
||||
- apps/pwa/src/store/calendarStore.ts
|
||||
- apps/pwa/src/styles/index.css
|
||||
- apps/pwa/src/styles/tokens.css
|
||||
- apps/pwa/src/hooks/useFocusTrap.ts
|
||||
findings:
|
||||
critical: 0
|
||||
warning: 0
|
||||
info: 1
|
||||
total: 1
|
||||
status: clean
|
||||
---
|
||||
|
||||
# Phase 6: Code Review Report (re-review)
|
||||
|
||||
**Reviewed:** 2026-06-10
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 22
|
||||
**Status:** clean
|
||||
|
||||
## Summary
|
||||
|
||||
This is the second adversarial pass over the Phase 6 UX-polish source set, focused on
|
||||
verifying the 12-commit fix batch (CR-01, WR-01–WR-08, IN-01/02/05/06) landed correctly
|
||||
and did not introduce regressions. IN-03 (single-member ColorLegend) and IN-04
|
||||
(recurrenceCount default of 1) were intentionally deferred as scope questions and are
|
||||
out of remediation scope.
|
||||
|
||||
Verification result: **all previously-flagged fixes are correctly applied, covered by
|
||||
tests, and introduce no regressions.** The full Phase 6 test surface is green —
|
||||
49 API broker tests (`expand`/`outboxWorker`/`vevent`) and 95 PWA tests
|
||||
(`client`/`EventForm`/`eventDateTime`).
|
||||
|
||||
No Critical or Warning findings on this pass. One Info note is carried forward
|
||||
unchanged (IN-04, the deferred count-default UX), recorded here only so it is not lost.
|
||||
|
||||
### Fix verification detail
|
||||
|
||||
- **CR-01 (RRULE UNTIL injection):** Closed. `recurrenceUntil` is now
|
||||
`z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()` at BOTH boundaries —
|
||||
`eventFieldsSchema` (events.ts:114) on ingress and `outboxPayloadSchema`
|
||||
(outboxWorker.ts:86) on re-parse. `until.replace(/-/g,'')` is now guaranteed
|
||||
digits-only, and `recurrenceCount` keeps its `int().min(1)` bound. The
|
||||
edit-as-move `_preservedRrule` carry-through (route → `.passthrough()` schema →
|
||||
create branch) is the server's own stored RECUR, not attacker-controlled, and
|
||||
is unit-tested (outboxWorker.test.ts:311, 337). No new vector introduced.
|
||||
- **WR-01 (all-day SQL over-select):** Fixed. The all-day non-recurring branch now
|
||||
gates `hasRrule = 0` (events.ts:207), so a recurring all-day master in the window
|
||||
is carried only by the recurring branch — no double expansion.
|
||||
- **WR-02 (blank "On date" → unbounded series):** Fixed. `validate()` now errors on
|
||||
`recurrenceBound === 'until' && !recurrenceUntil` (EventForm.tsx:375).
|
||||
- **WR-03 (NaN/0 recurrenceCount):** Fixed. `onChange` coerces via
|
||||
`parseInt` + `Number.isFinite` → 0 (EventForm.tsx:929-930), and `validate()`
|
||||
requires `Number.isInteger(recurrenceCount) && >= 1` (EventForm.tsx:364).
|
||||
- **WR-04 (UTC-slice anti-pattern):** Fixed. `calendarStore` imports and uses the
|
||||
exported `localDateISO` for both `initialCalendarRange()` and `todayIso()`
|
||||
(calendarStore.ts:27, 134-135, 142); no `toISOString().slice(0,10)` remains.
|
||||
- **WR-05 (fragile instanceof):** Fixed. `onGlobalError` now also matches
|
||||
`(error)?.name === 'SessionExpiredError'` (main.tsx:35-38).
|
||||
- **WR-06 (unbounded resync before commit):** Fixed. Success path wraps
|
||||
`triggerTargetedResync` in `Promise.race` with a 10s cap (outboxWorker.ts:711-714);
|
||||
the detached resync swallows its own errors and does not touch the `isDraining`
|
||||
guard, so no post-`finally` shared-state hazard.
|
||||
- **WR-07 (string compare with blank start):** Fixed. The bound-before-start compare
|
||||
is now guarded on a non-empty `startDate` (EventForm.tsx:377).
|
||||
- **WR-08 (permissive new Date parse):** Fixed. `parseDateTime` requires a
|
||||
`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}` shape on `clean` before trusting `new Date`
|
||||
(EventForm.tsx:129); a partial value like `2026-06` now returns `ok:false`.
|
||||
- **IN-01 (stale id doc comment):** Fixed. client.ts:107 now documents the
|
||||
`ev-<sanitized-uid>-<epochMs>` form matching expand.ts `makeOccurrenceId`.
|
||||
- **IN-02 (resolveDefaultView indirection):** Addressed via clarifying doc comment
|
||||
(CalendarShell.tsx:61-68) documenting the SSR-only intent.
|
||||
- **IN-05 (duplicated focus trap):** Fixed. Both EventForm and SeriesEditPrompt now
|
||||
consume the shared `useFocusTrap` hook (hooks/useFocusTrap.ts).
|
||||
- **IN-06 (unfolded ICS DESCRIPTION):** Fixed. The fixture DESCRIPTION is now folded
|
||||
across two lines with a leading-space continuation (weekly-count3.ics:10-11).
|
||||
|
||||
## Info
|
||||
|
||||
### IN-04: `recurrenceCount` default of `1` is send-eligible the instant bound flips to "count"
|
||||
|
||||
**File:** `apps/pwa/src/components/EventForm.tsx:225`
|
||||
**Issue:** Carried forward unchanged and explicitly deferred as a scope question.
|
||||
`recurrenceCount` still defaults to `1`, so selecting "After N times" and submitting
|
||||
without touching the field creates a 1-occurrence "recurring" event (effectively
|
||||
non-recurring). Not a bug; a confusing default. If addressed later, prefer an empty
|
||||
initial value with the existing `"e.g. 10"` placeholder. No action required this pass.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-06-10_
|
||||
_Reviewer: Claude (gsd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
status: testing
|
||||
phase: 06-ux-polish
|
||||
source: [06-VERIFICATION.md, 06-VALIDATION.md]
|
||||
started: 2026-06-10
|
||||
updated: 2026-06-10
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
number: 1
|
||||
name: PushPermissionPrompt spinner animates in an installed iOS/standalone PWA
|
||||
expected: |
|
||||
After the redundant local `@keyframes spin` block was removed from
|
||||
PushPermissionPrompt.tsx (06-04), the Loader2 "enabling" spinner must still
|
||||
rotate — resolving the global `@keyframes spin` in tokens.css. The push
|
||||
permission prompt only mounts in a Home-Screen-installed (standalone) PWA, so
|
||||
this cannot be driven by desktop playwright-cli.
|
||||
awaiting: user response
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. PushPermissionPrompt spinner animates (installed iOS PWA) — CP-04.3
|
||||
expected: Add the PWA to the iOS Home Screen, open it standalone, reach the push-permission prompt, tap to enable, and confirm the spinner visibly rotates (no static icon). Equivalent desktop spinners (SyncStateToast, EventForm) were confirmed `animationName === 'spin'` via playwright-cli; this verifies the same global keyframe in the standalone-only component.
|
||||
result: [pending]
|
||||
|
||||
### 2. Authelia iOS-Safari standalone cold-load + redirect (D-10/D-11)
|
||||
expected: On a real iOS device, Add to Home Screen and cold-load with no session. Confirm the FIRST painted frame is the neutral "Signing you in" splash (not the calendar/skeleton/alert), then it redirects to Authelia cleanly in standalone mode (no drop to a Safari browser tab). Trigger a session expiry and confirm the "Session expired / Signing you back in…" interstitial redirects without hanging. Desktop-Chromium equivalents passed via playwright-cli; standalone redirect behavior is the documented manual-only exception (06-VALIDATION.md).
|
||||
result: [pending]
|
||||
|
||||
## Summary
|
||||
|
||||
total: 2
|
||||
passed: 0
|
||||
issues: 0
|
||||
pending: 2
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
@@ -0,0 +1,472 @@
|
||||
---
|
||||
phase: 6
|
||||
slug: ux-polish
|
||||
status: draft
|
||||
shadcn_initialized: false
|
||||
preset: none
|
||||
created: 2026-06-10
|
||||
---
|
||||
|
||||
# Phase 6 — UI Design Contract
|
||||
|
||||
> Visual and interaction contract for Phase 6: UX Polish.
|
||||
> Generated by gsd-ui-researcher. Verified by gsd-ui-checker.
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Tool | none (custom CSS token layer) |
|
||||
| Preset | not applicable |
|
||||
| Component library | none — hand-rolled components using CSS custom properties |
|
||||
| Icon library | lucide-react@1.17.0 |
|
||||
| Font | system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif |
|
||||
|
||||
Source: `apps/pwa/src/styles/tokens.css` — fully established in Phase 2 (D-01/D-02).
|
||||
No new design-system tooling introduced in this phase.
|
||||
|
||||
---
|
||||
|
||||
## Spacing Scale
|
||||
|
||||
Declared values from `apps/pwa/src/styles/tokens.css` — no changes in this phase:
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| --space-1 | 4px | Icon gaps, inline padding, dot-label gaps |
|
||||
| --space-2 | 8px | Compact element spacing, badge padding |
|
||||
| --space-3 | 12px | Form field internal padding |
|
||||
| --space-4 | 16px | Default element spacing, card padding |
|
||||
| --space-6 | 24px | Section padding, sheet internal padding |
|
||||
| --space-8 | 32px | Layout gaps, modal vertical padding |
|
||||
| --space-12 | 48px | Major section breaks |
|
||||
|
||||
Exceptions:
|
||||
- Touch targets: minimum 44px height on all interactive elements (EventForm inputs, recurrence bound control, series-edit prompt buttons). 48px on primary CTAs (per existing PushPermissionPrompt pattern).
|
||||
- All-day banner row: height is not constrained to spacing scale; it follows the Schedule-X all-day row height. Do not override it.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
From `apps/pwa/src/styles/tokens.css` — no new sizes or weights introduced in this phase:
|
||||
|
||||
| Role | Size | Weight | Line Height | Used in Phase 6 |
|
||||
|------|------|--------|-------------|-----------------|
|
||||
| Body | 15px | 400 | 1.5 | Auth splash body text, session-expired interstitial copy, form labels |
|
||||
| Label | 13px | 400 | 1.4 | Recurrence bound control labels, "repeat until" date caption, series-edit prompt sub-copy |
|
||||
| Heading | 18px | 600 | 1.25 | Auth splash heading ("Signing you in"), series-edit prompt heading |
|
||||
| Display | 24px | 600 | 1.2 | Not used in Phase 6 new surfaces |
|
||||
|
||||
---
|
||||
|
||||
## Color
|
||||
|
||||
From `apps/pwa/src/styles/tokens.css` — no new colors introduced in this phase:
|
||||
|
||||
| Role | Value | Usage |
|
||||
|------|-------|-------|
|
||||
| Dominant (60%) | --color-surface: #FFFFFF | Auth splash background, modal/sheet backgrounds, EventForm background |
|
||||
| Secondary (30%) | --color-surface-dim: #F7F7F8 | Series-edit prompt background band, all-day visual treatment background fill |
|
||||
| Accent (10%) | --color-member-0: #4A90D9 | Primary CTA buttons only: "Turn On Notifications", "Save" in event form |
|
||||
| Destructive | --color-destructive: #DC2626 | Delete event, destructive actions only |
|
||||
|
||||
Accent reserved for:
|
||||
1. Primary CTA button fill in EventForm ("Save" / "Update Series")
|
||||
2. Focus ring (--color-focus-ring: #4A90D9) on interactive inputs
|
||||
|
||||
New usage decisions for Phase 6 surfaces:
|
||||
|
||||
**All-day event visual treatment (999.6):**
|
||||
- All-day chips/banners in Schedule-X all-day row: use full-width pill style with the member's `--color-member-N` or `--color-shared-family` as background fill at 100% opacity.
|
||||
- Timed event chips: keep existing member color fill.
|
||||
- The visual distinction is achieved through shape and presentation (full-width pill vs. standard event block), not a new color. The existing `_familySync.color` per event drives the fill in both cases.
|
||||
|
||||
**Auth splash (999.2) + session-expired interstitial (999.3):**
|
||||
- Full-screen overlay: `--color-surface` (#FFFFFF) background.
|
||||
- Spinner: `--color-member-0` (#4A90D9) — reuses the existing Loader2 + spin keyframe pattern.
|
||||
- Heading: `--color-text-primary` (#111318).
|
||||
- Body: `--color-text-secondary` (#6B7280).
|
||||
|
||||
**Series-edit prompt (999.9):**
|
||||
- Confirmation sheet / dialog uses same surface + border system as existing DeleteConfirmationDialog.
|
||||
- No new accent color. Primary confirm action uses `--color-member-0` fill (same as Save CTA).
|
||||
|
||||
---
|
||||
|
||||
## Copywriting Contract
|
||||
|
||||
### Auth splash — unauthenticated cold load (999.2)
|
||||
|
||||
| Element | Copy |
|
||||
|---------|------|
|
||||
| Heading | Signing you in |
|
||||
| Body | Taking you to the sign-in page… |
|
||||
| Fallback (one-shot guard failed) | Sign-in required. Tap here to try again. |
|
||||
|
||||
Rules:
|
||||
- No punctuation on the heading.
|
||||
- Body uses an ellipsis (…, U+2026) not three dots.
|
||||
- The "Sign-in required" fallback is the dead-end only — not the primary unauthenticated path.
|
||||
|
||||
### Session-expired interstitial (999.3)
|
||||
|
||||
| Element | Copy |
|
||||
|---------|------|
|
||||
| Heading | Session expired |
|
||||
| Body | Signing you back in… |
|
||||
|
||||
Rules:
|
||||
- This interstitial replaces the hanging/generic error state. It is shown for ≤2s before `window.location.href = '/api/login'` fires.
|
||||
- Do not show a dismiss button — the redirect is automatic.
|
||||
|
||||
### EventForm — recurrence bound control (999.8)
|
||||
|
||||
| Element | Copy |
|
||||
|---------|------|
|
||||
| Bound type label | Ends |
|
||||
| Option: no bound | Never |
|
||||
| Option: repeat until | On date |
|
||||
| Option: count | After N times |
|
||||
| Date input label | End date |
|
||||
| Count input label | Occurrences |
|
||||
| Count input placeholder | e.g. 10 |
|
||||
| Validation error: count < 1 | Must be at least 1 occurrence |
|
||||
| Validation error: until < start | End date must be after the event starts |
|
||||
|
||||
### EventForm — series-edit confirmation (999.9)
|
||||
|
||||
| Element | Copy |
|
||||
|---------|------|
|
||||
| Sheet/dialog heading | Edit recurring series |
|
||||
| Body | This will update all occurrences of this event. |
|
||||
| Confirm CTA | Update series |
|
||||
| Cancel | Cancel |
|
||||
|
||||
Rules:
|
||||
- "Update series" is the primary action (accent-filled button).
|
||||
- "Cancel" is a ghost/secondary button.
|
||||
- No destructive color on confirm — this is an edit, not a delete.
|
||||
|
||||
### EventForm — primary CTAs
|
||||
|
||||
| Mode | CTA label |
|
||||
|------|-----------|
|
||||
| Create (non-recurring) | Save event |
|
||||
| Create (recurring) | Save event |
|
||||
| Edit (non-recurring) | Save changes |
|
||||
| Edit (recurring occurrence) | Update series |
|
||||
|
||||
### Sync indicators (D-13)
|
||||
|
||||
No copy change. Existing copy is canonical:
|
||||
- Spinning: "Syncing…"
|
||||
- Done: "Saved"
|
||||
- Failed: "Didn't save. Try again." (or conflict variant)
|
||||
- Dead: "Not saved. Check your connection."
|
||||
- LiveSyncIndicator reconnecting: "Reconnecting…"
|
||||
- LiveSyncIndicator disconnected: "Updates paused"
|
||||
|
||||
### Empty states
|
||||
|
||||
No new empty states introduced in this phase. Existing EmptyState and ListsEmptyState copy is unchanged.
|
||||
|
||||
### Destructive actions
|
||||
|
||||
| Action | Trigger | Confirmation |
|
||||
|--------|---------|--------------|
|
||||
| Delete event | Trash2 icon in EventDetailPopover footer | Existing DeleteConfirmationDialog — "Delete event?" / "This can't be undone." / "Delete" (destructive-red) / "Cancel" |
|
||||
| Delete recurring series | (Not in scope for Phase 6 — whole-series edit only, not delete) | N/A |
|
||||
|
||||
---
|
||||
|
||||
## Surface Contracts
|
||||
|
||||
### Surface 1: Auth splash screen (999.2)
|
||||
|
||||
**Trigger:** `meQuery.isLoading` on initial mount (before auth state is known).
|
||||
|
||||
**Layout:**
|
||||
- Full-screen centered column: `display:flex; flex-direction:column; align-items:center; justify-content:center; height:100dvh; background:var(--color-surface)`.
|
||||
- Stack: Loader2 spinner (24px, `--color-member-0`, spinning via global `@keyframes spin`) → 16px gap → Heading (18px/600) → 8px gap → Body (15px/400, `--color-text-secondary`).
|
||||
- No app chrome (no BottomTabBar, no AppNav).
|
||||
|
||||
**States:**
|
||||
1. `meQuery.isLoading` → show spinner + "Signing you in" heading + "Taking you to the sign-in page…" body.
|
||||
2. `meQuery.isError` (opaqueredirect / 401), first attempt → trigger `maybeRedirectToLogin()` immediately; while the redirect is in-flight keep the spinner visible (same view).
|
||||
3. `meQuery.isError`, one-shot guard already fired (`familysync.loginRedirectAttempted` set) → replace body with "Sign-in required. Tap here to try again." with a tap handler that clears the flag and re-triggers login. No spinner in this dead-end state.
|
||||
|
||||
**Accessibility:** `role="status"` on the spinner wrapper, `aria-label="Signing you in"`.
|
||||
|
||||
### Surface 2: Session-expired interstitial (999.3)
|
||||
|
||||
**Trigger:** Any query or mutation returns 401 / opaqueredirect after initial auth succeeds.
|
||||
|
||||
**Layout:** Same full-screen centered column as Surface 1, but:
|
||||
- Heading: "Session expired"
|
||||
- Body: "Signing you back in…"
|
||||
- Spinner visible.
|
||||
- Fires `maybeRedirectToLogin()` after a 1.5s delay (enough for the user to read the message, not long enough to feel broken).
|
||||
|
||||
**Implementation note:** Centralized in `apps/pwa/src/api/client.ts` via a typed `SessionExpiredError`. A global TanStack Query `onError` handler intercepts it and sets a Zustand flag that renders this surface above the app tree. The existing one-shot guard in `loginRedirect.ts` is re-armed (clear `familysync.loginRedirectAttempted` before re-firing).
|
||||
|
||||
### Surface 3: All-day visual distinction (999.6)
|
||||
|
||||
**Schedule-X context:** All-day events appear in the all-day row in week/day views and as full-width banners in month/agenda views. Schedule-X renders them with its own chip CSS.
|
||||
|
||||
**Treatment:**
|
||||
- Override Schedule-X all-day chip styles to render as a full-width rounded pill (border-radius: 4px) spanning the full column width with the event's member color as solid background fill and white (`#FFFFFF`) label text.
|
||||
- Timed events keep their existing chip appearance (colored left border + lighter background tint, as Schedule-X default renders them with `--sx-color-primary`).
|
||||
- The visual distinction contract: **all-day = solid filled pill; timed = partial-fill chip with colored border accent**.
|
||||
- Override selector: `.sx__all-day-event` — set `border-radius:4px; color:#FFFFFF; font-weight:600; font-size:var(--text-label-size)`.
|
||||
- Color source: pass the event's `_familySync.color` to the Schedule-X `calendarId` color config (already done via `buildCalendarConfig`). No per-event inline style override needed if the `calendarId` color propagates.
|
||||
|
||||
**Accessibility:** No additional ARIA needed — Schedule-X all-day row already has date headers. The filled pill provides sufficient contrast (member colors are ≥3:1 on white text at these sizes).
|
||||
|
||||
### Surface 4: EventForm — end-tracking + all-day off-by-one fix (999.7)
|
||||
|
||||
**Behavior contract:**
|
||||
- On any `startDate` or `startTime` change: recalculate `endDate`/`endTime` to preserve the current duration.
|
||||
- Timed: `newEnd = newStart + (oldEnd - oldStart)`. If `oldEnd <= oldStart` (stale state), snap to `newStart + 1h`.
|
||||
- All-day: `newEndInclusive = newStartDate + (oldEndInclusive - oldStartDate)` in days. If span = 0, keep 0 (same-day). If `oldEnd < oldStart` (stale), snap `newEnd = newStart`.
|
||||
- Floor rule: end must never be before start. If arithmetic would place end before start, snap end = start (timed: same minute; all-day: same day).
|
||||
- The all-day-edit off-by-one (D-05) is already fixed at `EventForm.tsx:199`. Verify the `exclusiveEndToInclusiveDate` helper still applies correctly in edit pre-fill; do not re-implement.
|
||||
|
||||
**Interaction:**
|
||||
- No toast or indicator when end auto-advances — silent and expected.
|
||||
- The end date/time fields remain editable after the auto-advance; the user can override further.
|
||||
|
||||
### Surface 5: EventForm — recurrence bound control (999.8)
|
||||
|
||||
**Placement:** Appears below the frequency `<select>` in the recurrence section, shown only when recurrence ≠ "None".
|
||||
|
||||
**Layout:**
|
||||
- Label: "Ends" (13px/400, `--color-text-secondary`).
|
||||
- Three-option `<select>` or segmented control:
|
||||
- "Never" (default)
|
||||
- "On date" → reveals a date `<input type="date">` labeled "End date"
|
||||
- "After N times" → reveals a number `<input type="number" min="1">` labeled "Occurrences"
|
||||
- Both revealed inputs have the same 44px touch-target height as other form fields.
|
||||
- Validation errors shown inline below the field in 13px/400 `--color-destructive`.
|
||||
|
||||
**FREQ persistence fix (D-07):** The `RecurrencePreset` type in `apps/pwa/src/api/client.ts` must map to the correct `FREQ` string in `vevent.ts`. Verify the existing select value is serialized 1:1 into the RRULE FREQ field; if a daily selection persists as weekly, the bug is in the `preset → RRULE` mapping, not the form state.
|
||||
|
||||
### Surface 6: Series-edit prompt (999.9)
|
||||
|
||||
**Trigger:** User taps "Save" on an EventForm that is editing a recurring occurrence (occurrence has a `uid` whose event has `hasRrule=true`).
|
||||
|
||||
**Pattern:** Use the existing bottom-sheet/dialog pattern matching `DeleteConfirmationDialog`:
|
||||
- Phone (≤767px): bottom sheet sliding up from below.
|
||||
- Tablet/desktop (≥768px): centered dialog, max-width 480px.
|
||||
|
||||
**Layout:**
|
||||
```
|
||||
[ Sheet/Dialog ]
|
||||
Heading: "Edit recurring series" (18px/600)
|
||||
Body: "This will update all occurrences (15px/400, --color-text-secondary)
|
||||
of this event."
|
||||
─────────────────────────────────────────
|
||||
[ Cancel ] [ Update series ]
|
||||
ghost button accent-filled (--color-member-0)
|
||||
```
|
||||
|
||||
**Accessibility:**
|
||||
- `role="dialog"`, `aria-modal="true"`, `aria-labelledby` pointing to the heading.
|
||||
- Focus trap — Tab/Shift+Tab cycle between Cancel and Update series.
|
||||
- Escape key fires Cancel.
|
||||
|
||||
### Surface 7: Spin animation fix (D-13)
|
||||
|
||||
**Problem:** `@keyframes spin` is currently defined only inside `PushPermissionPrompt.tsx` inline styles (`:359`). `SyncStateToast` and `LiveSyncIndicator` use `animation: spin …` but the keyframe isn't globally available when that component isn't mounted.
|
||||
|
||||
**Fix:** The `@keyframes spin` in `apps/pwa/src/styles/tokens.css` (lines 140–147) IS already defined globally. The bug is that components are referencing it via inline style `animation: 'spin 1s linear infinite'` which works, but the `@keyframes spin` must be confirmed present in the global stylesheet before the component mounts. Executor: verify `tokens.css` exports `@keyframes spin` and that `index.css` imports it before any component referencing the animation mounts. The `@keyframes spin` definition in `PushPermissionPrompt.tsx` is redundant but harmless — remove it after confirming the global definition covers all consumers.
|
||||
|
||||
---
|
||||
|
||||
## Animation Contract
|
||||
|
||||
| Animation | Definition | Applied to |
|
||||
|-----------|-----------|-----------|
|
||||
| shimmer | `@keyframes shimmer` in tokens.css | SkeletonCalendar loading bars |
|
||||
| spin | `@keyframes spin` in tokens.css (global — do not redefine per-component) | Loader2 in SyncStateToast, PushPermissionPrompt, auth splash, session-expired interstitial |
|
||||
| pulse | `@keyframes pulse` — confirm presence in tokens.css or add it | LiveSyncIndicator reconnecting dot |
|
||||
|
||||
Note: If `@keyframes pulse` is not in tokens.css, add it alongside `@keyframes spin`:
|
||||
```css
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accessibility Contract
|
||||
|
||||
All new surfaces must meet:
|
||||
|
||||
| Requirement | Value |
|
||||
|-------------|-------|
|
||||
| Minimum touch target | 44px height (48px on primary CTAs) |
|
||||
| Focus management | Focus trap in all dialogs/sheets; Escape closes |
|
||||
| Live regions | `role="status"` for informational (spinner, sync); `role="alert"` for errors |
|
||||
| Color contrast | 4.5:1 for body text; 3:1 for large text (18px+/bold) |
|
||||
| Motion | `@keyframes spin` and `pulse` are UI-state feedback — not decorative; acceptable without `prefers-reduced-motion` guard, but a reduced-motion variant (opacity swap instead of spin) is a welcome addition if it fits the plan |
|
||||
|
||||
---
|
||||
|
||||
## Brand Assets & Iconography
|
||||
|
||||
Source: D-BRAND-01, D-BRAND-02 (locked user decisions, 2026-06-10).
|
||||
In-app UI icons remain lucide-react — this section covers only the brand mark, app icons, and favicons.
|
||||
|
||||
### Mark: Glyph + Wordmark
|
||||
|
||||
**Concept (D-BRAND-01):** Two overlapping rounded shapes — circles or rounded rectangles — suggesting two members sharing a space (the family) or two overlapping calendar tiles. The overlap region is the accent color at full opacity; each outer shape is the accent color at reduced opacity (≈60%). The wordmark "FamilySync" sits to the right of the glyph in the lockup variant; the glyph alone is the source for all raster icons.
|
||||
|
||||
**Geometry / construction:**
|
||||
- Two circles, each 40px diameter on a 64px × 64px artboard (SVG viewBox="0 0 64 64").
|
||||
- Circle 1 center: (24, 32). Circle 2 center: (40, 32).
|
||||
- Overlap: the intersection region formed by both paths.
|
||||
- Rendering: use two `<circle>` elements with `fill-opacity="0.6"` for the individual shapes, then a `<clipPath>`-intersected shape (or a third overlapping path) at `fill-opacity="1"` for the overlap highlight.
|
||||
- Wordmark: "FamilySync" set in the system-ui stack at 18px/600 in the lockup; the glyph and wordmark baseline-align.
|
||||
|
||||
**Color usage:**
|
||||
|
||||
| Surface | Glyph fill | Wordmark fill |
|
||||
|---------|-----------|---------------|
|
||||
| Light background (#FFFFFF) | #4A90D9 (accent) | #111318 (--color-text-primary) |
|
||||
| Dark background (≥50% dark) | #FFFFFF | #FFFFFF |
|
||||
| Monochrome (print / favicon .ico) | currentColor (#111318 on light, #FFFFFF on dark) — single flat shape, no opacity split |
|
||||
|
||||
**Clear space:** Minimum clear space = 1× the glyph diameter (64px on the 64px artboard, i.e. one full glyph-width on all four sides at actual render size).
|
||||
|
||||
**Minimum size:**
|
||||
- Glyph-only: 24px × 24px rendered (below this, detail is lost; use the monochrome flat variant).
|
||||
- Glyph + wordmark lockup: 120px wide minimum.
|
||||
|
||||
**Monochrome fallback:** A single filled shape representing both circles merged (union path), no opacity split. Used for favicon.ico and any single-color context.
|
||||
|
||||
### SVG Source-of-Truth Files
|
||||
|
||||
| File | Contents | Used as source for |
|
||||
|------|----------|--------------------|
|
||||
| `apps/pwa/src/assets/logo-glyph.svg` | Glyph only, viewBox="0 0 64 64", color-variable fills (`currentColor` + CSS custom property override) | All raster icon exports; in-app glyph-only placements |
|
||||
| `apps/pwa/src/assets/logo-lockup.svg` | Glyph + "FamilySync" wordmark, viewBox="0 0 240 64" | Auth splash, header lockup (if present) |
|
||||
| `apps/pwa/src/assets/logo-monochrome.svg` | Flat union-path glyph, single fill, viewBox="0 0 64 64" | favicon.ico source layer |
|
||||
|
||||
All SVG files: no embedded raster data, no `<image>` elements, path-only. Minified with no comments before commit.
|
||||
|
||||
### Raster Export Pipeline
|
||||
|
||||
Source: `apps/pwa/src/assets/logo-glyph.svg` (light-mode fill: #4A90D9 shapes on transparent background).
|
||||
|
||||
Export tool: any SVG-to-PNG renderer that preserves alpha (e.g. `sharp`, `Inkscape --export-png`, or `resvg`). A build-time script at `apps/pwa/scripts/export-icons.ts` (or equivalent Makefile target) must produce the full output list below from the SVG source — no manually-placed PNGs.
|
||||
|
||||
**Output list:**
|
||||
|
||||
| Output file | Size (px) | Format | Notes |
|
||||
|-------------|-----------|--------|-------|
|
||||
| `apps/pwa/public/icon-192.png` | 192×192 | PNG, RGBA | PWA manifest `any` icon |
|
||||
| `apps/pwa/public/icon-512.png` | 512×512 | PNG, RGBA | PWA manifest `any` icon |
|
||||
| `apps/pwa/public/icon-512-maskable.png` | 512×512 | PNG, RGBA | PWA manifest `maskable` icon — glyph centered in safe zone (see below) |
|
||||
| `apps/pwa/public/apple-touch-icon.png` | 180×180 | PNG, RGB (no alpha), white background | iOS home screen; Apple ignores alpha |
|
||||
| `apps/pwa/public/favicon-16.png` | 16×16 | PNG | favicon.ico source layer |
|
||||
| `apps/pwa/public/favicon-32.png` | 32×32 | PNG | favicon.ico source layer |
|
||||
| `apps/pwa/public/favicon-48.png` | 48×48 | PNG | favicon.ico source layer |
|
||||
| `apps/pwa/public/favicon.svg` | — | SVG (copy of logo-glyph.svg) | Modern browsers; referenced as `<link rel="icon" type="image/svg+xml">` |
|
||||
| `apps/pwa/public/favicon.ico` | 16+32+48 multi-res | ICO | Legacy browsers; bundle the three PNG layers into a single .ico using `png-to-ico` or equivalent |
|
||||
|
||||
Replace the three existing placeholder files (`icon-192.png`, `icon-512.png`, `apple-touch-icon.png`) with the real exports. The script must be idempotent (re-running overwrites all outputs).
|
||||
|
||||
### Maskable Safe Zone
|
||||
|
||||
The maskable icon specification requires the primary visual to fit within the center 80% of the canvas (the "safe zone"). For a 512×512 canvas, the safe zone is the inner 409×409 px centered region. The glyph export for `icon-512-maskable.png` must:
|
||||
- Scale the glyph to fit within 409×409 (≈80% of 512).
|
||||
- Center it on the 512×512 canvas.
|
||||
- Fill the outer 10% bleed area with the PWA `background_color` (#FFFFFF) so masked shapes (squircle, circle) show a clean white surround rather than transparency artifacts.
|
||||
|
||||
Update `vite.config.ts` manifest `icons` array to reference `icon-512-maskable.png` for the `maskable` purpose entry (separate file from the `any` 512px icon):
|
||||
|
||||
```ts
|
||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
{ src: '/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
```
|
||||
|
||||
### Favicon Set — index.html Changes
|
||||
|
||||
Current state: `index.html` has only `<link rel="apple-touch-icon">` and no `<link rel="icon">`. This is a gap.
|
||||
|
||||
Add the following tags inside `<head>`, after the existing `apple-touch-icon` line:
|
||||
|
||||
```html
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
```
|
||||
|
||||
Keep the existing tags unchanged:
|
||||
|
||||
```html
|
||||
<meta name="theme-color" content="#4A90D9" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="FamilySync" />
|
||||
```
|
||||
|
||||
Browser resolution order: SVG favicon first (Chrome 80+, Firefox 41+, Safari 12+), ICO fallback for IE/legacy. No `favicon-32.png` link needed in HTML — the ICO multi-res bundle covers the same use case and reduces link clutter.
|
||||
|
||||
### In-App Logo Usage
|
||||
|
||||
| Surface | Variant | Size | Placement |
|
||||
|---------|---------|------|-----------|
|
||||
| Auth splash (Surface 1) | Glyph + wordmark lockup (`logo-lockup.svg`) | 120px wide (auto height ~32px) | Centered above the spinner stack; 24px gap below lockup, then spinner |
|
||||
| Session-expired interstitial (Surface 2) | Glyph only (`logo-glyph.svg`) | 32×32px | Centered above spinner; same vertical stack as auth splash |
|
||||
| App header / nav bar (if present) | Glyph only (`logo-glyph.svg`) | 24×24px | Leading slot of the top nav bar, 16px from left edge, vertically centered |
|
||||
| PWA install prompt / about screen | Glyph + wordmark lockup | 160px wide | Centered |
|
||||
|
||||
Spacing tokens used: 24px gap (`--space-6`) between lockup and spinner on auth splash; 16px left inset (`--space-4`) for header placement. These are drawn from the established 8-point scale.
|
||||
|
||||
Color: render SVGs using CSS `color` inheritance where possible so light/dark mode automatically applies the correct fill. Set `fill="currentColor"` on all path elements in the SVG source; parent container sets `color: var(--color-text-primary)` (light) or `color: #FFFFFF` (dark overlay contexts such as auth splash over `--color-surface` — use `--color-text-primary` here since the background is white).
|
||||
|
||||
### Asset Manifest
|
||||
|
||||
| File path | Format | Size(s) | Purpose | Referenced in |
|
||||
|-----------|--------|---------|---------|---------------|
|
||||
| `apps/pwa/src/assets/logo-glyph.svg` | SVG | 64×64 viewBox | SVG source of truth — glyph only | Export script, in-app `<img>` or inline SVG |
|
||||
| `apps/pwa/src/assets/logo-lockup.svg` | SVG | 240×64 viewBox | SVG source of truth — glyph + wordmark | Auth splash, install prompt |
|
||||
| `apps/pwa/src/assets/logo-monochrome.svg` | SVG | 64×64 viewBox | Monochrome union-path variant | favicon.ico source |
|
||||
| `apps/pwa/public/favicon.svg` | SVG | — | Modern browser favicon | `<link rel="icon" type="image/svg+xml">` in index.html |
|
||||
| `apps/pwa/public/favicon.ico` | ICO | 16+32+48 multi-res | Legacy browser favicon | `<link rel="icon" type="image/x-icon">` in index.html |
|
||||
| `apps/pwa/public/icon-192.png` | PNG (RGBA) | 192×192 | PWA manifest any icon | vite.config.ts manifest `icons` |
|
||||
| `apps/pwa/public/icon-512.png` | PNG (RGBA) | 512×512 | PWA manifest any icon | vite.config.ts manifest `icons` |
|
||||
| `apps/pwa/public/icon-512-maskable.png` | PNG (RGBA) | 512×512 | PWA manifest maskable icon | vite.config.ts manifest `icons` |
|
||||
| `apps/pwa/public/apple-touch-icon.png` | PNG (RGB, white bg) | 180×180 | iOS home screen icon | `<link rel="apple-touch-icon">` in index.html |
|
||||
| `apps/pwa/scripts/export-icons.ts` | TypeScript | — | Build-time raster export pipeline | `make icons` or equivalent |
|
||||
|
||||
---
|
||||
|
||||
## Registry Safety
|
||||
|
||||
| Registry | Blocks Used | Safety Gate |
|
||||
|----------|-------------|-------------|
|
||||
| shadcn official | none | not applicable |
|
||||
| Third-party | none | not applicable |
|
||||
|
||||
No third-party component registries used. All components are hand-rolled using the existing token layer.
|
||||
|
||||
---
|
||||
|
||||
## Checker Sign-Off
|
||||
|
||||
- [ ] Dimension 1 Copywriting: PASS
|
||||
- [ ] Dimension 2 Visuals: PASS
|
||||
- [ ] Dimension 3 Color: PASS
|
||||
- [ ] Dimension 4 Typography: PASS
|
||||
- [ ] Dimension 5 Spacing: PASS
|
||||
- [ ] Dimension 6 Registry Safety: PASS
|
||||
|
||||
**Approval:** pending
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
phase: 6
|
||||
slug: ux-polish
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: 2026-06-10
|
||||
---
|
||||
|
||||
# Phase 6 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
> Source: `06-RESEARCH.md` § Validation Architecture. Planner fills the Per-Task Verification Map below as plans are written.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | vitest (both `apps/pwa` and `apps/api`) |
|
||||
| **Config file** | `apps/pwa/vitest.config.ts`, `apps/api/vitest.config.ts` |
|
||||
| **Quick run command** | `pnpm --filter @familysync/pwa test` (PWA-side fixes) · `pnpm --filter @familysync/api test` (recurrence write/expand) |
|
||||
| **Full suite command** | `pnpm -r test` |
|
||||
| **Browser verification** | `playwright-cli` skill (desktop Chromium) for visual/behavioral items — per CLAUDE.md convention |
|
||||
| **Estimated runtime** | ~30–60 seconds (unit); browser checks additive |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run the quick run command for the touched app
|
||||
- **After every plan wave:** Run `pnpm -r test`
|
||||
- **Before `/gsd-verify-work`:** Full suite green + playwright-cli checks pass
|
||||
- **Max feedback latency:** 60 seconds (unit)
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
> Planner: populate one row per task as plans are authored. Test-type guidance from RESEARCH § Validation Architecture:
|
||||
> - **unit** — duration-preservation math (`eventDateTime.ts`), RRULE UNTIL/COUNT serialization (`vevent.ts`), inclusive↔exclusive DTEND round-trip, FREQ-persistence regression (D-07), opaqueredirect/401 detection (`client.ts`), `hasRrule` population in `expandOccurrences()`.
|
||||
> - **integration** — PWA→API→expand recurrence round-trip (bounded series renders correct occurrence count, per-occurrence duration = start→end delta).
|
||||
> - **browser (playwright-cli, desktop Chromium)** — all-day visual distinctness (999.6), spinner/pulse actually animating (D-13), no calendar/"sign-in required" flash on cold load (999.2), clean session-expiry redirect (999.3), whole-series edit prompt (999.9).
|
||||
|
||||
| Task ID | Plan | Wave | Decision | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|----------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| 06-01-01 | 06-01 | 1 | D-04 | T-06-01 | N/A | unit | `cd apps/pwa && pnpm test -- run lib/eventDateTime` | ✅ | ⬜ pending |
|
||||
| 06-01-02 | 06-01 | 1 | D-04 | T-06-01 | N/A | unit | `cd apps/pwa && pnpm test -- run lib/eventDateTime` | ✅ | ⬜ pending |
|
||||
| 06-02-01 | 06-02 | 1 | D-06, D-07 | T-06-02 | RRULE UNTIL/COUNT value-type-matched; no raw passthrough to ICS | unit | `cd apps/api && pnpm test -- run broker/vevent broker/outboxWorker` | ✅ | ⬜ pending |
|
||||
| 06-02-02 | 06-02 | 1 | D-06, D-07 | T-06-02 / T-06-02b | Zod max-10 until + int≥1 count at route boundary; ical.js re-parse rejects malformed RRULE | unit | `cd apps/api && pnpm test -- run broker/vevent broker/outboxWorker` | ✅ | ⬜ pending |
|
||||
| 06-03-01 | 06-03 | 1 | D-08, D-06 | T-06-03 | N/A | unit | `cd apps/api && pnpm test -- run broker/expand` | ✅ | ⬜ pending |
|
||||
| 06-03-02 | 06-03 | 1 | D-08, D-06 | T-06-03 | hasRrule derived only from already-access-scoped data | unit | `cd apps/api && pnpm test -- run broker/expand` | ✅ | ⬜ pending |
|
||||
| 06-04-01 | 06-04 | 1 | D-13 | T-06-04 | N/A | unit | `grep -v '^#' apps/pwa/src/styles/tokens.css \| grep -c '@keyframes pulse' \| grep -qx 1 && cd apps/pwa && pnpm test -- run` | ✅ | ⬜ pending |
|
||||
| 06-04-02 | 06-04 | 1 | D-13 | T-06-04 | N/A | browser | playwright-cli (desktop Chromium) | ✅ | ⬜ pending |
|
||||
| 06-05-01 | 06-05 | 1 | D-11, D-06, D-08 | T-06-05-session | SessionExpiredError from 401/opaqueredirect only; never trusts response body; no token stored client-side | unit | `cd apps/pwa && pnpm test -- run api/client` | ✅ | ⬜ pending |
|
||||
| 06-05-02 | 06-05 | 1 | D-10 | T-06-05-info | Render gated on meQuery.isSuccess; no app data painted while auth unknown | integration | `cd apps/pwa && pnpm test -- run components/CalendarShell` | ✅ | ⬜ pending |
|
||||
| 06-05-03 | 06-05 | 1 | D-11 | T-06-05-redirect | Fixed internal /api/login target (no returnTo); one-shot guard bounds re-auth attempts | integration | `cd apps/pwa && pnpm test -- run` | ✅ | ⬜ pending |
|
||||
| 06-05-04 | 06-05 | 1 | D-10, D-11 | T-06-05-info / T-06-05-redirect | No pre-auth flash; clean mid-use 401 redirect within ~2s | browser | playwright-cli (desktop Chromium) | ✅ | ⬜ pending |
|
||||
| 06-06-01 | 06-06 | 2 | D-04, D-06, D-07, D-05 | T-06-06-input | Client-side count≥1 / until≥start validation (UX layer; server Zod is enforcement) | unit | `cd apps/pwa && pnpm test -- run components/EventForm` | ✅ | ⬜ pending |
|
||||
| 06-06-02 | 06-06 | 2 | D-08, D-09 | T-06-06-series | Whole-series PUT reuses existing per-user ownership/etag scope; prompt adds no privilege | integration | `cd apps/pwa && pnpm test -- run` | ✅ | ⬜ pending |
|
||||
| 06-06-03 | 06-06 | 2 | D-12 | T-06-06-xss | All-day label is plain-text JSX; CSS override is presentation-only, no injection surface | unit | `grep -v '^#' apps/pwa/src/styles/index.css \| grep -c 'sx__all-day-event' \| grep -qx 1 && cd apps/pwa && pnpm test -- run` | ✅ | ⬜ pending |
|
||||
| 06-06-04 | 06-06 | 2 | D-04, D-06, D-08, D-12 | T-06-06-input / T-06-06-series | End-tracking floor; bounded recurrence; series-edit gated; all-day distinct | browser | playwright-cli (desktop Chromium) | ✅ | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] Confirm existing vitest infra covers new test files (no framework install needed — vitest already configured in both apps)
|
||||
- [ ] Test stubs for: duration-preservation, RRULE UNTIL/COUNT, FREQ round-trip, session-error detection
|
||||
|
||||
*Existing infrastructure (vitest) covers all phase test types; Wave 0 is stub creation only.*
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Decision | Why Manual | Test Instructions |
|
||||
|----------|----------|------------|-------------------|
|
||||
| iOS-Safari standalone behavior (only if a fix regresses install/standalone) | D-10/D-11 | Cannot be driven by playwright-cli (per CLAUDE.md exception) | Add to Home Screen, cold-load, confirm splash + redirect on real iOS device |
|
||||
|
||||
*All other phase behaviors have automated verification (vitest) or desktop-Chromium browser verification (playwright-cli).*
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags (`vitest run`, not `vitest --watch`)
|
||||
- [ ] Feedback latency < 60s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
@@ -0,0 +1,184 @@
|
||||
---
|
||||
phase: 06-ux-polish
|
||||
verified: 2026-06-10T20:37:54Z
|
||||
status: human_needed
|
||||
score: 12/12
|
||||
overrides_applied: 0
|
||||
human_verification:
|
||||
- test: "iOS/standalone cold-load and OIDC redirect (D-10/D-11)"
|
||||
expected: "PWA installed to iOS Home Screen cold-loads to the AuthSplash 'Signing you in' splash; Authelia redirects correctly in standalone mode; session-expiry interstitial fires and navigates back to /api/login without a hang."
|
||||
why_human: "iOS Safari standalone OIDC redirect behavior is explicitly excluded from playwright-cli scope (CLAUDE.md convention; cannot simulate Safari standalone mode in desktop Chromium). Per 06-VALIDATION.md Manual-Only table."
|
||||
- test: "PushPermissionPrompt spinner on iOS device (CP-04.3)"
|
||||
expected: "The Loader2 spinner in PushPermissionPrompt rotates using the global @keyframes spin from tokens.css after the local redundant redefinition was removed."
|
||||
why_human: "PushPermissionPrompt only renders inside an installed iOS/standalone PWA. Desktop Chromium never surfaces the component. The global keyframe resolves correctly per code inspection but a real device spot-check was not run (documented residual in 06-04-SUMMARY.md)."
|
||||
---
|
||||
|
||||
# Phase 06: UX Polish Verification Report
|
||||
|
||||
**Phase 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).
|
||||
**Verified:** 2026-06-10T20:37:54Z
|
||||
**Status:** human_needed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
---
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | Moving an event's start moves its end preserving duration; end never strands behind start (D-03/D-04) | VERIFIED | `computeNewTimedEnd` + `computeNewAllDayEnd` exported from `eventDateTime.ts`; wired in `EventForm.tsx` start `onChange` handlers at lines 729-760; 6 unit tests green; EventForm tests D-04 timed + all-day pass |
|
||||
| 2 | A recurring series can be bounded via "Ends: Never / On date / After N times" (D-06) | VERIFIED | `assembleRruleString` in `outboxWorker.ts`; `recurrenceUntil`/`recurrenceCount` Zod fields in both `events.ts` and `outboxWorker.ts`; "Ends" control in `EventForm.tsx` (state at lines 214-216, rendered at line 888+); 7 assembleRruleString tests green; playwright-cli verified |
|
||||
| 3 | Editing a recurring occurrence prompts "Edit recurring series" before saving (D-08/D-09) | VERIFIED | `SeriesEditPrompt.tsx` created with `role="dialog"`, `aria-modal`, focus trap, Escape=cancel, correct copy; `EventForm.tsx` gates Save on `occurrence?.hasRrule === true` at line 419; `hasRrule` populated in `expand.ts` + mirrored in `client.ts`; playwright-cli verified |
|
||||
| 4 | All-day events visually distinct from timed events at a glance (999.6/D-12) | VERIFIED | `.sx__date-grid .sx__date-grid-event` and `.sx__month-grid-day__events .sx__month-grid-event:not(:has(.sx__month-grid-event-time))` CSS rules in `index.css` (lines 125-140) with `border-radius:4px`, `font-weight:600`, `border-inline-start:none`; real Schedule-X v4.6.0 selectors (not the non-existent `.sx__all-day-event`) verified correct after follow-up fix 6dbb166; playwright-cli verified |
|
||||
| 5 | All-day edit off-by-one stays fixed — re-editing does not grow event by a day (D-05) | VERIFIED | `exclusiveEndToInclusiveDate` pre-fill at EventForm reset line intact; D-05 round-trip test in EventForm.test.tsx passes; playwright-cli verified |
|
||||
| 6 | Unauthenticated cold load shows only the neutral "Signing you in" splash — no calendar/skeleton/alert flash (D-10) | VERIFIED | `CalendarShell.tsx` returns `<AuthSplash state="loading" />` on `meQuery.isLoading` before any calendar content (line 269); `AuthSplash.tsx` created with `role="status"`, correct copy, full-screen centered layout; playwright-cli checkpoint PASS for desktop Chromium |
|
||||
| 7 | A session that expires mid-use shows "Session expired" interstitial and cleanly redirects (D-11) | VERIFIED | `SessionExpiredError` class in `client.ts`; `handleAuthResponse` covers all 7 fetch wrappers; `QueryCache`/`MutationCache` `onError` in `main.tsx` (not `defaultOptions.onError`); `sessionExpired` flag in `calendarStore.ts`; CalendarShell renders `<AuthSplash state="redirecting" />` on `sessionExpired=true` with 1.5s redirect; dead-end state reachable when guard exhausted (follow-up fix e392c69); playwright-cli checkpoint PASS for desktop Chromium |
|
||||
| 8 | Sync indicators actually animate — SyncStateToast spinner spins and LiveSyncIndicator reconnecting dot pulses (D-13) | VERIFIED | `@keyframes pulse` added to `tokens.css` at line 149 (0%,100% opacity:1; 50% opacity:0.4); redundant local `@keyframes spin` block removed from `PushPermissionPrompt.tsx` (confirmed absent); playwright-cli checkpoint PASS — both `animationName` values non-'none' in desktop Chromium |
|
||||
| 9 | Nav chrome persists on /lists — BottomTabBar does not overlap Settings on desktop (UAT fixes FIX-3/FIX-4) | VERIFIED | `AppNav` lifted to `App.tsx` as a persistent sibling of `<Routes>` (outside any Route, line 112); `BottomTabBar` returns `null` on desktop via `isPhone()` guard (line 56); AppNav persistence test and BottomTabBar hidden-on-desktop test both green |
|
||||
| 10 | D-04 floor rule: end snaps to newStart+1h (timed) / same day (all-day) when old end was already behind start | VERIFIED | `deltaMs = oldEndMs > oldStartMs ? oldEndMs - oldStartMs : 60*60*1000` in `computeNewTimedEnd`; `Math.max(0, dateDiffDays(...))` in `computeNewAllDayEnd`; two floor-rule unit tests green |
|
||||
| 11 | RRULE UNTIL value-type matches DTSTART — DATE form for all-day, DATETIME UTC for timed (D-06, RFC 5545) | VERIFIED | `assembleRruleString`: all-day emits `UNTIL=YYYYMMDD`, timed emits `UNTIL=YYYYMMDDTHHMMSSZ (T235959Z)`; three vevent.test.ts serialization assertions + five assembleRruleString unit tests green |
|
||||
| 12 | FREQ=DAILY regression locked (D-07) | VERIFIED | `FREQ persistence (D-07 regression)` test in `outboxWorker.test.ts` asserts daily-recurrence payload emits `RRULE:FREQ=DAILY`; green |
|
||||
|
||||
**Score:** 12/12 truths verified
|
||||
|
||||
---
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `apps/pwa/src/lib/eventDateTime.ts` | `computeNewTimedEnd` + `computeNewAllDayEnd` exports with floor rules; no `toISOString().slice` | VERIFIED | Both functions exported at lines 114/142; WR-05 compliance confirmed — no `toISOString().slice` in helper code |
|
||||
| `apps/pwa/src/lib/eventDateTime.test.ts` | 6 new tests: 3 timed + 3 all-day end-tracking, RED→GREEN | VERIFIED | 6 tests present in two describe blocks; `computeNewTimedEnd` + `computeNewAllDayEnd` imported; all pass |
|
||||
| `apps/api/src/broker/outboxWorker.ts` | `assembleRruleString` exported; `recurrenceUntil`/`recurrenceCount` in `outboxPayloadSchema` | VERIFIED | `assembleRruleString` exported at line 114; both fields at lines 83-84 |
|
||||
| `apps/api/src/routes/events.ts` | `eventFieldsSchema` accepts `recurrenceUntil` + `recurrenceCount` | VERIFIED | Both fields at lines 111-112 |
|
||||
| `apps/api/tests/broker/vevent.test.ts` | UNTIL-DATE, UNTIL-DATETIME, COUNT serialization assertions | VERIFIED | 3 assertions match verified ical.js 2.2.1 output strings |
|
||||
| `apps/api/tests/broker/outboxWorker.test.ts` | assembleRruleString describe + FREQ persistence test | VERIFIED | Both describe blocks present; 7+1 tests pass |
|
||||
| `apps/api/src/broker/expand.ts` | `hasRrule: boolean` on `CalendarOccurrence`; populated from `event.isRecurring()` in both push sites | VERIFIED | Field at line 68; `const isRecurring` capture at line 224; both push sites at lines 261/308 |
|
||||
| `apps/api/tests/broker/expand.test.ts` | `hasRrule` true/false assertions + bounded COUNT=3 invariant | VERIFIED | `hasRrule` describe with 2 tests + `Bounded RRULE` describe with 3 tests; all pass |
|
||||
| `apps/api/tests/fixtures/weekly-count3.ics` | Bounded fixture for COUNT=3 test | VERIFIED | File exists at `apps/api/tests/fixtures/weekly-count3.ics` |
|
||||
| `apps/pwa/src/styles/tokens.css` | `@keyframes pulse` added globally | VERIFIED | Present at line 149; exactly once |
|
||||
| `apps/pwa/src/components/PushPermissionPrompt.tsx` | Redundant `@keyframes spin` `<style>` block removed | VERIFIED | `grep -q '@keyframes spin'` returns nothing |
|
||||
| `apps/pwa/src/api/client.ts` | `SessionExpiredError`; `handleAuthResponse`; `redirect:'manual'` on all wrappers; `hasRrule` on `CalendarOccurrence`; `recurrenceUntil`/`recurrenceCount` on `CreateEventPayload` | VERIFIED | All present: `class SessionExpiredError` at line 33; `handleAuthResponse` at line 51; 7 `handleAuthResponse` call sites; `hasRrule` at line 131; `recurrenceUntil` at line 190 |
|
||||
| `apps/pwa/src/api/client.test.ts` | SessionExpiredError detection tests (opaqueredirect + 401 per wrapper; 500 = generic Error) | VERIFIED | 40 tests pass; opaqueredirect/401/500 cases for fetchEvents, createEvent, updateEvent, deleteEvent, fetchMe |
|
||||
| `apps/pwa/src/components/AuthSplash.tsx` | Full-screen interstitial; loading/redirecting/dead-end states; `role="status"` | VERIFIED | File created; `AuthSplashState` type at line 27; `role="status"` at line 61; all three states handled |
|
||||
| `apps/pwa/src/components/CalendarShell.tsx` | `meQuery.isLoading` → AuthSplash loading; `meQuery.isError` → AuthSplash redirecting/dead-end; content only on `isSuccess`; `sessionExpired` interstitial wiring | VERIFIED | Lines 269-281 gate render; `sessionExpired` effect at lines 231-243; `enabled: meQuery.isSuccess` at line 120 |
|
||||
| `apps/pwa/src/main.tsx` | `QueryCache`/`MutationCache` `onError` (NOT `defaultOptions.onError`) routing `SessionExpiredError` to `setSessionExpired` | VERIFIED | `QueryCache` at line 37; `MutationCache` at line 38; no `defaultOptions.onError` in file |
|
||||
| `apps/pwa/src/store/calendarStore.ts` | `sessionExpired: boolean` + `setSessionExpired` action | VERIFIED | `sessionExpired: false` default at line 159; `setSessionExpired` at line 187 |
|
||||
| `apps/pwa/src/components/EventForm.tsx` | Start onChange handlers call `computeNewTimedEnd`/`computeNewAllDayEnd`; recurrenceBound state + "Ends" control; hasRrule gates SeriesEditPrompt; payload sends `recurrenceUntil`/`recurrenceCount` | VERIFIED | `computeNewTimedEnd` wired at lines 732/755; `computeNewAllDayEnd` at line 730; recurrenceBound state at line 214; "Ends" control at line 888; `hasRrule` gate at line 419; payload spread at lines 399-403 |
|
||||
| `apps/pwa/src/components/SeriesEditPrompt.tsx` | Bottom-sheet/dialog; focus trap; Escape=cancel; exact UI-SPEC copy; accent-filled "Update series"; ghost "Cancel" | VERIFIED | File created; `role="dialog"`, `aria-modal` at lines 135-136; "Edit recurring series" at line 154; "Update series" at line 217 |
|
||||
| `apps/pwa/src/styles/index.css` | `.sx__date-grid-event` + `.sx__month-grid-event:not(:has(.sx__month-grid-event-time))` all-day pill overrides (real v4.6.0 selectors) | VERIFIED | Both rules at lines 125/134 with `border-radius:4px`, `font-weight:600`, `border-inline-start:none` |
|
||||
| `apps/pwa/src/App.tsx` | AppNav as persistent sibling of `<Routes>` (FIX 3) | VERIFIED | `<AppNav>` rendered at line 112, outside `<Routes>` which starts at line 121 |
|
||||
| `apps/pwa/src/components/BottomTabBar.tsx` | Returns `null` on desktop (FIX 4) | VERIFIED | `if (!isPhone()) return null` at line 56 |
|
||||
|
||||
---
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| `EventForm.tsx` | `eventDateTime.ts` | start onChange → `computeNewTimedEnd` / `computeNewAllDayEnd` | WIRED | Both imports at lines 45-46; both calls in onChange handlers at lines 730, 732, 755 |
|
||||
| `EventForm.tsx` | `client.ts` | payload carries `recurrenceUntil`/`recurrenceCount`; `occurrence.hasRrule` gates prompt | WIRED | `recurrenceUntil` spread at line 399; `recurrenceCount` spread at line 402; `hasRrule` check at line 419 |
|
||||
| `main.tsx` | `calendarStore.ts` | `QueryCache`/`MutationCache` `onError` → `setSessionExpired(true)` on `SessionExpiredError` | WIRED | `useCalendarStore.getState().setSessionExpired(true)` at line 32; imperative store access confirmed |
|
||||
| `CalendarShell.tsx` | `AuthSplash.tsx` | `meQuery.isLoading`/`isError` and `sessionExpired` flag render AuthSplash | WIRED | Imports at line 50; `<AuthSplash state="loading" />` at line 270; `<AuthSplash state="redirecting" />` at lines 281/291 |
|
||||
| `outboxWorker.ts` | `vevent.ts` | `assembleRruleString` result passed to `buildVeventString` | WIRED | `assembleRruleString` called at lines 360/371/451/462; result flows as `rruleString` into the dispatch path |
|
||||
| `events.ts` | `outboxWorker.ts` | `recurrenceUntil`/`recurrenceCount` in enqueued payload | WIRED | Zod schema accepts fields in both `eventFieldsSchema` (events.ts:111-112) and `outboxPayloadSchema` (outboxWorker.ts:83-84) |
|
||||
| `expand.ts` | `client.ts` (mirror) | `CalendarOccurrence.hasRrule` server source-of-truth mirrored | WIRED | `hasRrule: boolean` at expand.ts line 68 (authoritative); mirrored at client.ts line 131 with explicit comment |
|
||||
|
||||
---
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||
|----------|---------------|--------|--------------------|--------|
|
||||
| `EventForm.tsx` | `computeNewTimedEnd` result → `endDate`/`endTime` state | `eventDateTime.ts` pure functions over form state (no network) | Yes — deterministic math, no network fetch, no empty source | FLOWING |
|
||||
| `EventForm.tsx` | `recurrenceUntil`/`recurrenceCount` → submit payload | User input (controlled form state) | Yes — user input flows directly to payload spread | FLOWING |
|
||||
| `EventForm.tsx` | `occurrence.hasRrule` gate | `CalendarOccurrence` from parent prop (occurrence fetched from API via `fetchEvents`) | Yes — `hasRrule` populated server-side in `expandOccurrences` from `event.isRecurring()` | FLOWING |
|
||||
| `CalendarShell.tsx` | `meQuery.isLoading`/`isError` | TanStack Query `['me']` query → `fetchMe()` → `/api/me` | Yes — real API call with `redirect:'manual'`; auth gating is live | FLOWING |
|
||||
| `CalendarShell.tsx` | `sessionExpired` | Zustand store, set by `QueryCache`/`MutationCache` `onError` on real `SessionExpiredError` | Yes — fires on real 401/opaqueredirect from any query/mutation | FLOWING |
|
||||
|
||||
---
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| `computeNewTimedEnd` + `computeNewAllDayEnd` exported | `node -e "import('./src/lib/eventDateTime.ts').then(m => console.log(typeof m.computeNewTimedEnd, typeof m.computeNewAllDayEnd))"` | `function function` | PASS |
|
||||
| D-04 end-tracking unit tests pass | `pnpm --filter @familysync/pwa test -- run lib/eventDateTime` | 191/191 pass | PASS |
|
||||
| SessionExpiredError detection tests pass | `npx vitest run src/api/client.test.ts` (apps/pwa) | 40/40 pass | PASS |
|
||||
| assembleRruleString + FREQ persistence tests pass | `npx vitest run tests/broker/outboxWorker.test.ts tests/broker/vevent.test.ts` (apps/api) | 39/39 pass | PASS |
|
||||
| hasRrule + bounded RRULE tests pass | `npx vitest run tests/broker/expand.test.ts` (apps/api) | 10/10 pass | PASS |
|
||||
| Full PWA test suite green | `pnpm --filter @familysync/pwa test -- run` | 191/191 pass (17 files) | PASS |
|
||||
| All phase-06 API broker + events tests green | `npx vitest run tests/broker/ tests/routes/events.test.ts` (apps/api) | 114/114 pass (9 files) | PASS |
|
||||
|
||||
Note: `tests/routes/lists.test.ts` and `tests/routes/push.test.ts` fail with `ER_ACCESS_DENIED_ERROR` (MariaDB not running with password in current dev environment). These are pre-existing integration-test DB-connectivity failures, not regressions introduced by phase 06. All broker tests that phase 06 modified or created are green.
|
||||
|
||||
---
|
||||
|
||||
### Probe Execution
|
||||
|
||||
No probe scripts found (`scripts/*/tests/probe-*.sh` absent). Step 7c: SKIPPED.
|
||||
|
||||
---
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
No `v1` REQ-IDs were assigned to this phase (confirmed by phase description and plan frontmatter — `requirements: []` in all plans). Phase is tracked against backlog items 999.2/3/6/7/8/9 and locked decisions D-01..D-13. All backlog items verified via truth/artifact checks above.
|
||||
|
||||
---
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| `SeriesEditPrompt.tsx` | 86 | `if (!open) return null` | Info | Correct conditional render guard — component is fully substantive when `open === true`; not a stub |
|
||||
|
||||
No TBD, FIXME, or XXX markers found in any phase-06-modified file. No unreferenced debt markers.
|
||||
|
||||
---
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
#### 1. iOS/Standalone Cold-Load and OIDC Redirect (D-10/D-11)
|
||||
|
||||
**Test:** Install the PWA to iOS Home Screen. Cold-load with no session cookie. Confirm the first painted frame is the neutral "Signing you in" splash (not the calendar shell or "Sign-in required" alert), and that Authelia redirect completes correctly in standalone mode. Then simulate a session expiry to confirm the "Session expired / Signing you back in..." interstitial appears and redirects to /api/login without hanging.
|
||||
|
||||
**Expected:** Splash shown on cold-load; Authelia round-trip succeeds; mid-use 401 shows interstitial then redirects within ~2s; no redirect loop.
|
||||
|
||||
**Why human:** iOS Safari standalone OIDC redirect behavior cannot be driven by playwright-cli. This is the documented CLAUDE.md exception (standalone-mode OIDC redirect, `window.location.href` cross-origin fallback behavior). Recorded in 06-VALIDATION.md Manual-Only table. Desktop Chromium checkpoints already PASS (playwright-cli verified in 06-05).
|
||||
|
||||
#### 2. PushPermissionPrompt Spinner on iOS Device (CP-04.3)
|
||||
|
||||
**Test:** On a real iOS device with the PWA installed as a standalone app, trigger the push permission prompt and confirm the Loader2 spinner rotates.
|
||||
|
||||
**Expected:** Spinner rotates using the global `@keyframes spin` from `tokens.css` (the redundant local redefinition was removed in commit `81f2678`).
|
||||
|
||||
**Why human:** `PushPermissionPrompt` only surfaces inside an installed iOS/standalone PWA. No desktop Chromium path to the component. Code-confirmed: the inline `animation: 'spin 1s linear infinite'` is still present on the Loader2 element and resolves to the global keyframe. A real-device spot-check is required for confidence. Documented residual in 06-04-SUMMARY.md.
|
||||
|
||||
---
|
||||
|
||||
### Notable Deviations from Plan (Not Gaps)
|
||||
|
||||
The following deviations were auto-fixed during execution and do not constitute gaps:
|
||||
|
||||
1. **Schedule-X CSS selector correction (06-06):** Plan assumed `.sx__all-day-event` but Schedule-X v4.6.0 does not emit that class. Executor discovered and fixed in commits `6dbb166` + `5620261` using real v4.6.0 selectors. playwright-cli re-verified PASS.
|
||||
|
||||
2. **AuthSplash dead-end state + redirect guard persistence (06-05):** Initial implementation had the dead-end state unreachable and the one-shot guard cleared prematurely. Found during playwright-cli checkpoint; fixed in commits `36ef7a0` + `e392c69`. Re-verified PASS.
|
||||
|
||||
3. **`hasExplicitRecurrence` precedence bug (06-02):** `recurrence:'none'` with `_preservedRrule` present incorrectly fell through to emit an RRULE. Fixed in GREEN commit `d2abb91`. Regression test `CR-01` confirms the fix.
|
||||
|
||||
4. **`EventDetailPopover.test.tsx` fixture update (06-06):** Existing test fixtures omitted the new required `hasRrule` field. Mechanical fix in commit `69e5ae8`.
|
||||
|
||||
5. **Dev-seed gap:** The dev-bypass user (id 1) has no CalDAV credential/calendars (those belong to user 2), so live event-create-via-form could not be exercised end-to-end against Fastmail. Server logic and form UI verified via route-mocks and direct DB occurrence inserts. Not a code defect.
|
||||
|
||||
---
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps. All 12 truths are VERIFIED. The two human verification items are device-only constraints (iOS/standalone behavior) that were explicitly pre-classified as manual checkpoints in 06-VALIDATION.md before execution began.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-06-10T20:37:54Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
Reference in New Issue
Block a user