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

12 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
06-ux-polish 02 tdd 1
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
true
truths artifacts key_links
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)
path provides contains
apps/api/src/broker/outboxWorker.ts assembleRruleString helper + UNTIL/COUNT assembly wired into the write payload assembleRruleString
path provides contains
apps/api/src/routes/events.ts eventFieldsSchema accepts recurrenceUntil + recurrenceCount recurrenceUntil
path provides contains
apps/api/tests/broker/vevent.test.ts UNTIL-DATE, UNTIL-DATETIME, COUNT serialization assertions COUNT=5
from to via pattern
apps/api/src/broker/outboxWorker.ts apps/api/src/broker/vevent.ts assembled rruleString passed to buildVeventString assembleRruleString|rruleString
from to via pattern
apps/api/src/routes/events.ts apps/api/src/broker/outboxWorker.ts recurrenceUntil/recurrenceCount in enqueued payload 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.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_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

<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>
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 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 - 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 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) 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.

<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>
- `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)`.

<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>
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).