From 456121969f2696af883cbf335ee022421fddebd2 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 10 Jun 2026 10:25:41 -0400 Subject: [PATCH] docs(06): create ux-polish phase plan (6 plans, 2 waves) Co-Authored-By: Claude Opus 4.8 --- .planning/ROADMAP.md | 21 +- .planning/phases/06-ux-polish/06-01-PLAN.md | 146 ++++++++++++ .planning/phases/06-ux-polish/06-02-PLAN.md | 165 ++++++++++++++ .planning/phases/06-ux-polish/06-03-PLAN.md | 143 ++++++++++++ .planning/phases/06-ux-polish/06-04-PLAN.md | 143 ++++++++++++ .planning/phases/06-ux-polish/06-05-PLAN.md | 238 ++++++++++++++++++++ .planning/phases/06-ux-polish/06-06-PLAN.md | 234 +++++++++++++++++++ 7 files changed, 1086 insertions(+), 4 deletions(-) create mode 100644 .planning/phases/06-ux-polish/06-01-PLAN.md create mode 100644 .planning/phases/06-ux-polish/06-02-PLAN.md create mode 100644 .planning/phases/06-ux-polish/06-03-PLAN.md create mode 100644 .planning/phases/06-ux-polish/06-04-PLAN.md create mode 100644 .planning/phases/06-ux-polish/06-05-PLAN.md create mode 100644 .planning/phases/06-ux-polish/06-06-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 2a948ae..ed58d0c 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -220,7 +220,7 @@ Plans: **Goal**: Smooth the rough edges surfaced during live use — clearer all-day events, saner event-form date/recurrence behavior, recurring-series editing, and auth-flow polish — so the app feels slick for the non-technical Apple member (hard UX constraint). **Mode:** mvp **Depends on**: Phase 3 (calendar/event-form polish); Phase 4 for any list-related polish -**Requirements**: TBD +**Requirements**: none (all v1 REQ-IDs complete in Phases 1–5; this is a polish phase tracked against backlog items 999.2/3/6/7/8/9 and locked decisions D-01..D-13) **Success Criteria** (what must be TRUE): 1. All-day events are visually distinct from timed events at a glance @@ -229,9 +229,22 @@ Plans: 4. A session that expires mid-use redirects cleanly to sign-in instead of hanging on a generic error 5. Unauthenticated cold load shows a neutral "signing you in…" splash — no calendar/"sign-in required" flash before Authelia -**Candidate scope** (promote via `/gsd-review-backlog` at planning): 999.2 (login flash), 999.3 (session-timeout redirect), 999.6 (all-day visual), 999.7 (form end-tracking + all-day-edit off-by-one), 999.8 (recurrence bound), 999.9 (recurring-series edit). 999.4 (reminders) and 999.5 (provider setup) are more feature than polish — decide at planning. +**Scope** (promoted from backlog, locked at planning): 999.2 (login flash), 999.3 (session-timeout redirect), 999.6 (all-day visual), 999.7 (form end-tracking + all-day-edit off-by-one), 999.8 (recurrence bound), 999.9 (recurring-series edit). 999.4 (reminders) and 999.5 (provider setup) deferred to milestone 1.1 (D-01/D-02). + +**Plans**: 6 plans (2 waves) +Plans: +**Wave 1** *(parallel — exclusive file ownership)* + +- [ ] 06-01-PLAN.md — TDD: duration-preserving end-tracking math (computeNewTimedEnd/computeNewAllDayEnd) in eventDateTime.ts (D-04) +- [ ] 06-02-PLAN.md — TDD: RRULE UNTIL/COUNT serialization + Zod acceptance + FREQ-persistence regression (vevent/outboxWorker/events route) (D-06/D-07) +- [ ] 06-03-PLAN.md — TDD: hasRrule on CalendarOccurrence + bounded-expansion lock (expand.ts) (D-06/D-08) +- [ ] 06-04-PLAN.md — Spinner/pulse: global @keyframes pulse + remove redundant spin redefinition (D-13) +- [ ] 06-05-PLAN.md — Auth gating slice: SessionExpiredError + AuthSplash + global QueryCache/MutationCache error handler; client.ts type mirrors (D-10/D-11, + D-06/D-08 type carriers) + +**Wave 2** *(blocked on 06-01/02/03/05)* + +- [ ] 06-06-PLAN.md — EventForm integration slice: end-tracking wiring + recurrence-bound control + series-edit prompt + all-day pill (D-03/D-04/D-05/D-06/D-07/D-08/D-09/D-12) -**Plans**: TBD **UI hint**: yes ## Progress @@ -247,7 +260,7 @@ Note: Phase 4 depends only on Phase 1 and can begin as soon as Phase 1 is comple | 3. Event Write-Back + PWA Install | 12/12 | Complete | 2026-06-07 | | 4. Shared Lists + Live Sync | 6/6 | Complete | 2026-06-09 | | 5. Web Push Notifications | 8/8 | Complete | 2026-06-10 | -| 6. UX Polish | 0/? | Not started | - | +| 6. UX Polish | 0/6 | Planned | - | ## Backlog diff --git a/.planning/phases/06-ux-polish/06-01-PLAN.md b/.planning/phases/06-ux-polish/06-01-PLAN.md new file mode 100644 index 0000000..d9d9432 --- /dev/null +++ b/.planning/phases/06-ux-polish/06-01-PLAN.md @@ -0,0 +1,146 @@ +--- +phase: 06-ux-polish +plan: 01 +type: tdd +wave: 1 +depends_on: [] +files_modified: + - apps/pwa/src/lib/eventDateTime.ts + - apps/pwa/src/lib/eventDateTime.test.ts +autonomous: true +requirements: [] +must_haves: + truths: + - "On any start change, the event end preserves its current duration (D-04)" + - "The end never strands behind the start day — at worst it snaps to the same day/+1h (D-04 floor)" + artifacts: + - path: "apps/pwa/src/lib/eventDateTime.ts" + provides: "computeNewTimedEnd + computeNewAllDayEnd pure end-tracking helpers" + contains: "computeNewTimedEnd" + - path: "apps/pwa/src/lib/eventDateTime.test.ts" + provides: "RED-then-GREEN unit coverage for duration preservation + floor rule" + contains: "computeNewTimedEnd (D-04" + key_links: + - from: "apps/pwa/src/lib/eventDateTime.ts" + to: "apps/pwa/src/lib/eventDateTime.test.ts" + via: "vitest unit assertions" + pattern: "computeNewTimedEnd|computeNewAllDayEnd" +--- + + +Deliver the duration-preservation math for event end-tracking (D-04) as two pure, fully-tested functions in `apps/pwa/src/lib/eventDateTime.ts`. These are the load-bearing logic behind the 999.7 fix: when a user moves an event's start, the end must follow so the event keeps its duration instead of stranding behind the start and producing absurd multi-month spans. + +This plan ships the math ONLY (TDD: tests first). Wiring these helpers into the `EventForm` start `onChange` handlers happens in the EventForm integration slice (Plan 06), which depends on this plan's exported symbols. + +Purpose: End-tracking math is deterministic input→output logic — the canonical TDD candidate. Isolating it from the React component keeps the cycle fast and the behavior verifiable without rendering. +Output: `computeNewTimedEnd` and `computeNewAllDayEnd` exported from `eventDateTime.ts`, green under `pnpm --filter @familysync/pwa test`. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/06-ux-polish/06-RESEARCH.md +@.planning/phases/06-ux-polish/06-PATTERNS.md +@.planning/phases/06-ux-polish/06-UI-SPEC.md + + + +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. + + + + + + Task 1: RED — failing tests for computeNewTimedEnd / computeNewAllDayEnd + apps/pwa/src/lib/eventDateTime.test.ts + + - apps/pwa/src/lib/eventDateTime.test.ts — copy the existing `import { describe, it, expect } from 'vitest'` header and `describe/it/expect` structure (the existing `serializeEventDateTime` block is the exact analog) + - apps/pwa/src/lib/eventDateTime.ts — existing `serializeEventDateTime`, `localWallClockToUtcIso`, and `parseDateTime` local-accessor pattern (RESEARCH §Focus 4; PATTERNS §eventDateTime.ts) + - .planning/phases/06-ux-polish/06-RESEARCH.md §"End-tracking pure functions (TDD target)" — exact signatures + the delta/floor algorithm + - .planning/phases/06-ux-polish/06-UI-SPEC.md §"Surface 4" — the behavior contract (timed: newEnd = newStart + (oldEnd − oldStart); floor snaps to +1h timed / same-day all-day) + + + - computeNewTimedEnd preserves a 1-hour delta: old 09:00→10:00 on a day, new start moved forward → new end is exactly 1h after new start, same date when within the day. + - computeNewTimedEnd preserves a multi-day timed delta (e.g. 26h) when start moves. + - computeNewTimedEnd floor rule: when oldEnd <= oldStart (already-invalid stale state), new end snaps to newStart + 1h (never behind start). + - computeNewAllDayEnd preserves a 0-day span (single-day all-day event) → new inclusive end equals new start date. + - computeNewAllDayEnd preserves a 3-day span → new inclusive end is newStart + 3 days. + - computeNewAllDayEnd floor rule: when oldEnd < oldStart, new end snaps to new start (same day). + + + Add a new `describe('computeNewTimedEnd (D-04 — end-tracking)', ...)` block and a `describe('computeNewAllDayEnd (D-04 — all-day end-tracking)', ...)` block alongside the existing tests. Import the two not-yet-existing functions from `./eventDateTime.js`. Write the six cases listed in the behavior block, asserting on returned `endDate` ('YYYY-MM-DD') and `endTime` ('HH:MM') strings. Use concrete dates (e.g. start 2026-06-11) so assertions are exact. Run the suite and CONFIRM RED — the import resolves to undefined and tests fail with a missing-export / call-of-undefined error (NOT a syntax/import-path error). Per CLAUDE.md WR-05: assertions must reflect LOCAL wall-clock dates, never UTC-sliced dates. Commit: `test(06-01): add failing tests for end-tracking duration math`. + + + cd apps/pwa && pnpm test -- run lib/eventDateTime 2>&1 | grep -E "computeNewTimedEnd|computeNewAllDayEnd" && echo "RED block present" + + + - eventDateTime.test.ts contains both new `describe` blocks with the six named cases. + - Running the suite shows the new tests FAILING (RED) due to the missing exports, not due to an import-path typo. + - A `test(06-01): ...` commit exists. + + Six new failing tests describe duration preservation + floor behavior for timed and all-day; RED confirmed and committed. + + + + Task 2: GREEN — implement the two end-tracking helpers + apps/pwa/src/lib/eventDateTime.ts + + - apps/pwa/src/lib/eventDateTime.ts — `serializeEventDateTime` export style + `parseDateTime` (lines ~107–133) local-accessor pattern (getFullYear/getMonth/getDate/getHours/getMinutes) — WR-05 constraint + - .planning/phases/06-ux-polish/06-PATTERNS.md §"apps/pwa/src/lib/eventDateTime.ts" — the exact function bodies to mirror, including the 1h floor and `Math.max(0, dateDiffDays(...))` span + - .planning/phases/06-ux-polish/06-RESEARCH.md §"End-tracking pure functions" — algorithm and the `60 * 60 * 1000` floor constant + + + Implement `computeNewTimedEnd` and `computeNewAllDayEnd` exactly per the signatures in RESEARCH §"End-tracking pure functions (TDD target)". Timed: compute `oldStartMs`/`oldEndMs` via `new Date(\`${date}T${time}:00\`).getTime()`, set `deltaMs = oldEndMs > oldStartMs ? oldEndMs - oldStartMs : 60*60*1000` (the 1h floor), then `newEnd = new Date(newStartMs + deltaMs)` and return `{ endDate, endTime }` formatted through LOCAL accessors (do NOT use `toISOString().slice(0,10)` — that returns UTC date; this is the WR-05 trap called out in PATTERNS). All-day: `span = Math.max(0, dateDiffDays(oldStartDate, oldEndDate))`, return `addDaysISO(newStartDate, span)` — the `Math.max(0, …)` is the floor (a stale negative span collapses to same-day). If `dateDiffDays`/`addDaysISO`/`localDateISO`/`localTimeHHMM` helpers are not already in the file, add them as small private functions using local Date accessors. Run the suite to GREEN. Commit: `feat(06-01): implement duration-preserving end-tracking helpers`. + + + cd apps/pwa && pnpm test -- run lib/eventDateTime + + + - `computeNewTimedEnd` and `computeNewAllDayEnd` are exported from eventDateTime.ts. + - All six new tests pass; the pre-existing eventDateTime tests still pass (no regression). + - Date formatting uses local accessors only (grep: no `toISOString().slice` in the new helpers). + - A `feat(06-01): ...` commit exists after the `test(06-01): ...` commit (RED→GREEN order). + + Both helpers implemented; full eventDateTime suite green; RED→GREEN commit order present. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| (none new) | Pure client-side date arithmetic on already-trusted local form state. No network, no untrusted input, no persistence. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06-01 | Tampering | computeNewTimedEnd / computeNewAllDayEnd | accept | Pure functions over local strings; no trust boundary crossed. Output is re-validated downstream by the existing serialize/write path (vevent.ts WR-04). UI/logic only — no new attack surface. | + + + +- `cd apps/pwa && pnpm test -- run lib/eventDateTime` is green. +- Git log shows `test(06-01)` before `feat(06-01)`. +- No change to any file outside `eventDateTime.ts` / `eventDateTime.test.ts`. + + + +- 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). + + + +Create `.planning/phases/06-ux-polish/06-01-SUMMARY.md` when done (RED/GREEN/REFACTOR notes + commit list). + diff --git a/.planning/phases/06-ux-polish/06-02-PLAN.md b/.planning/phases/06-ux-polish/06-02-PLAN.md new file mode 100644 index 0000000..42e6f36 --- /dev/null +++ b/.planning/phases/06-ux-polish/06-02-PLAN.md @@ -0,0 +1,165 @@ +--- +phase: 06-ux-polish +plan: 02 +type: tdd +wave: 1 +depends_on: [] +files_modified: + - apps/api/src/broker/vevent.ts + - apps/api/src/broker/outboxWorker.ts + - apps/api/src/routes/events.ts + - apps/api/tests/broker/vevent.test.ts + - apps/api/tests/broker/outboxWorker.test.ts +autonomous: true +requirements: [] +must_haves: + truths: + - "Scope fence (D-01/D-02): recurrence bounding only — NO VALARM/reminder serialization (999.4) is added to the write path; reminders are deferred to milestone 1.1" + - "A recurring series can be bounded by a repeat-until date (RRULE UNTIL) or an occurrence count (RRULE COUNT) (D-06)" + - "All-day UNTIL serializes as a DATE (YYYYMMDD); timed UNTIL serializes as a UTC DATETIME (YYYYMMDDT235959Z) (D-06, RFC 5545 §3.3.10)" + - "A 'daily' frequency selection persists as FREQ=DAILY end-to-end through the outbox (D-07)" + artifacts: + - path: "apps/api/src/broker/outboxWorker.ts" + provides: "assembleRruleString helper + UNTIL/COUNT assembly wired into the write payload" + contains: "assembleRruleString" + - path: "apps/api/src/routes/events.ts" + provides: "eventFieldsSchema accepts recurrenceUntil + recurrenceCount" + contains: "recurrenceUntil" + - path: "apps/api/tests/broker/vevent.test.ts" + provides: "UNTIL-DATE, UNTIL-DATETIME, COUNT serialization assertions" + contains: "COUNT=5" + key_links: + - from: "apps/api/src/broker/outboxWorker.ts" + to: "apps/api/src/broker/vevent.ts" + via: "assembled rruleString passed to buildVeventString" + pattern: "assembleRruleString|rruleString" + - from: "apps/api/src/routes/events.ts" + to: "apps/api/src/broker/outboxWorker.ts" + via: "recurrenceUntil/recurrenceCount in enqueued payload" + pattern: "recurrenceUntil|recurrenceCount" +--- + + +Deliver the server-side recurrence-bounding write path (D-06) and the FREQ-persistence regression lock (D-07). This is the API half of 999.8: the event write path must serialize `RRULE UNTIL`/`COUNT` correctly (value-type-matched to DTSTART per RFC 5545), and a daily selection must round-trip as `FREQ=DAILY`. + +The PWA UI control that produces `recurrenceUntil`/`recurrenceCount` is built in Plan 06; the `CreateEventPayload` type fields that carry them are added in Plan 05 (sole owner of `client.ts`). This plan owns the API contract: Zod acceptance + ical.js serialization + tests. + +Purpose: RRULE serialization is deterministic ICS-string output — a TDD candidate. Per RESEARCH, adding UNTIL/COUNT does NOT affect per-occurrence duration (that bug is D-04's end-tracking, fixed in Plan 01), so this plan is self-contained on the API side. +Output: `assembleRruleString` in `outboxWorker.ts`, extended Zod schema in `events.ts`, green `vevent.test.ts` + `outboxWorker.test.ts`. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/06-ux-polish/06-RESEARCH.md +@.planning/phases/06-ux-polish/06-PATTERNS.md + + + +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) + + + + + + Task 1: RED — failing tests for UNTIL/COUNT serialization + FREQ-persistence regression + apps/api/tests/broker/vevent.test.ts, apps/api/tests/broker/outboxWorker.test.ts + + - apps/api/tests/broker/vevent.test.ts — existing `buildVeventString` describe block (the exact test structure to mirror; PATTERNS gives copy-ready cases) + - apps/api/tests/broker/outboxWorker.test.ts — existing outbox-worker test setup (mock DB rows, payload shape) to mirror for the FREQ regression + - apps/api/src/broker/vevent.ts — `RRULE_PRESETS` (lines 49–54) + the `ICAL.Recur.fromString` + `rruleProp.setValue` serialization path (lines ~143–148) + - .planning/phases/06-ux-polish/06-RESEARCH.md §"Focus 1" + §"Focus 2" + §"Code Examples" — verified ical.js 2.2.1 UNTIL/COUNT output strings; FREQ-persistence diagnosis + - .planning/phases/06-ux-polish/06-PATTERNS.md §"apps/api/tests/broker/vevent.test.ts" — three copy-ready test cases + + + - buildVeventString with rruleString 'FREQ=WEEKLY;COUNT=5' (timed) → ICS contains `RRULE:FREQ=WEEKLY;COUNT=5`. + - buildVeventString with rruleString 'FREQ=DAILY;UNTIL=20260630' (all-day, isDate) → ICS contains `RRULE:FREQ=DAILY;UNTIL=20260630` and does NOT contain `T235959Z`. + - buildVeventString with rruleString 'FREQ=WEEKLY;UNTIL=20260630T235959Z' (timed) → ICS contains `RRULE:FREQ=WEEKLY;UNTIL=20260630T235959Z`. + - assembleRruleString('FREQ=DAILY', undefined, 5, false) → 'FREQ=DAILY;COUNT=5'. + - assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, true) → 'FREQ=WEEKLY;UNTIL=20260630' (all-day DATE form). + - assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, false) → 'FREQ=WEEKLY;UNTIL=20260630T235959Z' (timed DATETIME form). + - assembleRruleString with BOTH until and count present → COUNT wins, UNTIL omitted (mutual exclusion, RFC 5545 §3.3.10). + - FREQ-persistence regression (D-07): an outbox payload with `recurrence:'daily'` and no bound assembles to an rruleString of exactly `FREQ=DAILY` (NOT weekly/none). + + + In `vevent.test.ts`, add the three serialization cases from PATTERNS (COUNT, UNTIL-DATE, UNTIL-DATETIME). In `outboxWorker.test.ts`, add a `describe('assembleRruleString (D-06)')` block importing the not-yet-exported helper, plus a `describe('FREQ persistence (D-07 regression)')` case asserting that a daily-recurrence payload yields `FREQ=DAILY`. Use ical.js output strings VERIFIED in RESEARCH (do not invent formats). Run both suites and CONFIRM RED (missing `assembleRruleString` export + unimplemented UNTIL/COUNT path). Commit: `test(06-02): add failing tests for RRULE UNTIL/COUNT + FREQ persistence`. + + + cd apps/api && pnpm test -- run broker/vevent broker/outboxWorker 2>&1 | grep -E "COUNT=5|assembleRruleString|FREQ persistence" && echo "RED present" + + + - New cases exist in both test files with the exact expected ICS/RRULE strings from RESEARCH. + - Suites show the new tests FAILING for the right reason (missing export / unimplemented path), not import errors. + - A `test(06-02): ...` commit exists. + + UNTIL/COUNT serialization + FREQ-persistence regression tests written and RED; committed. + + + + Task 2: GREEN — assembleRruleString + Zod schema acceptance, wired into the write path + apps/api/src/broker/outboxWorker.ts, apps/api/src/routes/events.ts, apps/api/src/broker/vevent.ts + + - apps/api/src/broker/outboxWorker.ts — `outboxPayloadSchema` (lines 71–83) and the existing RRULE assembly site (lines ~255–308: `hasExplicitRecurrence`, `RRULE_PRESETS[fields.recurrence]`, preservedRrule path) + - apps/api/src/routes/events.ts — `eventFieldsSchema` (lines 100–109, the `recurrence: z.enum(...)` field) — add the two optional fields here mirroring the `location`/`description` `.optional()` style + - apps/api/src/broker/vevent.ts — confirm the `ICAL.Recur.fromString(params.rruleString)` path (lines ~143–148) handles UNTIL/COUNT unchanged (RESEARCH: verified — no vevent.ts logic change needed beyond receiving the assembled string) + - .planning/phases/06-ux-polish/06-PATTERNS.md §"apps/api/src/broker/outboxWorker.ts" — the exact `assembleRruleString` body + the series-edit "strip existing UNTIL/COUNT then re-apply" branch + - .planning/phases/06-ux-polish/06-RESEARCH.md §"Pitfall 1/2/3" — value-type matching, T235959Z trade-off, parse-then-modify (do NOT blindly concatenate onto a rich preserved RRULE) + + + Add `recurrenceUntil: z.string().max(10).optional()` and `recurrenceCount: z.number().int().min(1).optional()` to BOTH `eventFieldsSchema` (events.ts) and `outboxPayloadSchema` (outboxWorker.ts), mirroring the existing `.optional()` field style. Implement `assembleRruleString(basePreset, until?, count?, allDay?)` per PATTERNS: COUNT takes precedence (`;COUNT=N`); else UNTIL → all-day emits `;UNTIL=${until.replace(/-/g,'')}` (DATE form `20260630`), timed emits `;UNTIL=${...}T235959Z` (DATETIME UTC). Wire it into the existing assembly site: when an explicit preset is present, assemble `preset + bound`; on series edit where only the bound changes (preserved RRULE present, no new preset), parse the preserved RRULE, STRIP any existing `;(UNTIL|COUNT)=...` via the regex in PATTERNS, then re-apply the new bound — never naive-concatenate onto `FREQ=WEEKLY;BYDAY=...`. Pass the assembled string to `buildVeventString` unchanged. Use the verified `T235959Z` end-of-UTC-day choice for timed UNTIL (RESEARCH A2). Run suites to GREEN. Commit: `feat(06-02): serialize RRULE UNTIL/COUNT and lock FREQ persistence`. + + + cd apps/api && pnpm test -- run broker/vevent broker/outboxWorker + + + - `assembleRruleString` is exported and produces the exact strings asserted in Task 1. + - `recurrenceUntil` + `recurrenceCount` are accepted by both Zod schemas (rejecting count < 1 and over-length until strings). + - All new tests pass; the full `vevent` + `outboxWorker` suites still pass (no regression to existing recurrence handling). + - Series-edit bound change strips-then-reapplies (a test or assertion shows `FREQ=WEEKLY;BYDAY=MO` + new UNTIL does not produce a double-UNTIL). + - `feat(06-02): ...` commit follows the `test(06-02): ...` commit (RED→GREEN). + + UNTIL/COUNT serialize value-type-matched; daily persists as FREQ=DAILY; schemas accept the new fields; suites green; RED→GREEN order present. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| client → API (POST/PATCH /api/events) | `recurrenceUntil` / `recurrenceCount` are new untrusted inputs crossing into the write path and ultimately into an ICS RRULE string sent to Fastmail CalDAV. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06-02 | Tampering | recurrenceUntil/recurrenceCount → RRULE string (events.ts, outboxWorker.ts) | mitigate | Zod `z.string().max(10)` on `recurrenceUntil` + `z.number().int().min(1)` on `recurrenceCount` at the route boundary; `assembleRruleString` only emits digits from a `replace(/-/g,'')` of a length-bounded string; final string is re-parsed by `ICAL.Recur.fromString` which rejects malformed RRULE — no raw passthrough to the ICS. (V5 Input Validation, ASVS L1.) | +| T-06-02b | Tampering | RRULE injection via crafted until value | mitigate | The `.replace(/-/g,'')` plus the fixed `;UNTIL=`/`;COUNT=` templates prevent injecting extra `;`-delimited RRULE parts; `ICAL.Recur.fromString` sanitizes via parse. A date that is not `YYYY-MM-DD` produces a non-date string that ical.js rejects or normalizes — fails closed (event enqueue errors), no silent corruption. | +| T-06-02-SC | Tampering | npm installs | accept | No package installs in this plan (RESEARCH: Package Legitimacy Audit not applicable — zero new deps). | + + + +- `cd apps/api && pnpm test -- run broker/vevent broker/outboxWorker` green. +- `grep -n "recurrenceUntil" apps/api/src/routes/events.ts apps/api/src/broker/outboxWorker.ts` shows the field in both schemas. +- Git log: `test(06-02)` precedes `feat(06-02)`. + + + +- 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. + + + +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). + diff --git a/.planning/phases/06-ux-polish/06-03-PLAN.md b/.planning/phases/06-ux-polish/06-03-PLAN.md new file mode 100644 index 0000000..46b7f34 --- /dev/null +++ b/.planning/phases/06-ux-polish/06-03-PLAN.md @@ -0,0 +1,143 @@ +--- +phase: 06-ux-polish +plan: 03 +type: tdd +wave: 1 +depends_on: [] +files_modified: + - apps/api/src/broker/expand.ts + - apps/api/tests/broker/expand.test.ts +autonomous: true +requirements: [] +must_haves: + truths: + - "Each expanded occurrence exposes hasRrule, true for occurrences of a recurring series and false otherwise (D-08)" + - "A bounded RRULE (e.g. COUNT=3) expands to exactly the bounded number of occurrences within a wide window, each with start→end duration (D-06 verify)" + artifacts: + - path: "apps/api/src/broker/expand.ts" + provides: "hasRrule:boolean field on CalendarOccurrence, populated from event.isRecurring()" + contains: "hasRrule" + - path: "apps/api/tests/broker/expand.test.ts" + provides: "hasRrule true/false assertions + bounded-RRULE occurrence-count assertion" + contains: "hasRrule" + key_links: + - from: "apps/api/src/broker/expand.ts" + to: "apps/pwa/src/api/client.ts (mirror, added in Plan 05)" + via: "CalendarOccurrence.hasRrule is the source-of-truth field the client mirrors" + pattern: "hasRrule" +--- + + +Expose `hasRrule` on the server-side `CalendarOccurrence` so the PWA can detect "this occurrence belongs to a recurring series" — the signal that gates the whole-series-edit confirmation prompt (D-08/D-09). Today the field is absent from the type on both sides, so the PWA cannot tell a recurring occurrence from a single event. + +This plan owns the SERVER source-of-truth (`expand.ts`). The PWA mirror field on `client.ts`'s `CalendarOccurrence` is added in Plan 05 (sole owner of `client.ts`); the prompt UI that consumes it is built in Plan 06. Per PATTERNS, both interfaces are hand-mirrored — `expand.ts` is authoritative. + +This plan also locks D-06's expansion invariant: a bounded RRULE terminates at COUNT/UNTIL and each occurrence's duration derives from DTSTART→DTEND (not the recurrence span) — RESEARCH verified no `expand.ts` logic change is needed for bounding, so this is an assertion to prevent regression. + +Purpose: `hasRrule` population is deterministic transform logic over a parsed VEVENT — a TDD candidate. Isolated from the PWA, it is verifiable purely against `expandOccurrences`. +Output: `hasRrule` on `CalendarOccurrence` + population in `expandOccurrences`, green `expand.test.ts`. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/06-ux-polish/06-RESEARCH.md +@.planning/phases/06-ux-polish/06-PATTERNS.md + + + +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) + + + + + + Task 1: RED — failing tests for hasRrule population + bounded expansion count + apps/api/tests/broker/expand.test.ts + + - apps/api/tests/broker/expand.test.ts — existing `expandOccurrences` tests + ICS fixtures (mirror the fixture-injection style for a recurring vs non-recurring VEVENT) + - apps/api/src/broker/expand.ts — `CalendarOccurrence` interface (lines 37–67), the two `occurrences.push({...})` sites (non-recurring ~241–256, recurring ~287–302), and `event.isRecurring()` usage (~line 223) + - .planning/phases/06-ux-polish/06-RESEARCH.md §"Focus 3 — hasRrule" + §"Focus 1" (bounded expansion verified: COUNT=3 → 3 occurrences, complete=true) + - .planning/phases/06-ux-polish/06-PATTERNS.md §"apps/api/tests/broker/expand.test.ts" — copy-ready assertion patterns + + + - expandOccurrences on a recurring VEVENT (has RRULE) → every returned occurrence has `hasRrule === true`. + - expandOccurrences on a non-recurring VEVENT (no RRULE) → the single occurrence has `hasRrule === false`. + - expandOccurrences on a VEVENT with `FREQ=WEEKLY;COUNT=3` over a wide window → exactly 3 occurrences, and each occurrence's (end − start) equals the DTSTART→DTEND duration (not the recurrence span). + + + Add assertions to (or alongside) the existing recurring-expansion test: assert `occs[0].hasRrule === true` for a recurring fixture and `occs[0].hasRrule === false` for a non-recurring fixture. Add a `FREQ=WEEKLY;COUNT=3` fixture and assert `occs.length === 3` within a multi-month window plus a per-occurrence duration assertion. Run the suite and CONFIRM RED (the `hasRrule` property is absent → TypeScript/runtime undefined on the assertion). Commit: `test(06-03): add failing tests for hasRrule + bounded expansion`. + + + cd apps/api && pnpm test -- run broker/expand 2>&1 | grep -E "hasRrule|COUNT" && echo "RED present" + + + - expand.test.ts asserts hasRrule true (recurring) and false (non-recurring), plus the bounded-count case. + - Tests FAIL because `hasRrule` is undefined (not because of fixture/import errors). + - `test(06-03): ...` commit exists. + + hasRrule + bounded-expansion tests written and RED; committed. + + + + Task 2: GREEN — add hasRrule to CalendarOccurrence and populate it + apps/api/src/broker/expand.ts + + - apps/api/src/broker/expand.ts — `CalendarOccurrence` interface (37–67) and both `occurrences.push({...})` sites; `event.isRecurring()` already called near line 223 + - .planning/phases/06-ux-polish/06-PATTERNS.md §"apps/api/src/broker/expand.ts" — capture `const isRecurring = event.isRecurring()` once before the branch; pass `hasRrule: isRecurring` into both push sites + - .planning/phases/06-ux-polish/06-RESEARCH.md §"Pitfall 4" — update server type here; the client mirror is Plan 05's job (do NOT touch client.ts) + + + Add `hasRrule: boolean` to the `CalendarOccurrence` interface in `expand.ts`. In `expandOccurrences`, capture `const isRecurring = event.isRecurring()` once before the recurring/non-recurring branch, then add `hasRrule: isRecurring` to each `occurrences.push({...})` (it is `false` in the non-recurring branch, `true` in the recurring branch — using the single captured value keeps them consistent). Do NOT change the DB query or `client.ts` (the client mirror is added atomically in Plan 05). Run the suite to GREEN. Commit: `feat(06-03): expose hasRrule on expanded occurrences`. + + + cd apps/api && pnpm test -- run broker/expand + + + - `CalendarOccurrence` in expand.ts includes `hasRrule: boolean`. + - Both push sites set `hasRrule` from the single `isRecurring` capture. + - All new + existing expand tests pass; bounded `COUNT=3` fixture yields exactly 3 occurrences. + - `feat(06-03): ...` commit follows the `test(06-03): ...` commit (RED→GREEN). + + hasRrule populated; bounded expansion verified; expand suite green; RED→GREEN order present. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| (none new) | Read-side transform of already-cached, already-trusted calendar data. No new input crosses a boundary; `hasRrule` is derived from a parsed VEVENT the server already holds. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06-03 | Information Disclosure | hasRrule on CalendarOccurrence | accept | `hasRrule` is a boolean derived from data already returned to the authenticated, access-scoped caller (existing `/api/events` ownership filter unchanged). It reveals no new information beyond "this event recurs", which is already visible from rendered occurrences. No new trust boundary. | + + + +- `cd apps/api && pnpm test -- run broker/expand` green. +- `grep -n "hasRrule" apps/api/src/broker/expand.ts` shows the field on the interface and both push sites. +- `client.ts` untouched by this plan (ownership belongs to Plan 05). + + + +- 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. + + + +Create `.planning/phases/06-ux-polish/06-03-SUMMARY.md` when done (RED/GREEN notes + commits). + diff --git a/.planning/phases/06-ux-polish/06-04-PLAN.md b/.planning/phases/06-ux-polish/06-04-PLAN.md new file mode 100644 index 0000000..1a33157 --- /dev/null +++ b/.planning/phases/06-ux-polish/06-04-PLAN.md @@ -0,0 +1,143 @@ +--- +phase: 06-ux-polish +plan: 04 +type: execute +wave: 1 +depends_on: [] +files_modified: + - apps/pwa/src/styles/tokens.css + - apps/pwa/src/components/PushPermissionPrompt.tsx +autonomous: false +requirements: [] +must_haves: + truths: + - "Sync indicators actually animate — the SyncStateToast spinner spins and the LiveSyncIndicator reconnecting dot pulses (D-13)" + - "@keyframes pulse exists globally in tokens.css so LiveSyncIndicator's reconnecting dot animates regardless of which components are mounted (D-13)" + artifacts: + - path: "apps/pwa/src/styles/tokens.css" + provides: "global @keyframes pulse (added) alongside the existing @keyframes spin" + contains: "@keyframes pulse" + - path: "apps/pwa/src/components/PushPermissionPrompt.tsx" + provides: "redundant local @keyframes spin ` block (lines ~357–363) to delete; the inline `animation: 'spin 1s linear infinite'` stays + - apps/pwa/src/components/LiveSyncIndicator.tsx — `animation: 'pulse 1.4s ease-in-out infinite'` (~line 69) — the consumer that needs the new keyframe + - apps/pwa/src/components/SyncStateToast.tsx — `animation: 'spin 1s linear infinite'` (~line 158) — already-working consumer, confirm unchanged + - .planning/phases/06-ux-polish/06-RESEARCH.md §"Focus 5" (the corrected diagnosis) + .planning/phases/06-ux-polish/06-UI-SPEC.md §"Animation Contract" (exact pulse keyframe) + + + In `tokens.css`, add a `@keyframes pulse` block (0%,100% opacity 1; 50% opacity 0.4) directly after the existing `@keyframes spin` block, matching the surrounding format. In `PushPermissionPrompt.tsx`, delete ONLY the redundant local `