---
phase: 06-ux-polish
plan: 06
type: execute
wave: 2
depends_on: ["06-01", "06-02", "06-03", "06-05"]
files_modified:
- apps/pwa/src/components/EventForm.tsx
- apps/pwa/src/components/SeriesEditPrompt.tsx
- apps/pwa/src/styles/index.css
autonomous: false
requirements: []
must_haves:
truths:
- "Scope fence (D-01/D-02): this phase delivers only the six promoted polish items (999.2/3/6/7/8/9); 999.4 reminders/VALARM, 999.5 provider setup, and 999.1 provider abstraction are NOT built here (deferred to milestone 1.1)"
- "Moving an event's start moves its end with it, preserving duration; the end never strands behind the start (D-03/D-04, success criterion 2)"
- "A recurring event can be bounded in the form via 'Ends: Never / On date / After N times' (D-06, success criterion 2)"
- "Editing a recurring occurrence prompts 'Edit recurring series' before saving the whole-series change (D-08/D-09, success criterion 3)"
- "The all-day-edit off-by-one stays fixed — re-editing an all-day event does not grow it by a day (D-05 verify, success criterion 2)"
- "All-day events are visually distinct from timed events at a glance (999.6/D-12, success criterion 1)"
artifacts:
- path: "apps/pwa/src/components/EventForm.tsx"
provides: "start onChange handlers that call computeNewTimedEnd/computeNewAllDayEnd; recurrence-bound control; hasRrule-gated series-edit confirmation"
contains: "computeNewTimedEnd"
- path: "apps/pwa/src/components/SeriesEditPrompt.tsx"
provides: "whole-series edit confirmation sheet/dialog (focus trap, Escape=cancel)"
contains: "Update series"
- path: "apps/pwa/src/styles/index.css"
provides: "Schedule-X all-day chip override (full-width filled pill)"
contains: "sx__all-day-event"
key_links:
- from: "apps/pwa/src/components/EventForm.tsx"
to: "apps/pwa/src/lib/eventDateTime.ts"
via: "start onChange → computeNewTimedEnd / computeNewAllDayEnd"
pattern: "computeNewTimedEnd|computeNewAllDayEnd"
- from: "apps/pwa/src/components/EventForm.tsx"
to: "apps/pwa/src/api/client.ts"
via: "payload carries recurrenceUntil/recurrenceCount; occurrence.hasRrule gates the prompt"
pattern: "recurrenceUntil|recurrenceCount|hasRrule"
---
Wire the prepared logic into the event form so the user-facing 999.7/999.8/999.9 fixes are live, and give all-day events their distinct look (999.6). After this plan a real user can: move a start and watch the end follow (D-04), bound a recurring series with "Ends: On date / After N times" (D-06), edit a recurring series behind a confirming prompt (D-08/D-09), and tell all-day from timed events at a glance (999.6) — with the all-day-edit off-by-one staying fixed (D-05).
This is the Wave-2 integration slice. It consumes (does NOT redefine) artifacts from earlier plans: `computeNewTimedEnd`/`computeNewAllDayEnd` (Plan 01), the `recurrenceUntil`/`recurrenceCount` payload fields + `hasRrule` on `CalendarOccurrence` (Plan 05 client types, backed by Plan 02 API contract + Plan 03 server expansion).
Purpose: This is glue + UI (form wiring, a confirmation component, a CSS override) — standard tasks, verified with playwright-cli per CLAUDE.md. The deterministic math/serialization it depends on is already unit-tested in Plans 01–03.
Output: EventForm end-tracking handlers + recurrence-bound control + series-edit prompt trigger; `SeriesEditPrompt.tsx`; all-day Schedule-X override in `index.css`.
@$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
@apps/pwa/src/components/DeleteConfirmationDialog.tsx
@.planning/phases/06-ux-polish/06-01-SUMMARY.md
@.planning/phases/06-ux-polish/06-05-SUMMARY.md
NEW symbols introduced here (exclude from drift/convergence checks):
- Start `onChange` handlers in `EventForm.tsx` that call `computeNewTimedEnd`/`computeNewAllDayEnd`
- `recurrenceBound: 'never'|'until'|'count'`, `recurrenceUntil: string`, `recurrenceCount: number` state + the "Ends" control in `EventForm.tsx`
- `SeriesEditPrompt` component (`apps/pwa/src/components/SeriesEditPrompt.tsx`)
- `.sx__all-day-event` CSS override block in `apps/pwa/src/styles/index.css`
NOTE: `computeNewTimedEnd`, `computeNewAllDayEnd`, `recurrenceUntil`/`recurrenceCount` payload fields, and `hasRrule` are NOT new here — they are consumed from Plans 01/05.
Task 1: End-tracking wiring + recurrence-bound control in EventForm (D-04, D-06, D-07, D-05 verify)apps/pwa/src/components/EventForm.tsx, apps/pwa/src/components/EventForm.test.tsx
- apps/pwa/src/components/EventForm.tsx — start date input `onChange` (~line 671) and start time input `onChange` (~line 684); state block (199–210); the reset `useEffect` (~232–262); the submit handler payload construction (~355–370, the `...(isEdit ? {} : { recurrence })` pattern); the recurrence `
- Changing startDate (timed) updates endDate/endTime so the duration is preserved (delegates to computeNewTimedEnd); never lands end before start.
- Changing startDate (all-day) updates endDate preserving the day-span (computeNewAllDayEnd).
- Changing startTime (timed) recomputes end preserving the delta.
- Selecting recurrence ≠ "None" reveals the "Ends" control; "On date" reveals a date input, "After N times" reveals a number input (min 1); "Never" sends neither bound field.
- Submitting with bound="until" sends `recurrenceUntil`; bound="count" sends `recurrenceCount`; neither when recurrence==='none' or bound==='never'.
- Inline validation: count < 1 → "Must be at least 1 occurrence"; until before start → "End date must be after the event starts".
- D-05 regression: opening an existing all-day event pre-fills the inclusive end (no +1 drift); saving twice does not grow the event.
Replace the bare start date/time `onChange` handlers with handlers that call `computeNewAllDayEnd` (all-day) or `computeNewTimedEnd` (timed) to recompute end, then set start — per PATTERNS §EventForm. Add `recurrenceBound`/`recurrenceUntil`/`recurrenceCount` state alongside the existing state block; extend the reset `useEffect` to reset them to defaults (mirror the `setRecurrence(... ?? 'none')` line). Render the "Ends" control below the recurrence `cd apps/pwa && pnpm test -- run components/EventForm
- Start `onChange` handlers call `computeNewTimedEnd`/`computeNewAllDayEnd` (grep present in EventForm.tsx).
- The "Ends" control renders only when recurrence ≠ none with the exact UI-SPEC labels and emits `recurrenceUntil`/`recurrenceCount` correctly (and neither when "Never").
- Inline validation messages use the exact UI-SPEC copy.
- `exclusiveEndToInclusiveDate` pre-fill at ~line 199 is unchanged; an all-day edit round-trip test shows no day drift (D-05 holds).
- EventForm test suite green.
End auto-tracks start with a floor; recurrence is boundable in the form; all-day edit stays drift-free; D-07 mapping confirmed 1:1.Task 2: Series-edit confirmation prompt, gated on hasRrule (D-08, D-09)apps/pwa/src/components/SeriesEditPrompt.tsx, apps/pwa/src/components/EventForm.tsx
- apps/pwa/src/components/DeleteConfirmationDialog.tsx — the existing bottom-sheet(phone)/dialog(desktop) pattern, focus trap, Escape-to-cancel, role="dialog"/aria-modal — the exact analog to mirror
- apps/pwa/src/components/EventForm.tsx — the submit handler (where Save fires) + how `occurrence` is available; the edit-mode branch (`isEdit`)
- apps/pwa/src/api/client.ts — `CalendarOccurrence.hasRrule` (from Plan 05) — the gate signal
- .planning/phases/06-ux-polish/06-UI-SPEC.md §"Surface 6" (layout, ≤767px sheet / ≥768px dialog max-width 480px, focus trap, Escape=Cancel) + §"Copywriting Contract" (heading "Edit recurring series", body "This will update all occurrences of this event.", confirm "Update series" accent-filled, "Cancel" ghost) + §"EventForm primary CTAs" (Edit recurring occurrence CTA = "Update series")
- .planning/phases/06-ux-polish/06-RESEARCH.md §"Focus 3 — Confirmation prompt (D-09)" — render when editMode && occurrence.hasRrule && Save tapped; PUT replaces master VEVENT (no RECURRENCE-ID)
Create `SeriesEditPrompt.tsx` mirroring `DeleteConfirmationDialog`'s responsive sheet/dialog, focus trap, and Escape-to-cancel, with `role="dialog"`, `aria-modal="true"`, `aria-labelledby` → the heading. Use the exact UI-SPEC Surface 6 copy: heading "Edit recurring series", body "This will update all occurrences of this event.", primary accent-filled "Update series", ghost "Cancel" (NO destructive color — this is an edit). In `EventForm.tsx`, when in edit mode AND `occurrence?.hasRrule === true`, tapping Save opens `SeriesEditPrompt` instead of submitting directly; confirming "Update series" runs the existing edit submit (the same PATCH `/api/events/:uid/edit` that PUTs the master VEVENT wholesale — no RECURRENCE-ID, per D-08); Cancel returns to the form without submitting. For non-recurring or create mode, Save submits directly as today. Set the edit-recurring CTA label to "Update series" per UI-SPEC. Commit `feat(06-06): add whole-series edit confirmation prompt`.
cd apps/pwa && pnpm test -- run 2>&1 | tail -3
- `SeriesEditPrompt` renders the exact UI-SPEC copy with focus trap + Escape=cancel + role="dialog"/aria-modal.
- In edit mode with `occurrence.hasRrule === true`, Save opens the prompt; confirming runs the existing whole-series PATCH; canceling does not submit.
- Non-recurring / create-mode Save behavior is unchanged (no prompt).
- PWA suite green.
Recurring-occurrence edits are confirmed via a whole-series prompt; master-VEVENT PUT path unchanged; no per-occurrence edit introduced.Task 3: All-day visual distinction — Schedule-X override (999.6, D-12)apps/pwa/src/styles/index.css
- apps/pwa/src/styles/index.css — the existing `.sx__*` override section (the documented Schedule-X selector overrides) to extend with an all-day rule
- apps/pwa/src/lib/hydrateEvents.ts + apps/pwa/src/lib/calendarConfig.ts — how `_familySync.color` / `calendarId` color propagates to Schedule-X chips (so the member color already drives the fill)
- .planning/phases/06-ux-polish/06-UI-SPEC.md §"Surface 3" — treatment contract: all-day = full-width filled rounded pill (border-radius 4px), white label, font-weight 600, `--text-label-size`; override selector `.sx__all-day-event`; timed events keep their existing partial-fill chip; color comes from the existing calendarId color config (no per-event inline override)
Add a `.sx__all-day-event` override to the Schedule-X section of `index.css` per UI-SPEC Surface 3: render all-day chips as a full-width rounded pill (`border-radius: 4px`), white (`#FFFFFF`) label text, `font-weight: 600`, `font-size: var(--text-label-size)`, with the member color as solid background fill sourced from the existing `calendarId` color config (do NOT add per-event inline styles — the color already propagates via `buildCalendarConfig`). Leave timed-event chip styling untouched so the contract holds: all-day = solid filled pill, timed = partial-fill chip with colored border accent. Keep all existing `.sx__*` rules intact. Commit `feat(06-06): distinct all-day event pill styling`.
grep -v '^#' apps/pwa/src/styles/index.css | grep -c 'sx__all-day-event' | grep -qx 1 && cd apps/pwa && pnpm test -- run 2>&1 | tail -3
- `.sx__all-day-event` override present exactly once with full-width pill + white bold label per UI-SPEC.
- Existing `.sx__*` layout rules unchanged.
- PWA suite green (no test regression from the CSS addition).
All-day chips render as distinct filled pills; timed chips unchanged.Task 4: playwright-cli — end-tracking, recurrence bound, series-edit prompt, all-day distinction(verification only — no files modified)
Verification task (no code changes). Using the playwright-cli skill against desktop Chromium (dev stack host-side per docs/deployment.md, DEV_AUTH_BYPASS=true), exercise the six behaviors: (1) end-tracking — move a timed event's start and confirm the end follows preserving 1h and never lands before start; repeat all-day (day-span preserved); (2) recurrence bound — set Weekly, confirm the "Ends" control appears, pick "On date" and save a bounded series (occurrences stop), then "After N times" N=3 → 3 occurrences, and "Never" stays unbounded; (3) FREQ persistence — create a Daily recurrence and confirm occurrences render daily not weekly; (4) series edit — edit an existing recurring occurrence, tap Save, confirm the focus-trapped "Edit recurring series" prompt (Escape cancels) and that "Update series" applies across occurrences; (5) all-day distinction — confirm all-day events render as full-width filled pills visually distinct from timed chips; (6) all-day no-drift — edit an existing all-day event and save twice, confirming it does not grow by a day. This is a blocking human-verify checkpoint — pause for operator confirmation.
- .claude/skills/playwright-cli/SKILL.md — drive desktop Chromium, interact with the event form and calendar
- docs/deployment.md §"Running locally (host-side, no Docker)" — dev run command (DEV_AUTH_BYPASS=true)
- .planning/phases/06-ux-polish/06-UI-SPEC.md — Surfaces 3/4/5/6 acceptance behavior
EventForm end-tracking, the "Ends" recurrence-bound control, the whole-series edit confirmation prompt, and the all-day filled-pill visual treatment.
1. End-tracking (D-04): open New Event, set a 1h timed event, then move the start date/time forward — confirm the end follows, preserving 1h, and never lands before the start. Repeat for an all-day event (day-span preserved).
2. Recurrence bound (D-06): set recurrence to Weekly, confirm the "Ends" control appears; pick "On date" and a date, save, and confirm the created series is bounded (occurrences stop at/after the date, not an endless/2-month bar). Try "After N times" with N=3 and confirm 3 occurrences. Confirm "Never" is unbounded as before.
3. FREQ persistence (D-07): create a Daily recurrence and confirm occurrences render daily (not weekly).
4. Series edit (D-08/D-09): open an existing recurring occurrence, edit the title/time, tap Save — confirm the "Edit recurring series" prompt appears (focus-trapped, Escape cancels), confirm "Update series" applies the change across occurrences.
5. All-day distinction (999.6): confirm all-day events render as full-width filled pills visually distinct from timed chips at a glance.
6. All-day edit no-drift (D-05): edit an existing all-day event and save twice — confirm it does not grow by a day.
End follows start with a floor; recurrence is boundable and FREQ persists; series-edit prompt gates whole-series edits; all-day pills are visually distinct; all-day edits do not drift.Type "approved" or describe which behavior failed.
- All six behaviors above observed correctly in desktop Chromium.
The full event-form polish set verified live via playwright-cli.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → API (PATCH /api/events/:uid/edit) | Whole-series edit PUTs the master VEVENT back to Fastmail; the new `recurrenceUntil`/`recurrenceCount` cross here (validated server-side in Plan 02). |
| user input → form state | Recurrence bound date/count are user inputs shaped in the form before submit. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-06-input | Tampering | recurrence bound inputs (EventForm) | mitigate | Form-side validation (count ≥ 1, until ≥ start) plus the authoritative server-side Zod validation from Plan 02 (`recurrenceUntil` max-10, `recurrenceCount` int≥1) — the client check is UX, the server check is the enforcement boundary. Defense in depth; no raw passthrough. (ASVS V5.) |
| T-06-06-series | Tampering | whole-series edit PUT (EventForm → existing /edit route) | mitigate | Reuses the existing edit route's ownership + objectUrl/etag lookup (unchanged from Phase 3) — the prompt only gates the UX; it adds no new privilege. The PUT replaces the master VEVENT for the caller's own event only; access scope is the existing per-user filter. |
| T-06-06-xss | Tampering / XSS | all-day pill label, prompt copy | accept | All-day labels and prompt text render as plain-text JSX children (existing EventForm XSS posture, T-03-15) — no `dangerouslySetInnerHTML`; the CSS override sets presentation only. No new injection surface. |
| T-06-06-SC | Tampering | npm installs | accept | No package installs (zero new deps). |
- `cd apps/pwa && pnpm test -- run components/EventForm` green; full `pnpm --filter @familysync/pwa test` green.
- `grep -n "computeNewTimedEnd\|computeNewAllDayEnd" apps/pwa/src/components/EventForm.tsx` present; `grep -n "recurrenceUntil\|recurrenceCount" apps/pwa/src/components/EventForm.tsx` present.
- `grep -c 'sx__all-day-event' apps/pwa/src/styles/index.css` returns 1.
- playwright-cli verifies all six event-form behaviors.
- D-03/D-04 (criterion 2): end follows start with a floor.
- D-06 (criterion 2): recurrence is boundable; D-07: FREQ persists.
- D-08/D-09 (criterion 3): whole-series edit behind a confirmation prompt.
- D-05 (criterion 2): all-day edit off-by-one stays fixed.
- 999.6/D-12 (criterion 1): all-day events visually distinct.