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)
path
provides
contains
apps/pwa/src/lib/eventDateTime.ts
computeNewTimedEnd + computeNewAllDayEnd pure end-tracking helpers
computeNewTimedEnd
path
provides
contains
apps/pwa/src/lib/eventDateTime.test.ts
RED-then-GREEN unit coverage for duration preservation + floor rule
computeNewTimedEnd (D-04
from
to
via
pattern
apps/pwa/src/lib/eventDateTime.ts
apps/pwa/src/lib/eventDateTime.test.ts
vitest unit assertions
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.
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>
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.
<threat_model>
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.
</threat_model>
- `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`.
<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>
Create `.planning/phases/06-ux-polish/06-01-SUMMARY.md` when done (RED/GREEN/REFACTOR notes + commit list).