---
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'
---

<objective>
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`.
</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
@.planning/phases/06-ux-polish/06-UI-SPEC.md
</context>

<artifacts_this_plan_produces>
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.
  </artifacts_this_plan_produces>

<tasks>

<task type="tdd" tdd="true">
  <name>Task 1: RED — failing tests for computeNewTimedEnd / computeNewAllDayEnd</name>
  <files>apps/pwa/src/lib/eventDateTime.test.ts</files>
  <read_first>
    - 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)
  </read_first>
  <behavior>
    - 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).
  </behavior>
  <action>
    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`.
  </action>
  <verify>
    <automated>cd apps/pwa && pnpm test -- run lib/eventDateTime 2>&1 | grep -E "computeNewTimedEnd|computeNewAllDayEnd" && echo "RED block present"</automated>
  </verify>
  <acceptance_criteria>
    - 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.
  </acceptance_criteria>
  <done>Six new failing tests describe duration preservation + floor behavior for timed and all-day; RED confirmed and committed.</done>
</task>

<task type="tdd" tdd="true">
  <name>Task 2: GREEN — implement the two end-tracking helpers</name>
  <files>apps/pwa/src/lib/eventDateTime.ts</files>
  <read_first>
    - 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
  </read_first>
  <action>
    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`.
  </action>
  <verify>
    <automated>cd apps/pwa && pnpm test -- run lib/eventDateTime</automated>
  </verify>
  <acceptance_criteria>
    - `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).
  </acceptance_criteria>
  <done>Both helpers implemented; full eventDateTime suite green; RED→GREEN commit order present.</done>
</task>

</tasks>

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

<verification>
- `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`.
</verification>

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

<output>
Create `.planning/phases/06-ux-polish/06-01-SUMMARY.md` when done (RED/GREEN/REFACTOR notes + commit list).
</output>
