Files
familysync/.planning/phases/06-ux-polish/06-02-PLAN.md
T

166 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 06-ux-polish
plan: 02
type: tdd
wave: 1
depends_on: []
files_modified:
- apps/api/src/broker/vevent.ts
- apps/api/src/broker/outboxWorker.ts
- apps/api/src/routes/events.ts
- apps/api/tests/broker/vevent.test.ts
- apps/api/tests/broker/outboxWorker.test.ts
autonomous: true
requirements: []
must_haves:
truths:
- "Scope fence (D-01/D-02): recurrence bounding only — NO VALARM/reminder serialization (999.4) is added to the write path; reminders are deferred to milestone 1.1"
- "A recurring series can be bounded by a repeat-until date (RRULE UNTIL) or an occurrence count (RRULE COUNT) (D-06)"
- "All-day UNTIL serializes as a DATE (YYYYMMDD); timed UNTIL serializes as a UTC DATETIME (YYYYMMDDT235959Z) (D-06, RFC 5545 §3.3.10)"
- "A 'daily' frequency selection persists as FREQ=DAILY end-to-end through the outbox (D-07)"
artifacts:
- path: "apps/api/src/broker/outboxWorker.ts"
provides: "assembleRruleString helper + UNTIL/COUNT assembly wired into the write payload"
contains: "assembleRruleString"
- path: "apps/api/src/routes/events.ts"
provides: "eventFieldsSchema accepts recurrenceUntil + recurrenceCount"
contains: "recurrenceUntil"
- path: "apps/api/tests/broker/vevent.test.ts"
provides: "UNTIL-DATE, UNTIL-DATETIME, COUNT serialization assertions"
contains: "COUNT=5"
key_links:
- from: "apps/api/src/broker/outboxWorker.ts"
to: "apps/api/src/broker/vevent.ts"
via: "assembled rruleString passed to buildVeventString"
pattern: "assembleRruleString|rruleString"
- from: "apps/api/src/routes/events.ts"
to: "apps/api/src/broker/outboxWorker.ts"
via: "recurrenceUntil/recurrenceCount in enqueued payload"
pattern: "recurrenceUntil|recurrenceCount"
---
<objective>
Deliver the server-side recurrence-bounding write path (D-06) and the FREQ-persistence regression lock (D-07). This is the API half of 999.8: the event write path must serialize `RRULE UNTIL`/`COUNT` correctly (value-type-matched to DTSTART per RFC 5545), and a daily selection must round-trip as `FREQ=DAILY`.
The PWA UI control that produces `recurrenceUntil`/`recurrenceCount` is built in Plan 06; the `CreateEventPayload` type fields that carry them are added in Plan 05 (sole owner of `client.ts`). This plan owns the API contract: Zod acceptance + ical.js serialization + tests.
Purpose: RRULE serialization is deterministic ICS-string output — a TDD candidate. Per RESEARCH, adding UNTIL/COUNT does NOT affect per-occurrence duration (that bug is D-04's end-tracking, fixed in Plan 01), so this plan is self-contained on the API side.
Output: `assembleRruleString` in `outboxWorker.ts`, extended Zod schema in `events.ts`, green `vevent.test.ts` + `outboxWorker.test.ts`.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-ux-polish/06-RESEARCH.md
@.planning/phases/06-ux-polish/06-PATTERNS.md
</context>
<artifacts_this_plan_produces>
NEW symbols introduced here (exclude from drift/convergence checks):
- `assembleRruleString(basePreset, until?, count?, allDay?): string` in `apps/api/src/broker/outboxWorker.ts`
- Two new optional fields on `eventFieldsSchema` (`events.ts`) and `outboxPayloadSchema` (`outboxWorker.ts`): `recurrenceUntil` ('YYYY-MM-DD'), `recurrenceCount` (int ≥ 1)
- New test cases in `vevent.test.ts` (COUNT, UNTIL DATE, UNTIL DATETIME) and `outboxWorker.test.ts` (FREQ-persistence regression, bound-assembly)
</artifacts_this_plan_produces>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: RED — failing tests for UNTIL/COUNT serialization + FREQ-persistence regression</name>
<files>apps/api/tests/broker/vevent.test.ts, apps/api/tests/broker/outboxWorker.test.ts</files>
<read_first>
- apps/api/tests/broker/vevent.test.ts — existing `buildVeventString` describe block (the exact test structure to mirror; PATTERNS gives copy-ready cases)
- apps/api/tests/broker/outboxWorker.test.ts — existing outbox-worker test setup (mock DB rows, payload shape) to mirror for the FREQ regression
- apps/api/src/broker/vevent.ts — `RRULE_PRESETS` (lines 4954) + the `ICAL.Recur.fromString` + `rruleProp.setValue` serialization path (lines ~143148)
- .planning/phases/06-ux-polish/06-RESEARCH.md §"Focus 1" + §"Focus 2" + §"Code Examples" — verified ical.js 2.2.1 UNTIL/COUNT output strings; FREQ-persistence diagnosis
- .planning/phases/06-ux-polish/06-PATTERNS.md §"apps/api/tests/broker/vevent.test.ts" — three copy-ready test cases
</read_first>
<behavior>
- buildVeventString with rruleString 'FREQ=WEEKLY;COUNT=5' (timed) → ICS contains `RRULE:FREQ=WEEKLY;COUNT=5`.
- buildVeventString with rruleString 'FREQ=DAILY;UNTIL=20260630' (all-day, isDate) → ICS contains `RRULE:FREQ=DAILY;UNTIL=20260630` and does NOT contain `T235959Z`.
- buildVeventString with rruleString 'FREQ=WEEKLY;UNTIL=20260630T235959Z' (timed) → ICS contains `RRULE:FREQ=WEEKLY;UNTIL=20260630T235959Z`.
- assembleRruleString('FREQ=DAILY', undefined, 5, false) → 'FREQ=DAILY;COUNT=5'.
- assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, true) → 'FREQ=WEEKLY;UNTIL=20260630' (all-day DATE form).
- assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, false) → 'FREQ=WEEKLY;UNTIL=20260630T235959Z' (timed DATETIME form).
- assembleRruleString with BOTH until and count present → COUNT wins, UNTIL omitted (mutual exclusion, RFC 5545 §3.3.10).
- FREQ-persistence regression (D-07): an outbox payload with `recurrence:'daily'` and no bound assembles to an rruleString of exactly `FREQ=DAILY` (NOT weekly/none).
</behavior>
<action>
In `vevent.test.ts`, add the three serialization cases from PATTERNS (COUNT, UNTIL-DATE, UNTIL-DATETIME). In `outboxWorker.test.ts`, add a `describe('assembleRruleString (D-06)')` block importing the not-yet-exported helper, plus a `describe('FREQ persistence (D-07 regression)')` case asserting that a daily-recurrence payload yields `FREQ=DAILY`. Use ical.js output strings VERIFIED in RESEARCH (do not invent formats). Run both suites and CONFIRM RED (missing `assembleRruleString` export + unimplemented UNTIL/COUNT path). Commit: `test(06-02): add failing tests for RRULE UNTIL/COUNT + FREQ persistence`.
</action>
<verify>
<automated>cd apps/api && pnpm test -- run broker/vevent broker/outboxWorker 2>&1 | grep -E "COUNT=5|assembleRruleString|FREQ persistence" && echo "RED present"</automated>
</verify>
<acceptance_criteria>
- New cases exist in both test files with the exact expected ICS/RRULE strings from RESEARCH.
- Suites show the new tests FAILING for the right reason (missing export / unimplemented path), not import errors.
- A `test(06-02): ...` commit exists.
</acceptance_criteria>
<done>UNTIL/COUNT serialization + FREQ-persistence regression tests written and RED; committed.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: GREEN — assembleRruleString + Zod schema acceptance, wired into the write path</name>
<files>apps/api/src/broker/outboxWorker.ts, apps/api/src/routes/events.ts, apps/api/src/broker/vevent.ts</files>
<read_first>
- apps/api/src/broker/outboxWorker.ts — `outboxPayloadSchema` (lines 7183) and the existing RRULE assembly site (lines ~255308: `hasExplicitRecurrence`, `RRULE_PRESETS[fields.recurrence]`, preservedRrule path)
- apps/api/src/routes/events.ts — `eventFieldsSchema` (lines 100109, the `recurrence: z.enum(...)` field) — add the two optional fields here mirroring the `location`/`description` `.optional()` style
- apps/api/src/broker/vevent.ts — confirm the `ICAL.Recur.fromString(params.rruleString)` path (lines ~143148) handles UNTIL/COUNT unchanged (RESEARCH: verified — no vevent.ts logic change needed beyond receiving the assembled string)
- .planning/phases/06-ux-polish/06-PATTERNS.md §"apps/api/src/broker/outboxWorker.ts" — the exact `assembleRruleString` body + the series-edit "strip existing UNTIL/COUNT then re-apply" branch
- .planning/phases/06-ux-polish/06-RESEARCH.md §"Pitfall 1/2/3" — value-type matching, T235959Z trade-off, parse-then-modify (do NOT blindly concatenate onto a rich preserved RRULE)
</read_first>
<action>
Add `recurrenceUntil: z.string().max(10).optional()` and `recurrenceCount: z.number().int().min(1).optional()` to BOTH `eventFieldsSchema` (events.ts) and `outboxPayloadSchema` (outboxWorker.ts), mirroring the existing `.optional()` field style. Implement `assembleRruleString(basePreset, until?, count?, allDay?)` per PATTERNS: COUNT takes precedence (`;COUNT=N`); else UNTIL → all-day emits `;UNTIL=${until.replace(/-/g,'')}` (DATE form `20260630`), timed emits `;UNTIL=${...}T235959Z` (DATETIME UTC). Wire it into the existing assembly site: when an explicit preset is present, assemble `preset + bound`; on series edit where only the bound changes (preserved RRULE present, no new preset), parse the preserved RRULE, STRIP any existing `;(UNTIL|COUNT)=...` via the regex in PATTERNS, then re-apply the new bound — never naive-concatenate onto `FREQ=WEEKLY;BYDAY=...`. Pass the assembled string to `buildVeventString` unchanged. Use the verified `T235959Z` end-of-UTC-day choice for timed UNTIL (RESEARCH A2). Run suites to GREEN. Commit: `feat(06-02): serialize RRULE UNTIL/COUNT and lock FREQ persistence`.
</action>
<verify>
<automated>cd apps/api && pnpm test -- run broker/vevent broker/outboxWorker</automated>
</verify>
<acceptance_criteria>
- `assembleRruleString` is exported and produces the exact strings asserted in Task 1.
- `recurrenceUntil` + `recurrenceCount` are accepted by both Zod schemas (rejecting count < 1 and over-length until strings).
- All new tests pass; the full `vevent` + `outboxWorker` suites still pass (no regression to existing recurrence handling).
- Series-edit bound change strips-then-reapplies (a test or assertion shows `FREQ=WEEKLY;BYDAY=MO` + new UNTIL does not produce a double-UNTIL).
- `feat(06-02): ...` commit follows the `test(06-02): ...` commit (RED→GREEN).
</acceptance_criteria>
<done>UNTIL/COUNT serialize value-type-matched; daily persists as FREQ=DAILY; schemas accept the new fields; suites green; RED→GREEN order present.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → API (POST/PATCH /api/events) | `recurrenceUntil` / `recurrenceCount` are new untrusted inputs crossing into the write path and ultimately into an ICS RRULE string sent to Fastmail CalDAV. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-02 | Tampering | recurrenceUntil/recurrenceCount → RRULE string (events.ts, outboxWorker.ts) | mitigate | Zod `z.string().max(10)` on `recurrenceUntil` + `z.number().int().min(1)` on `recurrenceCount` at the route boundary; `assembleRruleString` only emits digits from a `replace(/-/g,'')` of a length-bounded string; final string is re-parsed by `ICAL.Recur.fromString` which rejects malformed RRULE — no raw passthrough to the ICS. (V5 Input Validation, ASVS L1.) |
| T-06-02b | Tampering | RRULE injection via crafted until value | mitigate | The `.replace(/-/g,'')` plus the fixed `;UNTIL=`/`;COUNT=` templates prevent injecting extra `;`-delimited RRULE parts; `ICAL.Recur.fromString` sanitizes via parse. A date that is not `YYYY-MM-DD` produces a non-date string that ical.js rejects or normalizes — fails closed (event enqueue errors), no silent corruption. |
| T-06-02-SC | Tampering | npm installs | accept | No package installs in this plan (RESEARCH: Package Legitimacy Audit not applicable — zero new deps). |
</threat_model>
<verification>
- `cd apps/api && pnpm test -- run broker/vevent broker/outboxWorker` green.
- `grep -n "recurrenceUntil" apps/api/src/routes/events.ts apps/api/src/broker/outboxWorker.ts` shows the field in both schemas.
- Git log: `test(06-02)` precedes `feat(06-02)`.
</verification>
<success_criteria>
- D-06: bounded recurrence serializes correctly, value-type-matched to DTSTART.
- D-07: daily→FREQ=DAILY regression is locked by an automated test.
- API contract (`recurrenceUntil`/`recurrenceCount`) is live for Plan 06's UI to drive.
</success_criteria>
<output>
Create `.planning/phases/06-ux-polish/06-02-SUMMARY.md` when done (RED/GREEN notes + commits; note any Fastmail UNTIL value-type observation for the verify step).
</output>