style(13-03): apply Prettier formatting across repo

Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
@@ -11,20 +11,20 @@ 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)"
- '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"
- 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"
- from: 'apps/pwa/src/lib/eventDateTime.ts'
to: 'apps/pwa/src/lib/eventDateTime.test.ts'
via: 'vitest unit assertions'
pattern: 'computeNewTimedEnd|computeNewAllDayEnd'
---
<objective>
@@ -52,10 +52,11 @@ Output: `computeNewTimedEnd` and `computeNewAllDayEnd` exported from `eventDateT
<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>
</artifacts_this_plan_produces>
<tasks>
@@ -116,17 +117,19 @@ NEW symbols introduced here (exclude from any drift/convergence check — they d
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 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 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>
@@ -136,10 +139,11 @@ NEW symbols introduced here (exclude from any drift/convergence check — they d
</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>
</success_criteria>
<output>
Create `.planning/phases/06-ux-polish/06-01-SUMMARY.md` when done (RED/GREEN/REFACTOR notes + commit list).
@@ -1,6 +1,6 @@
---
phase: 06-ux-polish
plan: "01"
plan: '01'
subsystem: pwa/lib
tags: [tdd, date-math, event-form, d-04]
dependency_graph:
@@ -25,7 +25,7 @@ decisions:
- Private helpers (localDateISO, localTimeHHMM, dateDiffDays, addDaysISO) added to eventDateTime.ts to support the two exports
metrics:
duration_minutes: 2
completed_date: "2026-06-10"
completed_date: '2026-06-10'
tasks_completed: 2
files_changed: 2
---
@@ -36,10 +36,10 @@ metrics:
## 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 |
| 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
@@ -54,14 +54,14 @@ Four private helpers added to the same file: `localDateISO`, `localTimeHHMM`, `d
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 |
| 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.
@@ -14,29 +14,29 @@ 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)"
- '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"
- 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"
- 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>
@@ -63,10 +63,11 @@ Output: `assembleRruleString` in `outboxWorker.ts`, extended Zod schema in `even
<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>
</artifacts_this_plan_produces>
<tasks>
@@ -133,19 +134,21 @@ NEW symbols introduced here (exclude from drift/convergence checks):
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 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 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>
@@ -155,10 +158,11 @@ NEW symbols introduced here (exclude from drift/convergence checks):
</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>
</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).
@@ -1,6 +1,6 @@
---
phase: 06-ux-polish
plan: "02"
plan: '02'
subsystem: api/broker
tags: [tdd, rrule, recurrence, serialization, ical.js, zod]
dependency_graph:
@@ -27,13 +27,13 @@ key_files:
- 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"
- '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"
- '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"
completed_date: '2026-06-10'
tasks_completed: 2
files_modified: 4
---
@@ -44,22 +44,25 @@ metrics:
## 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 |
| # | 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`
@@ -68,6 +71,7 @@ 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
@@ -75,23 +79,25 @@ RED confirmed: `assembleRruleString is not a function` (6 failing tests).
- 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` |
| 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.
@@ -121,16 +127,17 @@ None. All test assertions target exact ICS/RRULE strings verified against ical.j
## 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 |
| Item | Status |
| ------------------------------- | --------- |
| SUMMARY.md created | FOUND |
| RED commit a59455a | FOUND |
| GREEN commit d2abb91 | FOUND |
| 39 tests passing | CONFIRMED |
| recurrenceUntil in both schemas | CONFIRMED |
@@ -11,20 +11,20 @@ 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)"
- '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"
- 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"
- 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>
@@ -53,9 +53,10 @@ Output: `hasRrule` on `CalendarOccurrence` + population in `expandOccurrences`,
<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>
</artifacts_this_plan_produces>
<tasks>
@@ -113,17 +114,19 @@ NEW symbols introduced here (exclude from drift/convergence checks):
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 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 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>
@@ -133,10 +136,11 @@ NEW symbols introduced here (exclude from drift/convergence checks):
</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>
</success_criteria>
<output>
Create `.planning/phases/06-ux-polish/06-03-SUMMARY.md` when done (RED/GREEN notes + commits).
@@ -1,39 +1,39 @@
---
phase: "06-ux-polish"
plan: "03"
subsystem: "api/broker"
tags: ["tdd", "expand", "hasRrule", "ical", "recurrence", "d-08", "d-06"]
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)"
- '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"
- '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)"
- '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"
- '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"
- 'apps/api/tests/fixtures/weekly-count3.ics'
modified:
- "apps/api/src/broker/expand.ts"
- "apps/api/tests/broker/expand.test.ts"
- '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"
- '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"
duration: '11m'
completed: '2026-06-10'
tasks_completed: 2
files_modified: 3
---
@@ -44,10 +44,10 @@ Added `hasRrule: boolean` to the `CalendarOccurrence` interface in `expand.ts` a
## 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 |
| # | 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
@@ -60,10 +60,12 @@ Added `hasRrule: boolean` field to `CalendarOccurrence` interface with JSDoc. Ca
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`
@@ -80,6 +82,7 @@ New fixture: `FREQ=WEEKLY;COUNT=3`, `DTSTART:20260601T090000Z`, `DTEND:20260601T
## 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)
@@ -11,19 +11,19 @@ autonomous: false
requirements: []
must_haves:
truths:
- "Sync indicators actually animate — the SyncStateToast spinner spins and the LiveSyncIndicator reconnecting dot pulses (D-13)"
- '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"
- 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"
- 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"
pattern: '@keyframes pulse'
---
<objective>
@@ -49,9 +49,10 @@ Output: `@keyframes pulse` added to `tokens.css`; redundant `<style>` block remo
<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>
</artifacts_this_plan_produces>
<tasks>
@@ -115,17 +116,19 @@ NEW symbols introduced here (exclude from drift/convergence checks):
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 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 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>
@@ -135,8 +138,9 @@ NEW symbols introduced here (exclude from drift/convergence checks):
</verification>
<success_criteria>
- D-13: pulse keyframe present globally; reconnecting dot animates; spinner confirmed animating; redundant redefinition removed.
</success_criteria>
</success_criteria>
<output>
Create `.planning/phases/06-ux-polish/06-04-SUMMARY.md` when done (note the playwright-cli observation of both animations).
@@ -34,10 +34,10 @@ metrics:
## 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) | — |
| 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
@@ -47,6 +47,7 @@ The plan's corrected diagnosis was that `@keyframes spin` was already global in
- **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.
@@ -17,32 +17,33 @@ 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)"
- 'Every PWA fetch wrapper detects 401/opaqueredirect and throws a typed SessionExpiredError (D-11)'
artifacts:
- path: "apps/pwa/src/api/client.ts"
- 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"
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"
- 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.
@@ -68,6 +69,7 @@ Output: `SessionExpiredError` + consistent `redirect:'manual'` in all fetch wrap
<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)
@@ -75,7 +77,7 @@ NEW symbols introduced here (exclude from drift/convergence checks):
- `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>
</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.
@@ -200,23 +202,25 @@ RESEARCH flagged the TanStack Query v5 global-error API as an unverified assumpt
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 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`. |
| 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 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>
@@ -227,11 +231,12 @@ RESEARCH flagged the TanStack Query v5 global-error API as an unverified assumpt
</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>
</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).
@@ -1,6 +1,6 @@
---
phase: 06-ux-polish
plan: "05"
plan: '05'
subsystem: pwa/auth
tags: [tdd, auth, session-expiry, d-10, d-11]
dependency_graph:
@@ -40,7 +40,7 @@ decisions:
- In-flight write replay deferred (D-11 nice-to-have, RESEARCH Open Question 3)
metrics:
duration_minutes: 35
completed_date: "2026-06-10"
completed_date: '2026-06-10'
tasks_completed: 4
files_changed: 6
---
@@ -51,15 +51,15 @@ metrics:
## 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 |
| 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
@@ -88,6 +88,7 @@ Created `apps/pwa/src/components/AuthSplash.tsx`:
- 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`.
@@ -97,10 +98,12 @@ Created `apps/pwa/src/components/AuthSplash.tsx`:
`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.
@@ -120,6 +123,7 @@ The checkpoint surfaced two related issues:
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.
@@ -142,6 +146,7 @@ Re-verified PASS via playwright-cli after fix.
### 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.
@@ -3,7 +3,7 @@ phase: 06-ux-polish
plan: 06
type: execute
wave: 2
depends_on: ["06-01", "06-02", "06-03", "06-05"]
depends_on: ['06-01', '06-02', '06-03', '06-05']
files_modified:
- apps/pwa/src/components/EventForm.tsx
- apps/pwa/src/components/SeriesEditPrompt.tsx
@@ -12,31 +12,31 @@ 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)"
- '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)"
- '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"
- 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"
- 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>
@@ -67,12 +67,13 @@ Output: EventForm end-tracking handlers + recurrence-bound control + series-edit
<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>
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>
@@ -193,21 +194,23 @@ NOTE: `computeNewTimedEnd`, `computeNewAllDayEnd`, `recurrenceUntil`/`recurrence
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 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. |
| 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 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>
@@ -218,12 +221,13 @@ NOTE: `computeNewTimedEnd`, `computeNewAllDayEnd`, `recurrenceUntil`/`recurrence
</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>
</success_criteria>
<output>
Create `.planning/phases/06-ux-polish/06-06-SUMMARY.md` when done (note playwright-cli observations for each behavior).
@@ -1,6 +1,6 @@
---
phase: 06-ux-polish
plan: "06"
plan: '06'
subsystem: pwa/event-form
tags: [event-form, recurrence, all-day, d-04, d-06, d-08, 999.6]
dependency_graph:
@@ -33,7 +33,7 @@ decisions:
- 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"
completed_date: '2026-06-10'
tasks_completed: 4
files_changed: 5
---
@@ -44,15 +44,15 @@ metrics:
## 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 |
| 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
@@ -116,6 +116,7 @@ Note: live event-create-via-the-form could not be exercised because the dev-bypa
### 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.
@@ -123,6 +124,7 @@ Note: live event-create-via-the-form could not be exercised because the dev-bypa
- **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.
@@ -10,14 +10,14 @@ Smooth the rough edges surfaced during Phase 3 Gate 2 live use so the app feels
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 |
| 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.
@@ -27,31 +27,38 @@ Scope is the six backlog polish items promoted into this phase:
## 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.
- **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.
@@ -59,17 +66,20 @@ Scope is the six backlog polish items promoted into this phase:
</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)
@@ -77,12 +87,14 @@ Scope is the six backlog polish items promoted into this phase:
- `.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 (`:3844`); centralization point for D-11
- `apps/pwa/src/lib/loginRedirect.ts``maybeRedirectToLogin()` / one-shot `familysync.loginRedirectAttempted` guard to be re-armed (D-11)
@@ -90,19 +102,23 @@ Scope is the six backlog polish items promoted into this phase:
</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.
@@ -129,5 +145,5 @@ Scope is the six backlog polish items promoted into this phase:
---
*Phase: 6-ux-polish*
*Context gathered: 2026-06-10*
_Phase: 6-ux-polish_
_Context gathered: 2026-06-10_
@@ -11,11 +11,11 @@
## 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 | |
| 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.
@@ -24,12 +24,12 @@
## 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 | ✓ |
| 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.
@@ -38,11 +38,11 @@
## 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) |
| 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).
@@ -51,11 +51,11 @@
## 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 | ✓ |
| 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.
@@ -8,22 +8,22 @@
## 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 |
| 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 |
---
@@ -36,6 +36,7 @@
**Analog:** same file — mirrors the existing `serializeEventDateTime` / `localWallClockToUtcIso` pattern exactly.
**Existing function signature pattern** (`eventDateTime.ts:3351`):
```typescript
export function serializeEventDateTime(
allDay: boolean,
@@ -45,23 +46,25 @@ export function serializeEventDateTime(
endTime: string,
): { start: string; end: string } {
if (allDay) {
return { start: startDate, end: endDate }
return { start: startDate, end: endDate };
}
return {
start: localWallClockToUtcIso(startDate, startTime),
end: localWallClockToUtcIso(endDate, endTime),
}
};
}
```
**Local accessor pattern** (`eventDateTime.ts:5961`):
```typescript
export function localWallClockToUtcIso(date: string, time: string): string {
return new Date(`${date}T${time}:00`).toISOString()
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(
@@ -72,14 +75,14 @@ export function computeNewTimedEnd(
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)
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)
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). */
@@ -88,8 +91,8 @@ export function computeNewAllDayEnd(
oldStartDate: string,
oldEndDate: string,
): string {
const span = Math.max(0, dateDiffDays(oldStartDate, oldEndDate))
return addDaysISO(newStartDate, span)
const span = Math.max(0, dateDiffDays(oldStartDate, oldEndDate));
return addDaysISO(newStartDate, span);
}
```
@@ -104,20 +107,22 @@ export function computeNewAllDayEnd(
**Analog:** same file — copy the `describe/it/expect` Vitest structure at lines 1853.
**Test structure to mirror** (`eventDateTime.test.ts:1853`):
```typescript
import { describe, it, expect } from 'vitest'
import { serializeEventDateTime, localWallClockToUtcIso } from './eventDateTime.js'
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)
})
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', () => { ... })
@@ -136,6 +141,7 @@ describe('computeNewAllDayEnd (D-04 — all-day end-tracking)', () => {
### `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).
@@ -143,6 +149,7 @@ describe('computeNewAllDayEnd (D-04 — all-day end-tracking)', () => {
**Exact bug sites (lines to replace):**
Start date `onChange` — line 671:
```typescript
// CURRENT (bug):
onChange={(e) => setStartDate(e.target.value)}
@@ -162,6 +169,7 @@ onChange={(e) => {
```
Start time `onChange` — line 683 (same pattern but updates from time change):
```typescript
// CURRENT (bug):
onChange={(e) => setStartTime(e.target.value)}
@@ -169,22 +177,24 @@ onChange={(e) => setStartTime(e.target.value)}
```
**Existing state declaration pattern to add new recurrence state alongside** (`EventForm.tsx:201207`):
```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')
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)
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:232258`):
The effect at lines 232262 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 = {
@@ -203,7 +213,7 @@ const payload: CreateEventPayload = {
...(recurrence !== 'none' && recurrenceBound === 'count' && recurrenceCount >= 1
? { recurrenceCount }
: {}),
}
};
```
---
@@ -211,63 +221,67 @@ const payload: CreateEventPayload = {
### `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:136148`):
```typescript
export interface CreateEventPayload {
title: string
allDay: boolean
start: string
end: string
recurrence?: RecurrencePreset
location?: string
description?: string
calendarUrl?: string
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
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:7191`):
```typescript
export interface CalendarOccurrence {
// ... all existing fields ...
description: string | null
description: string | null;
// ADD (D-08):
hasRrule: boolean // true when this occurrence belongs to a recurring series
hasRrule: boolean; // true when this occurrence belongs to a recurring series
}
```
**Existing auth detection pattern to generalize** (`client.ts:3653``fetchMe`):
```typescript
// CURRENT — only in fetchMe:
const res = await fetch('/api/me', {
credentials: 'include',
redirect: 'manual', // ← only fetchMe has this
})
redirect: 'manual', // ← only fetchMe has this
});
if (res.type === 'opaqueredirect' || res.status === 401) {
throw new Error('GET /api/me: authentication required') // ← untyped
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'
readonly name = 'SessionExpiredError';
constructor() {
super('Session expired — re-authentication required')
Object.setPrototypeOf(this, SessionExpiredError.prototype)
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()
throw new SessionExpiredError();
}
if (!res.ok) throw new Error(`${label} failed: ${res.status}`)
if (!res.ok) throw new Error(`${label} failed: ${res.status}`);
}
```
@@ -278,11 +292,13 @@ Every fetch function (`fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`
### `apps/pwa/src/components/CalendarShell.tsx` (component, request-response) — D-10, D-11
**Changes:**
1. Replace the `meQuery.isError` "Sign-in required" branch (lines 220235) 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:220235`):
```tsx
// CURRENT — remove this entire block:
if (meQuery.isError) {
@@ -311,42 +327,45 @@ if (meQuery.isError) {
```
**Existing useEffect pattern for auth redirect to keep** (`CalendarShell.tsx:196208`):
```typescript
// Keep these two effects — they handle the one-shot guard correctly:
useEffect(() => {
if (meQuery.isError) {
maybeRedirectToLogin()
maybeRedirectToLogin();
}
}, [meQuery.isError])
}, [meQuery.isError]);
useEffect(() => {
if (meQuery.isSuccess) {
clearLoginRedirect()
clearLoginRedirect();
}
}, [meQuery.isSuccess])
}, [meQuery.isSuccess]);
```
**Existing import pattern to extend** (`CalendarShell.tsx:4456`):
```typescript
import { fetchMe, fetchEvents } from '../api/client.js'
import { maybeRedirectToLogin, clearLoginRedirect } from '../lib/loginRedirect.js'
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
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
setSessionExpired(true); // Zustand flag
}
})
});
queryClient.getMutationCache().subscribe((event) => {
if (event.type === 'error' && event.error instanceof SessionExpiredError) {
setSessionExpired(true)
setSessionExpired(true);
}
})
});
```
---
@@ -356,19 +375,24 @@ queryClient.getMutationCache().subscribe((event) => {
**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:2845`):
```typescript
export function maybeRedirectToLogin(): boolean {
if (typeof window === 'undefined') return false
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 }
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 { }
try {
sessionStorage.removeItem(LOGIN_REDIRECT_KEY);
} catch {}
}
```
@@ -379,18 +403,29 @@ export function clearLoginRedirect(): void {
**Change:** Add missing `@keyframes pulse`. Remove nothing (the `@keyframes spin` at lines 140147 stays).
**Existing `@keyframes spin` to copy the CSS pattern from** (`tokens.css:140147`):
```css
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
```
**Add directly after it:**
```css
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
```
@@ -403,14 +438,17 @@ export function clearLoginRedirect(): void {
**Change:** Remove the redundant local `<style>` block at lines 358363. No other changes.
**Block to delete** (`PushPermissionPrompt.tsx:357363`):
```tsx
{/* Spin animation for loader */}
{
/* Spin animation for loader */
}
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
`}</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.
@@ -422,15 +460,16 @@ The `tokens.css` global definition (loaded via `main.tsx` → `index.css` → `@
**Change:** Add `hasRrule: boolean` to `CalendarOccurrence` interface and populate it in `expandOccurrences`.
**Interface to extend** (`expand.ts:3767`):
```typescript
export interface CalendarOccurrence {
id: string
uid: string
calendarId: number
id: string;
uid: string;
calendarId: number;
// ... all existing fields ...
description: string | null
description: string | null;
// ADD:
hasRrule: boolean // true when this event has an RRULE (recurring series)
hasRrule: boolean; // true when this event has an RRULE (recurring series)
}
```
@@ -438,22 +477,23 @@ export interface CalendarOccurrence {
```typescript
// BEFORE the branch at line 223:
const isRecurring = event.isRecurring()
const isRecurring = event.isRecurring();
// In the non-recurring push at lines 241256 — add:
occurrences.push({
// ... existing fields ...
hasRrule: isRecurring, // always false here — non-recurring branch
})
hasRrule: isRecurring, // always false here — non-recurring branch
});
// In the recurring push at lines 287302 — add:
occurrences.push({
// ... existing fields ...
hasRrule: isRecurring, // always true here — recurring branch
})
hasRrule: isRecurring, // always true here — recurring branch
});
```
**Existing push pattern to mirror** (`expand.ts:241257`):
```typescript
occurrences.push({
id: makeOccurrenceId(uid, dtstart),
@@ -471,7 +511,7 @@ occurrences.push({
location: event.location ?? null,
description: event.description ?? null,
// ADD: hasRrule: isRecurring
})
});
```
---
@@ -481,24 +521,26 @@ occurrences.push({
**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:143148`):
```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)
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:4954`):
```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.
@@ -508,11 +550,13 @@ The UNTIL/COUNT string assembly happens in `outboxWorker.ts` (see below). `build
### `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:7183`):
```typescript
const outboxPayloadSchema = z
.object({
@@ -526,56 +570,63 @@ const outboxPayloadSchema = z
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'
recurrenceUntil: z.string().max(10).optional(), // 'YYYY-MM-DD'
recurrenceCount: z.number().int().min(1).optional(),
})
.passthrough()
.passthrough();
```
**Existing RRULE assembly site to extend** (`outboxWorker.ts:255308`):
```typescript
// Existing (keep):
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence')
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence');
const rruleFromPayload =
fields.recurrence && fields.recurrence !== 'none'
? RRULE_PRESETS[fields.recurrence as string]
: undefined
: 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'
basePreset: string, // e.g. 'FREQ=WEEKLY' from RRULE_PRESETS
until?: string, // 'YYYY-MM-DD'
count?: number,
allDay?: boolean,
): string {
let s = basePreset
let s = basePreset;
if (count !== undefined) {
s += `;COUNT=${count}`
s += `;COUNT=${count}`;
} else if (until) {
if (allDay) {
s += `;UNTIL=${until.replace(/-/g, '')}` // DATE form: 20260630
s += `;UNTIL=${until.replace(/-/g, '')}`; // DATE form: 20260630
} else {
s += `;UNTIL=${until.replace(/-/g, '')}T235959Z` // DATETIME UTC: 20260630T235959Z
s += `;UNTIL=${until.replace(/-/g, '')}T235959Z`; // DATETIME UTC: 20260630T235959Z
}
}
return s
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)
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;
```
---
@@ -585,6 +636,7 @@ const finalRruleString = hasExplicitRecurrence && rruleFromPayload
**Change:** Add test cases for UNTIL (DATE form), UNTIL (DATETIME UTC form), COUNT.
**Existing test structure to extend** (`vevent.test.ts:2260`):
```typescript
import { describe, it, expect } from 'vitest'
import { buildVeventString } from '../../src/broker/vevent.js'
@@ -597,40 +649,41 @@ describe('buildVeventString', () => {
```
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 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 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')
})
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');
});
```
---
@@ -640,6 +693,7 @@ Add alongside existing cases:
**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, ...)
@@ -658,48 +712,55 @@ const boundedRrule = 'FREQ=WEEKLY;COUNT=3'
## Shared Patterns
### Auth detection — `redirect: 'manual'` + typed error
**Source:** `apps/pwa/src/api/client.ts` lines 3653 (`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
})
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
throw new Error('GET /api/me: authentication required'); // ← replace with SessionExpiredError
}
if (!res.ok) {
throw new Error(`GET /api/me failed: ${res.status}`)
throw new Error(`GET /api/me failed: ${res.status}`);
}
```
### One-shot login redirect
**Source:** `apps/pwa/src/lib/loginRedirect.ts` lines 2864
**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 7183 (`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 143148
**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:3767` (server) + `apps/pwa/src/api/client.ts:7191` (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 1553
**Apply to:** New `computeNewTimedEnd` / `computeNewAllDayEnd` tests (D-04)
@@ -711,8 +772,8 @@ Copy the `import { describe, it, expect } from 'vitest'` header and `describe('.
All touched files have direct in-repo analogs. One net-new component is implied:
| Implied New File | Role | Data Flow | Reason |
|---|---|---|---|
| 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.
@@ -7,6 +7,7 @@
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
@@ -37,7 +38,7 @@
- 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>
</user_constraints>
---
@@ -58,16 +59,16 @@ Six independent fix areas, each self-contained but sharing the EventForm and cli
## 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 |
| 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 |
---
@@ -75,14 +76,14 @@ Six independent fix areas, each self-contained but sharing the EventForm and cli
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 |
| 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.**
@@ -109,6 +110,7 @@ No new files/directories needed beyond what already exists. New test files follo
#### 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'
@@ -126,6 +128,7 @@ recurrenceCount?: number // integer ≥ 1 — maps to RRULE COUNT; undefined
```
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).
@@ -135,6 +138,7 @@ Rules:
#### API route validation (`events.ts:100109`)
The Zod schema `eventFieldsSchema` and `outboxPayloadSchema` in `outboxWorker.ts:7183` both need the two new fields:
```typescript
recurrenceUntil: z.string().max(10).optional(), // 'YYYY-MM-DD'
recurrenceCount: z.number().int().min(1).optional(),
@@ -158,6 +162,7 @@ ICAL.Recur.fromString('FREQ=DAILY;UNTIL=20260630T235959Z').toString()
```
**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`).
@@ -168,23 +173,23 @@ ICAL.Recur.fromString('FREQ=DAILY;UNTIL=20260630T235959Z').toString()
```typescript
// In outboxWorker.ts, when building rruleString for buildVeventString:
function assembleRruleString(
preset: string, // 'FREQ=WEEKLY' etc. from RRULE_PRESETS
until?: string, // 'YYYY-MM-DD'
preset: string, // 'FREQ=WEEKLY' etc. from RRULE_PRESETS
until?: string, // 'YYYY-MM-DD'
count?: number,
allDay?: boolean,
): string {
let s = preset
let s = preset;
if (count !== undefined) {
s += `;COUNT=${count}`
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
s += `;UNTIL=${until.replace(/-/g, '')}`; // 20260630
} else {
s += `;UNTIL=${until.replace(/-/g, '')}T235959Z` // 20260630T235959Z
s += `;UNTIL=${until.replace(/-/g, '')}T235959Z`; // 20260630T235959Z
}
}
return s
return s;
}
```
@@ -195,6 +200,7 @@ function assembleRruleString(
**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
```
@@ -231,6 +237,7 @@ COUNT=3 with weekly FREQ → RecurExpansion.next() returns 3 occurrences then ma
#### 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`.
@@ -250,6 +257,7 @@ The events route SQL join already fetches enough data; no DB query change needed
#### Write-back path for master VEVENT edit
**Current state** [VERIFIED: codebase read]: The edit route in `events.ts` (lines 326431) 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.
@@ -278,10 +286,10 @@ UI-SPEC.md §Surface 6 locks the pattern: bottom-sheet on phone, dialog on deskt
```typescript
// EventForm.tsx state (lines 201206)
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'
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.
@@ -291,27 +299,29 @@ The start date and time inputs currently have individual `onChange` handlers (`s
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
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)
setStartDate(newStartDate);
const oldSpanDays = Math.max(0, dateDiffDays(startDate, endDate));
const newEnd = addDays(newStartDate, oldSpanDays); // pure date arithmetic
setEndDate(newEnd);
}
```
@@ -330,10 +340,15 @@ These are pure functions with defined I/O — **TDD-eligible.** Extract to `apps
#### Actual state of `@keyframes spin` [VERIFIED: codebase read]
`apps/pwa/src/styles/tokens.css` lines 140147:
```css
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
```
@@ -346,6 +361,7 @@ The `@keyframes spin` global definition IS present and IS loaded before any comp
#### Actual bugs
**Bug 1 — Redundant local redefine** [VERIFIED: codebase read]: `PushPermissionPrompt.tsx:358363` contains:
```tsx
<style>{`
@keyframes spin {
@@ -354,17 +370,26 @@ The `@keyframes spin` global definition IS present and IS loaded before any comp
}
`}</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'
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; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
```
@@ -379,12 +404,14 @@ animation: 'pulse 1.4s ease-in-out infinite'
#### 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 220235), 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).
@@ -393,11 +420,11 @@ animation: 'pulse 1.4s ease-in-out infinite'
```tsx
// In CalendarShell, before the main render tree:
if (meQuery.isLoading) {
return <AuthSplash state="loading" />
return <AuthSplash state="loading" />;
}
if (meQuery.isError) {
// useEffect handles maybeRedirectToLogin() — splash shows while redirect fires
return <AuthSplash state="redirecting" />
return <AuthSplash state="redirecting" />;
}
```
@@ -406,6 +433,7 @@ The `useEffect` for `maybeRedirectToLogin()` (already at line 197) fires after t
#### Session expiry mid-use — D-11 [VERIFIED: codebase read]
**Current state:**
- `fetchMe` in `client.ts` (lines 3653): 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.
@@ -413,23 +441,27 @@ The `useEffect` for `maybeRedirectToLogin()` (already at line 197) fires after t
**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') }
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()
throw new SessionExpiredError();
}
if (!res.ok) throw new Error(`HTTP ${res.status}`)
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({
@@ -437,19 +469,19 @@ const queryClient = new QueryClient({
queries: {
onError: (error) => {
if (error instanceof SessionExpiredError) {
setSessionExpiredFlag() // Zustand flag
setSessionExpiredFlag(); // Zustand flag
}
},
},
mutations: {
onError: (error) => {
if (error instanceof SessionExpiredError) {
setSessionExpiredFlag()
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()`).
@@ -464,12 +496,12 @@ A Zustand `sessionExpired: boolean` flag triggers the session-expiry interstitia
## 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 |
| 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 |
---
@@ -516,18 +548,18 @@ A Zustand `sessionExpired: boolean` flag triggers the session-expiry interstitia
// 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)
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')
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')
const recur = ICAL.Recur.fromString('FREQ=WEEKLY;UNTIL=20260630T235959Z');
// produces: RRULE:FREQ=WEEKLY;UNTIL=20260630T235959Z
```
@@ -545,14 +577,14 @@ export function computeNewTimedEnd(
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)
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. */
@@ -561,8 +593,8 @@ export function computeNewAllDayEnd(
oldStartDate: string,
oldEndDate: string, // inclusive
): string {
const span = Math.max(0, dateDiffDays(oldStartDate, oldEndDate))
return addDaysISO(newStartDate, span)
const span = Math.max(0, dateDiffDays(oldStartDate, oldEndDate));
return addDaysISO(newStartDate, span);
}
```
@@ -572,30 +604,30 @@ export function computeNewAllDayEnd(
// Source: design from CONTEXT.md D-11 + codebase analysis
export class SessionExpiredError extends Error {
readonly name = 'SessionExpiredError'
readonly name = 'SessionExpiredError';
constructor() {
super('Session expired — re-authentication required')
Object.setPrototypeOf(this, SessionExpiredError.prototype)
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}`)
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 |
| # | 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 |
---
@@ -621,12 +653,12 @@ if (!res.ok) throw new Error(`GET /api/events failed: ${res.status}`)
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) | — |
| 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) | — |
---
@@ -636,39 +668,40 @@ Step 2.6: No new external dependencies required. All tooling (Node.js 22, pnpm,
### 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`) |
| 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 |
| 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
@@ -683,48 +716,50 @@ Write tests FIRST for these:
### 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 |
| 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.)*
_(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 | — |
| 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` |
| 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` |
---
@@ -760,6 +795,7 @@ Write tests FIRST for these:
## 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
@@ -16,6 +16,7 @@ status: partial
**Iteration:** 1
**Summary:**
- Findings in scope: 15 (fix_scope=all — CR + WR + IN)
- Fixed: 13
- Skipped: 2
@@ -36,7 +37,7 @@ Verification note: the isolated worktree has no `node_modules`, so a full `tsc -
**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.
**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
@@ -16,6 +16,7 @@ status: partial
**Iteration:** 1
**Summary:**
- Findings in scope: 15 (fix_scope=all — CR + WR + IN)
- Fixed: 13
- Skipped: 2
@@ -36,7 +37,7 @@ Verification note: the isolated worktree has no `node_modules`, so a full `tsc -
**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.
**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
@@ -47,7 +47,7 @@ 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
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
@@ -67,9 +67,9 @@ The route validates `recurrenceUntil` only as `z.string().max(10).optional()`
≤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
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
@@ -103,7 +103,7 @@ output is guaranteed digits-only and the injection vector closes.
**File:** `apps/api/src/routes/events.ts:199-203`
**Issue:**
The third `or()` branch selects *any* row with `dtstartDate` in `[start, end)`
The third `or()` branch selects _any_ row with `dtstartDate` in `[start, end)`
**without** gating on `hasRrule = 0`:
```js
@@ -111,7 +111,7 @@ 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
@@ -124,13 +124,14 @@ real on the wire and any consumer that counts occurrences (or a future view that
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
@@ -154,9 +155,10 @@ On date"). Treat a blank `recurrenceUntil` while `bound === 'until'` as a valida
error.
**Fix:** Add to the `recurrence !== 'none'` block:
```js
if (recurrenceBound === 'until' && !recurrenceUntil) {
newErrors.recurrenceBound = 'Choose an end date'
newErrors.recurrenceBound = 'Choose an end date';
}
```
@@ -173,6 +175,7 @@ yielding an unbounded series. The 0 case is caught by validation, but the NaN ca
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)
@@ -192,8 +195,8 @@ if (recurrenceBound === 'count' && (!Number.isInteger(recurrenceCount) || recurr
`.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
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
@@ -201,6 +204,7 @@ at EventForm.tsx:153/158) pre-fills tomorrow. The fix already exists as the priv
**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())}`
@@ -221,6 +225,7 @@ interstitial never arms, leaving the user on a hung query. The class uses a fixe
should defensively also check `name`.
**Fix:**
```js
function onGlobalError(error: unknown): void {
if (error instanceof SessionExpiredError ||
@@ -256,7 +261,7 @@ and let the next poll reconcile.
**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
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
@@ -272,7 +277,7 @@ the implicit format coupling is fragile. Compare parsed dates or assert non-empt
**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
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
@@ -287,7 +292,7 @@ Tighten the accepted timed-format (e.g. require a `T` and `:` before calling
### 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 ```
**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
@@ -11,20 +11,22 @@ updated: 2026-06-10
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.
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]
@@ -16,13 +16,13 @@ created: 2026-06-10
## 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 |
| 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.
@@ -33,17 +33,18 @@ No new design-system tooling introduced in this phase.
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 |
| 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.
@@ -53,12 +54,12 @@ Exceptions:
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 |
| 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 |
---
@@ -66,31 +67,35 @@ From `apps/pwa/src/styles/tokens.css` — no new sizes or weights introduced in
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 |
| 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 (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).
@@ -100,68 +105,72 @@ New usage decisions for Phase 6 surfaces:
### Auth splash — unauthenticated cold load (999.2)
| Element | Copy |
|---------|------|
| Heading | Signing you in |
| Body | Taking you to the sign-in page… |
| 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… |
| 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 |
| 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 |
| 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 |
| 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)
@@ -175,10 +184,10 @@ No new empty states introduced in this phase. Existing EmptyState and ListsEmpty
### 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 |
| 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 |
---
@@ -189,11 +198,13 @@ No new empty states introduced in this phase. Existing EmptyState and ListsEmpty
**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.
@@ -205,6 +216,7 @@ No new empty states introduced in this phase. Existing EmptyState and ListsEmpty
**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.
@@ -217,6 +229,7 @@ No new empty states introduced in this phase. Existing EmptyState and ListsEmpty
**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**.
@@ -228,6 +241,7 @@ No new empty states introduced in this phase. Existing EmptyState and ListsEmpty
### 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`.
@@ -235,6 +249,7 @@ No new empty states introduced in this phase. Existing EmptyState and ListsEmpty
- 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.
@@ -243,6 +258,7 @@ No new empty states introduced in this phase. Existing EmptyState and ListsEmpty
**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)
@@ -258,10 +274,12 @@ No new empty states introduced in this phase. Existing EmptyState and ListsEmpty
**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)
@@ -273,6 +291,7 @@ No new empty states introduced in this phase. Existing EmptyState and ListsEmpty
```
**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.
@@ -287,17 +306,23 @@ No new empty states introduced in this phase. Existing EmptyState and ListsEmpty
## 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 |
| 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; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
```
@@ -307,13 +332,13 @@ Note: If `@keyframes pulse` is not in tokens.css, add it alongside `@keyframes s
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 |
| 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 |
---
@@ -327,6 +352,7 @@ In-app UI icons remain lucide-react — this section covers only the brand mark,
**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.
@@ -335,15 +361,16 @@ In-app UI icons remain lucide-react — this section covers only the brand mark,
**Color usage:**
| Surface | Glyph fill | Wordmark fill |
|---------|-----------|---------------|
| Light background (#FFFFFF) | #4A90D9 (accent) | #111318 (--color-text-primary) |
| Dark background (≥50% dark) | #FFFFFF | #FFFFFF |
| 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.
@@ -351,11 +378,11 @@ In-app UI icons remain lucide-react — this section covers only the brand mark,
### 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 |
| 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.
@@ -367,23 +394,24 @@ Export tool: any SVG-to-PNG renderer that preserves alpha (e.g. `sharp`, `Inksca
**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 |
| 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.
@@ -421,12 +449,12 @@ Browser resolution order: SVG favicon first (Chrome 80+, Firefox 41+, Safari 12+
### 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 |
| 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.
@@ -434,27 +462,27 @@ Color: render SVGs using CSS `color` inheritance where possible so light/dark mo
### 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 |
| 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 |
| 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.
@@ -16,14 +16,14 @@ created: 2026-06-10
## 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** | ~3060 seconds (unit); browser checks additive |
| 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** | ~3060 seconds (unit); browser checks additive |
---
@@ -39,30 +39,31 @@ created: 2026-06-10
## 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 |
| 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*
_Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky_
---
@@ -71,17 +72,17 @@ created: 2026-06-10
- [ ] 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.*
_Existing infrastructure (vitest) covers all phase test types; Wave 0 is stub creation only._
---
## Manual-Only Verifications
| Behavior | Decision | Why Manual | Test Instructions |
|----------|----------|------------|-------------------|
| 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).*
_All other phase behaviors have automated verification (vitest) or desktop-Chromium browser verification (playwright-cli)._
---
@@ -5,12 +5,12 @@ status: human_needed
score: 12/12
overrides_applied: 0
human_verification:
- test: "iOS/standalone cold-load and OIDC redirect (D-10/D-11)"
- 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)."
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
@@ -26,20 +26,20 @@ human_verification:
### 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 |
| # | 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
@@ -47,70 +47,70 @@ human_verification:
### 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 |
| 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 |
| 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 |
| 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 |
| 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.
@@ -130,9 +130,9 @@ No `v1` REQ-IDs were assigned to this phase (confirmed by phase description and
### 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 |
| 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.