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