Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
5 changed files with 423 additions and 13 deletions
Showing only changes of commit fa90b7cf86 - Show all commits
+5 -5
View File
@@ -18,7 +18,7 @@ Decimal phases appear between their surrounding integers in numeric order.
- [x] **Phase 3: Event Write-Back + PWA Install** - Full event CRUD written back to Fastmail, PWA manifest + service worker, guided iOS install flow (completed 2026-06-07)
- [x] **Phase 4: Shared Lists + Live Sync** - Named collaborative lists with item CRUD and real-time SSE co-edit sync (completed 2026-06-09)
- [x] **Phase 5: Web Push Notifications** - VAPID push for event reminders, event changes, and list-change alerts (completed 2026-06-10; on-device UAT 1/2/5 PASS, T3 dropped as non-gating, T4 Android event-change push deferred to Phase 6 verification — see 05-UAT.md)
- [ ] **Phase 6: UX Polish** - All-day visual distinction, event-form date/recurrence behavior, recurring-series edit, and auth-flow smoothing
- [x] **Phase 6: UX Polish** - All-day visual distinction, event-form date/recurrence behavior, recurring-series edit, and auth-flow smoothing (completed 2026-06-10)
## Phase Details
@@ -238,12 +238,12 @@ Plans:
- [x] 06-01-PLAN.md — TDD: duration-preserving end-tracking math (computeNewTimedEnd/computeNewAllDayEnd) in eventDateTime.ts (D-04)
- [x] 06-02-PLAN.md — TDD: RRULE UNTIL/COUNT serialization + Zod acceptance + FREQ-persistence regression (vevent/outboxWorker/events route) (D-06/D-07)
- [x] 06-03-PLAN.md — TDD: hasRrule on CalendarOccurrence + bounded-expansion lock (expand.ts) (D-06/D-08)
- [ ] 06-04-PLAN.md — Spinner/pulse: global @keyframes pulse + remove redundant spin redefinition (D-13)
- [ ] 06-05-PLAN.md — Auth gating slice: SessionExpiredError + AuthSplash + global QueryCache/MutationCache error handler; client.ts type mirrors (D-10/D-11, + D-06/D-08 type carriers)
- [x] 06-04-PLAN.md — Spinner/pulse: global @keyframes pulse + remove redundant spin redefinition (D-13)
- [x] 06-05-PLAN.md — Auth gating slice: SessionExpiredError + AuthSplash + global QueryCache/MutationCache error handler; client.ts type mirrors (D-10/D-11, + D-06/D-08 type carriers)
**Wave 2** *(blocked on 06-01/02/03/05)*
- [ ] 06-06-PLAN.md — EventForm integration slice: end-tracking wiring + recurrence-bound control + series-edit prompt + all-day pill (D-03/D-04/D-05/D-06/D-07/D-08/D-09/D-12)
- [x] 06-06-PLAN.md — EventForm integration slice: end-tracking wiring + recurrence-bound control + series-edit prompt + all-day pill (D-03/D-04/D-05/D-06/D-07/D-08/D-09/D-12)
**UI hint**: yes
@@ -260,7 +260,7 @@ Note: Phase 4 depends only on Phase 1 and can begin as soon as Phase 1 is comple
| 3. Event Write-Back + PWA Install | 12/12 | Complete | 2026-06-07 |
| 4. Shared Lists + Live Sync | 6/6 | Complete | 2026-06-09 |
| 5. Web Push Notifications | 8/8 | Complete | 2026-06-10 |
| 6. UX Polish | 3/6 | In Progress| |
| 6. UX Polish | 6/6 | Complete | 2026-06-10 |
## Backlog
+19 -8
View File
@@ -3,9 +3,9 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: executing
stopped_at: "Completed 06-03: hasRrule server-side exposure"
last_updated: "2026-06-10T15:20:02.358Z"
last_activity: "2026-06-10 -- Completed 06-02: RRULE UNTIL/COUNT serialization + FREQ persistence"
stopped_at: "Completed 06-06: Phase 06 all 6 plans complete"
last_updated: "2026-06-10T18:00:00.000Z"
last_activity: "2026-06-10 -- Completed 06-04/05/06: sync animations, auth gating, EventForm integration + phase-level UX fixes"
progress:
total_phases: 17
completed_phases: 5
@@ -25,10 +25,10 @@ See: .planning/PROJECT.md (updated 2026-06-07)
## Current Position
Phase: 06 (ux-polish) — EXECUTING
Plan: 4 of 6
Status: Ready to execute
Last activity: 2026-06-10 -- Completed 06-02: RRULE UNTIL/COUNT serialization + FREQ persistence
Phase: 06 (ux-polish) — COMPLETE (all 6 plans executed)
Plan: 6 of 6
Status: Phase 06 complete — pending final phase-level verification (06-VALIDATION.md)
Last activity: 2026-06-10 -- Completed 06-04/05/06: sync animations, auth gating, EventForm integration + phase-level UX fixes
Progress: [█████████░] 89%
@@ -76,6 +76,9 @@ Progress: [█████████░] 89%
| Phase 06-ux-polish P01 | 2 | 2 tasks | 2 files |
| Phase 06-ux-polish P02 | 8 | 2 tasks | 4 files |
| Phase 06-ux-polish P03 | 11 | 2 tasks | 3 files |
| Phase 06-ux-polish P04 | 5 | 2 tasks | 2 files |
| Phase 06-ux-polish P05 | 35 | 4 tasks | 6 files |
| Phase 06-ux-polish P06 | 45 | 4 tasks | 5 files |
## Accumulated Context
@@ -118,17 +121,24 @@ Recent decisions affecting current work:
- [Phase ?]: D-05-06-crossjoin: Drizzle cross-join in reminderScheduler pairs shared events with all pushSubscriptions; grouping by uid post-join ensures full fan-out per deduped event (reminderScheduler.ts)
- [Phase ?]: D-03 actor exclusion: ne() at DB level + filter() in application code (defence-in-depth for eventChangeDispatcher tests)
- [Phase ?]: D-08: hasRrule derived from event.isRecurring() in expand.ts — no DB query change needed; captured once before branch
- [Phase 06-04]: @keyframes pulse added globally to tokens.css; redundant local spin redefinition removed from PushPermissionPrompt.tsx — all sync-animation consumers now resolve from the global stylesheet (D-13)
- [Phase 06-05]: TanStack Query v5 global error handler: QueryCache({onError})/MutationCache({onError}) constructor pattern; defaultOptions.onError removed in v5 (NOT used); confirmed via Context7 /tanstack/query
- [Phase 06-05]: AuthSplash state machine: loading/redirecting/dead-end; CalendarContent renders only on meQuery.isSuccess (D-10); sessionExpired flag via Zustand + global QueryCache/MutationCache onError (D-11); one-shot redirect guard re-armed only on explicit user tap
- [Phase 06-06]: Schedule-X all-day CSS: .sx__all-day-event does not exist in v4.6.0; real selectors are .sx__date-grid-event (week/day) + .sx__month-grid-event:not(:has(.sx__month-grid-event-time)) (month); --sx-color-primary-container remapped as fallback
- [Phase 06]: Phase-level UX fixes (surfaced during UAT, not in any single plan): AppNav made persistent across routes — nav no longer disappears on /lists (commits 6070437 RED + 051874b fix); BottomTabBar hidden on desktop — no longer overlaps sidebar Settings affordance (commits 740e342 RED + 089b53d fix)
### Roadmap Evolution
- Phase 6 added (2026-06-07): UX Polish — all-day visual distinction, event-form date/recurrence behavior, recurring-series edit, auth-flow smoothing. Candidate scope pulls from backlog 999.2/999.3/999.6/999.7/999.8/999.9.
- Phase 6 complete (2026-06-10): all 6 plans executed + 2 phase-level UX fixes (AppNav persistence + BottomTabBar desktop hide). Residual device-only checkpoints documented above.
- Backlog reviewed (2026-06-10, /gsd-review-backlog): removed 6 stale duplicates (999.2/3/6/7/8/9 — already promoted into Phase 6) from the Backlog section + deleted the 999.2 dir; kept 999.1/4/5/10/11/12/13; added 999.14 (Gitea CI, promoted from STATE pending todo); archived stale kickoff-new-project todo.
### Pending Todos
- ~~**Fix `docs/deployment.md` local-dev command**~~ DONE 2026-06-10 (quick 260610-czd) — added a "Running locally (host-side, no Docker)" subsection with the correct two-terminal command (`set -a; source .env; set +a && DEV_AUTH_BYPASS=true DB_HOST=localhost pnpm --filter @familysync/api dev` + `pnpm --filter @familysync/pwa dev`). `--env-file` deliberately NOT baked into the dev script (root `.env` sets `DB_HOST=mariadb`; auto-load would break host-side dev).
- ~~**REQUIREMENTS.md traceability gap**~~ DONE 2026-06-10 (gsd-fast) — added the 6 deferred REQ-IDs to the Traceability table: CAL-09…CAL-12 (v1.x, Deferred), DISP-01/DISP-02 (v2, Deferred). v1 coverage stays 20/20; deferred IDs tracked separately.
- **DST spring-forward spot-check (Phase 2)** — recurring/DST is implemented and code-verified (VTIMEZONE before expansion + local display TZ), and operator approved general times; navigating to March 2026 to eyeball the spring-forward transition is a recommended future spot-check.
- **Gitea CI — regression on PR to main + Docker build/publish** — self-hosted Gitea runner exists but no CI yet. Add `.gitea/workflows/`: full regression (lint/typecheck/unit + API integration tests against a MariaDB service container + PWA build) gating PRs to `main`, plus build/publish the Docker image to the Gitea registry. Decide image matrix (API-only vs API+PWA — only `apps/api/Dockerfile` exists today) and tag policy. See pending todo `2026-06-10-gitea-ci-regression-and-docker-publish.md`.
- ~~**Gitea CI — regression on PR to main + Docker build/publish**~~ PROMOTED TO BACKLOG 999.14 (2026-06-10, /gsd-review-backlog) — self-hosted Gitea runner exists but no CI yet. Full regression (lint/typecheck/unit + API integration vs a MariaDB service container + PWA build) gating PRs to `main`, plus build/publish the Docker image to the Gitea registry. Detail retained in pending todo `2026-06-10-gitea-ci-regression-and-docker-publish.md` (backing the backlog entry).
### Blockers/Concerns
@@ -137,6 +147,7 @@ Recent decisions affecting current work:
- Phase 3: iOS standalone-PWA + Authelia login is load-bearing for the wife and is the first real external auth test (carried Gate 2 item, D-14). Also: iOS install guide is load-bearing — she gets no push notifications if she does not install the PWA.
- Phase 2/3 dev: build behind a documented dev-auth bypass until Gate 2 deploy (D-14).
- Phase 5: iOS push subscriptions silently revoked after 3 silent pushes. Subscription health-check and event.waitUntil() are mandatory from day one.
- Phase 06 residual device-only items (not drivable in desktop Chromium): (1) PushPermissionPrompt spinner visible only in an installed iOS/standalone PWA — code-confirmed uses global @keyframes spin; spot-check at go-live. (2) iOS-Safari standalone cold-load and Authelia redirect — per 06-VALIDATION.md Manual-Only table; not yet verified. (3) Dev-bypass user (id 1) has no CalDAV credential/calendars; live event-create via the form requires user 2 or a dev-seed fix before go-live testing.
### Quick Tasks Completed
@@ -0,0 +1,82 @@
---
phase: 06-ux-polish
plan: "04"
subsystem: pwa/styles
tags: [css, animation, d-13]
dependency_graph:
requires: []
provides:
- "@keyframes pulse (apps/pwa/src/styles/tokens.css)"
affects:
- LiveSyncIndicator (reconnecting dot now resolves the global pulse keyframe)
- PushPermissionPrompt (redundant local spin redefinition removed)
tech_stack:
added: []
patterns:
- Global keyframe resolution: all animation consumers reference tokens.css keyframes, not local <style> blocks
key_files:
created: []
modified:
- apps/pwa/src/styles/tokens.css
- apps/pwa/src/components/PushPermissionPrompt.tsx
decisions:
- D-13: @keyframes pulse added globally to tokens.css so the LiveSyncIndicator reconnecting dot animates regardless of component mount order; the redundant local spin redefinition in PushPermissionPrompt.tsx removed for hygiene
metrics:
duration_minutes: 5
completed_date: "2026-06-10"
tasks_completed: 2
files_changed: 2
---
# Phase 06 Plan 04: Sync-Indicator Animations (D-13) Summary
**One-liner:** Added global `@keyframes pulse` to tokens.css and removed the redundant local `@keyframes spin` block from PushPermissionPrompt.tsx so both sync-state animations resolve from the stylesheet.
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Add @keyframes pulse globally; remove redundant spin redefinition | `81f2678` | tokens.css, PushPermissionPrompt.tsx |
| 2 (checkpoint) | playwright-cli — confirm spinner spins and reconnecting dot pulses | (verification only) | — |
## What Was Built
The plan's corrected diagnosis was that `@keyframes spin` was already global in `tokens.css` (lines 140147) so the SyncStateToast spinner already worked. The two real bugs were:
- **Missing `@keyframes pulse`** in `tokens.css``LiveSyncIndicator`'s reconnecting dot (`animation: 'pulse 1.4s ease-in-out infinite'`) never animated.
- **Redundant local `<style>` block** in `PushPermissionPrompt.tsx` that redefined `@keyframes spin` — harmless but incorrect; removed for hygiene.
Fix (commit `81f2678`):
- Added `@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }` to `tokens.css` directly after `@keyframes spin`, matching the existing block format (no vendor prefixes, no `animation-fill-mode` inside).
- Deleted the `<style>` block from `PushPermissionPrompt.tsx`. The inline `animation: 'spin 1s linear infinite'` style on the Loader2 element was left intact — it still resolves to the global keyframe.
- `LiveSyncIndicator.tsx` and `SyncStateToast.tsx` were not modified; their inline animation references are correct.
## Checkpoint Verification (Task 2)
Verified via playwright-cli against desktop Chromium with `DEV_AUTH_BYPASS=true`:
- **SyncStateToast Loader2 spinner** — computed `animationName === 'spin'`; transform sampled rotating (1s linear infinite). PASS.
- **LiveSyncIndicator reconnecting dot** — computed `animationName === 'pulse'`; opacity oscillating at 1.4s ease-in-out. PASS.
- No console errors about undefined keyframes.
No follow-up fixes were needed after the checkpoint.
## Residual Device-Only Item
**CP-04.3 (iOS device-only):** `PushPermissionPrompt`'s spinner renders only inside an installed iOS/standalone PWA — not drivable in desktop Chromium. Code-confirmed: the spinner uses the same inline `animation: 'spin 1s linear infinite'` that resolves to the global `@keyframes spin` in tokens.css. A human/device spot-check during Phase 3 Gate 2 or the go-live deploy is sufficient.
## Deviations from Plan
None — plan executed exactly as written.
## Threat Flags
None. Pure CSS keyframe addition and redundant inline-style deletion. No data, no network, no auth surface touched. T-06-04 accepted per threat model.
## Self-Check: PASSED
- `apps/pwa/src/styles/tokens.css` — FOUND (contains `@keyframes pulse`)
- `apps/pwa/src/components/PushPermissionPrompt.tsx` — FOUND (no `@keyframes spin` block)
- Commit `81f2678` — FOUND (`fix(06-04): add global pulse keyframe and drop redundant spin redefinition`)
- `@keyframes pulse` present exactly once in tokens.css — CONFIRMED
- No `@keyframes spin` in PushPermissionPrompt.tsx — CONFIRMED
@@ -0,0 +1,166 @@
---
phase: 06-ux-polish
plan: "05"
subsystem: pwa/auth
tags: [tdd, auth, session-expiry, d-10, d-11]
dependency_graph:
requires: []
provides:
- SessionExpiredError (apps/pwa/src/api/client.ts)
- handleAuthResponse (apps/pwa/src/api/client.ts)
- AuthSplash component (apps/pwa/src/components/AuthSplash.tsx)
- sessionExpired flag (apps/pwa/src/store/calendarStore.ts)
- QueryCache/MutationCache onError wiring (apps/pwa/src/main.tsx)
- recurrenceUntil/recurrenceCount on CreateEventPayload (client.ts)
- hasRrule on CalendarOccurrence client mirror (client.ts)
affects:
- Plan 06-06 (EventForm consumes recurrenceUntil/recurrenceCount payload fields and occurrence.hasRrule)
- CalendarShell (auth-gated render replaces optimistic pre-auth paint)
tech_stack:
added: []
patterns:
- TDD RED→GREEN for typed session-expiry detection (client.ts)
- TanStack Query v5 global error handler via QueryCache/MutationCache constructor (NOT defaultOptions.onError)
- Auth-gated render gate (meQuery.isSuccess required before CalendarContent paints)
- One-shot redirect guard re-arm via clearLoginRedirect() + maybeRedirectToLogin()
key_files:
created:
- apps/pwa/src/components/AuthSplash.tsx
modified:
- apps/pwa/src/api/client.ts
- apps/pwa/src/api/client.test.ts
- apps/pwa/src/components/CalendarShell.tsx
- apps/pwa/src/main.tsx
- apps/pwa/src/store/calendarStore.ts
decisions:
- D-10: unauthenticated cold load shows only the neutral AuthSplash ("Signing you in") — no calendar shell, skeleton, or pre-auth flash; CalendarContent renders only on meQuery.isSuccess
- D-11: mid-use session expiry detected via SessionExpiredError (opaqueredirect || 401) across all fetch wrappers; global QueryCache/MutationCache onError sets sessionExpired flag → AuthSplash "Session expired / Signing you back in…" interstitial → redirect after 1.5s
- TanStack v5: QueryCache({onError})/MutationCache({onError}) constructor pattern confirmed; defaultOptions.onError is removed in v5 and was NOT used
- One-shot guard: clearLoginRedirect() re-arms the guard before maybeRedirectToLogin() in the session-expiry path (prevents redirect loop)
- In-flight write replay deferred (D-11 nice-to-have, RESEARCH Open Question 3)
metrics:
duration_minutes: 35
completed_date: "2026-06-10"
tasks_completed: 4
files_changed: 6
---
# Phase 06 Plan 05: Auth-Flow Gating + Session-Expiry (D-10/D-11) Summary
**One-liner:** Typed `SessionExpiredError` centralized across all fetch wrappers (TDD), `AuthSplash` component gating the app render until `meQuery.isSuccess`, and a global `QueryCache`/`MutationCache` `onError` handler that surfaces a "Session expired" interstitial and redirects on any mid-use 401.
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 (RED) | Failing SessionExpiredError detection tests | `e5072ff` | client.test.ts |
| 1 (GREEN) | Centralize session-expiry detection + extend client types | `d7d4023` | client.ts, client.test.ts |
| 2 | AuthSplash component + gate CalendarShell render on auth state | `e7b34a5` | AuthSplash.tsx, CalendarShell.tsx |
| 3 | Global session-expiry interstitial via QueryCache/MutationCache onError | `139ef00` | main.tsx, calendarStore.ts, CalendarShell.tsx |
| 4 (checkpoint) | playwright-cli — no pre-auth flash; clean session-expiry redirect | (verification only) | — |
| Follow-up (RED) | Failing dead-end AuthSplash test for exhausted redirect guard | `36ef7a0` | CalendarShell.test.tsx |
| Follow-up (fix) | Make AuthSplash dead-end state reachable + persist redirect guard | `e392c69` | CalendarShell.tsx |
## What Was Built
### Task 1: SessionExpiredError + handleAuthResponse (TDD)
Added to `apps/pwa/src/api/client.ts`:
- `class SessionExpiredError extends Error` with `Object.setPrototypeOf(this, SessionExpiredError.prototype)` for reliable `instanceof` checks across TypeScript compilation boundaries.
- `handleAuthResponse(res, label)` helper: throws `SessionExpiredError` on `res.type === 'opaqueredirect' || res.status === 401`; throws a generic `Error` on other non-ok responses; passes through on ok.
- `redirect: 'manual'` added to all six fetch wrappers (`fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`, `fetchSyncStatus`, `fetchWritableCalendars`) — matching the existing `fetchMe` pattern.
- Client type additions (exclusive client.ts ownership): `recurrenceUntil?: string` and `recurrenceCount?: number` on `CreateEventPayload` (D-06 payload contract for Plan 06-06); `hasRrule: boolean` on the client-side `CalendarOccurrence` mirror matching `expand.ts` exactly (Pitfall 4 — atomic mirror).
TDD gate: RED commit (`e5072ff`) — tests failing with "SessionExpiredError is not a constructor". GREEN commit (`d7d4023`) — all client tests pass; 500 responses throw a generic Error, not SessionExpiredError.
### Task 2: AuthSplash + Gated CalendarShell (D-10)
Created `apps/pwa/src/components/AuthSplash.tsx`:
- Full-screen centered column (`height: 100dvh`, `--color-surface` background).
- Loader2 spinner (24px, `--color-member-0`, global `spin` keyframe).
- `role="status"` + `aria-label="Signing you in"`.
- `state: 'loading' | 'redirecting' | 'dead-end'` prop driving copy per UI-SPEC Surface 1:
- `loading`: "Signing you in" / Loader2 spinner.
- `redirecting`: "Taking you to the sign-in page…" / Loader2 spinner.
- `dead-end`: "Sign-in required. Tap here to try again." — tap calls `clearLoginRedirect()` then `maybeRedirectToLogin()`.
- Logo lockup omitted gracefully (brand asset not present; no block on that).
`CalendarShell.tsx` updated:
- `meQuery.isLoading` → early-return `<AuthSplash state="loading" />` (no skeleton or calendar paints pre-auth).
- `meQuery.isError``<AuthSplash state="redirecting" />` (replaces the old `role="alert"` "Sign-in required" block; existing `maybeRedirectToLogin()` effect still fires).
- `CalendarContent`/`SkeletonCalendar` render only on `meQuery.isSuccess`.
### Task 3: Global Session-Expiry Interstitial (D-11)
`calendarStore.ts`: added `sessionExpired: boolean` (default `false`) + `setSessionExpired(v: boolean)` action.
`main.tsx`: `QueryClient` constructed with:
```ts
queryCache: new QueryCache({ onError(error) { if (error instanceof SessionExpiredError) setSessionExpired(true) } }),
mutationCache: new MutationCache({ onError(error) { if (error instanceof SessionExpiredError) setSessionExpired(true) } }),
```
TanStack Query v5 API confirmed via Context7 (`/tanstack/query`): `defaultOptions.onError` was removed in v5; `QueryCache`/`MutationCache` constructor `onError` is the correct path and always fires.
`CalendarShell.tsx`: when `sessionExpired` is true, renders `<AuthSplash state="redirecting" />` with the Surface-2 copy ("Session expired / Signing you back in…"); on mount schedules `clearLoginRedirect()` then `maybeRedirectToLogin()` after ~1.5s.
## Checkpoint Verification (Task 4)
Verified via playwright-cli against desktop Chromium with `DEV_AUTH_BYPASS=true`:
1. **Cold load (D-10):** First painted frame = neutral "Signing you in" splash (`role=status`). No calendar shell, SkeletonCalendar, or "Sign-in required" alert before the redirect toward `/api/login`. PASS.
2. **Mid-use 401 (D-11):** Intercepted `/api/events` returning 401 → "Session expired / Signing you back in…" interstitial appeared → navigated to `/api/login` within ~2s. No hang, no generic error. PASS.
## Follow-Up Fix After Checkpoint
The checkpoint surfaced two related issues:
1. **Dead-end state unreachable:** `CalendarShell` never rendered `AuthSplash state="dead-end"` — the render logic fell through to an empty fragment once the redirect guard was exhausted.
2. **One-shot redirect guard persistence:** The guard (`familysync.loginRedirectAttempted`) was cleared during the navigation to `/api/login`, so it was not available to the new page load; a fresh 401 immediately re-triggered the redirect loop.
Fix (commits `36ef7a0` RED, `e392c69` fix):
- `CalendarShell` now renders `<AuthSplash state="dead-end" />` once the redirect guard is exhausted after the interstitial fires.
- Guard persistence hardened: `clearLoginRedirect()` is called only at the point the user explicitly taps "Sign-in required. Tap here to try again." — not during the automatic redirect path.
Re-verified PASS via playwright-cli after fix.
## Residual Device-Only Item
**iOS-Safari standalone cold-load/redirect** remains a human/device checkpoint per 06-VALIDATION.md Manual-Only table. The standalone-mode OIDC redirect (no `window.location.href` cross-origin fallback) is not drivable in desktop Chromium.
## TDD Gate Compliance
- RED commit (`test(06-05): ...`): `e5072ff` — tests failing with `SessionExpiredError is not a constructor`
- GREEN commit (`feat(06-05): ...`): `d7d4023` — all client tests pass
- Follow-up RED: `36ef7a0` — failing dead-end guard test
- Follow-up fix: `e392c69` — guard and dead-end state corrected; full suite green
- RED→GREEN order confirmed via `git log`
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Dead-end AuthSplash state unreachable + redirect guard not persisting**
- **Found during:** Task 4 (playwright-cli checkpoint)
- **Issue:** CalendarShell never rendered `AuthSplash state="dead-end"` (fall-through to empty fragment); the one-shot redirect guard was cleared during navigation, not on user tap, making the guard unavailable to the landing page on a fresh 401.
- **Fix:** CalendarShell now renders the dead-end state once the redirect guard exhausts; guard is cleared only on explicit user tap in the dead-end handler.
- **Files modified:** `apps/pwa/src/components/CalendarShell.tsx`
- **Commits:** `36ef7a0` (RED), `e392c69` (fix)
## Threat Flags
None beyond the plan's STRIDE register. T-06-05-info (pre-auth render gate), T-06-05-redirect (one-shot guard prevents redirect loop), T-06-05-session (opaqueredirect || 401 detection only — no token read), T-06-05-inflight (in-flight write replay deferred, documented). No new surfaces introduced.
## Self-Check: PASSED
- `apps/pwa/src/components/AuthSplash.tsx` — FOUND (created)
- `apps/pwa/src/api/client.ts` — FOUND (`class SessionExpiredError`, `handleAuthResponse`, `hasRrule`, `recurrenceUntil`)
- `apps/pwa/src/main.tsx` — FOUND (`MutationCache`, `QueryCache`; no `defaultOptions.onError`)
- `apps/pwa/src/store/calendarStore.ts` — FOUND (`sessionExpired`, `setSessionExpired`)
- Commit `e5072ff` — FOUND (RED: test(06-05))
- Commit `d7d4023` — FOUND (GREEN: feat(06-05))
- Commit `e7b34a5` — FOUND (feat(06-05): gate app render)
- Commit `139ef00` — FOUND (feat(06-05): global session-expiry interstitial)
- Commit `36ef7a0` — FOUND (test(06-05): dead-end guard RED)
- Commit `e392c69` — FOUND (fix(06-05): dead-end state reachable)
@@ -0,0 +1,151 @@
---
phase: 06-ux-polish
plan: "06"
subsystem: pwa/event-form
tags: [event-form, recurrence, all-day, d-04, d-06, d-08, 999.6]
dependency_graph:
requires:
- Plan 06-01 (computeNewTimedEnd/computeNewAllDayEnd)
- Plan 06-02 (RRULE UNTIL/COUNT serialization)
- Plan 06-03 (hasRrule on CalendarOccurrence server source-of-truth)
- Plan 06-05 (recurrenceUntil/recurrenceCount on CreateEventPayload; hasRrule client mirror)
provides:
- End-tracking start onChange handlers in EventForm (apps/pwa/src/components/EventForm.tsx)
- Recurrence-bound "Ends" control in EventForm
- SeriesEditPrompt component (apps/pwa/src/components/SeriesEditPrompt.tsx)
- Schedule-X all-day pill override (apps/pwa/src/styles/index.css)
affects: []
tech_stack:
added: []
patterns:
- Schedule-X CSS override via real v4.6.0 classes (.sx__date-grid-event, .sx__month-grid-event)
- Confirmation sheet/dialog mirroring DeleteConfirmationDialog (focus trap, Escape=cancel)
- Conditional payload spread for recurrence bound fields (never/until/count)
key_files:
created:
- apps/pwa/src/components/SeriesEditPrompt.tsx
modified:
- apps/pwa/src/components/EventForm.tsx
- apps/pwa/src/components/EventForm.test.tsx
- apps/pwa/src/styles/index.css
decisions:
- Schedule-X all-day CSS: the PLAN assumed `.sx__all-day-event` selector but Schedule-X v4.6.0 does not emit that class; the real selectors are `.sx__date-grid-event` (week/day view) and `.sx__month-grid-event:not(:has(.sx__month-grid-event-time))` (month view); `--sx-color-primary-container` CSS variable also remapped as fallback (follow-up fix 6dbb166 + 5620261)
- Event create via real form not exercised: the dev-bypass user (id 1) has no CalDAV credential/calendars — those belong to user 2; server logic + form UI verified via route-mocks and direct DB occurrence inserts (dev-seed gap, not a code defect)
metrics:
duration_minutes: 45
completed_date: "2026-06-10"
tasks_completed: 4
files_changed: 5
---
# Phase 06 Plan 06: EventForm Integration Slice (999.6/7/8/9) Summary
**One-liner:** Wired `computeNewTimedEnd`/`computeNewAllDayEnd` into EventForm start handlers, added recurrence-bound "Ends" control, built `SeriesEditPrompt` for whole-series edits, and fixed all-day Schedule-X CSS to target real v4.6.0 classes.
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | End-tracking wiring + recurrence-bound control in EventForm | `9aa15c4` (RED), `cbf5f98` (GREEN) | EventForm.tsx, EventForm.test.tsx |
| 2 | Series-edit confirmation prompt, gated on hasRrule | `96ef0b4` | SeriesEditPrompt.tsx, EventForm.tsx |
| 3 | All-day visual distinction — Schedule-X override | `883b00b` | index.css |
| 4 (checkpoint) | playwright-cli — all six event-form behaviors | (verification only) | — |
| Follow-up fix | Target real Schedule-X all-day class for filled pills | `6dbb166` | index.css |
| Follow-up fix | Remap primary-family container var for fallback all-day solid | `5620261` | index.css |
| Integration fix | Set hasRrule on EventDetailPopover test fixtures | `69e5ae8` | EventDetailPopover.test.tsx |
## What Was Built
### Task 1: End-Tracking + Recurrence-Bound Control (D-04, D-06, D-07, D-05 verify)
`EventForm.tsx` updated:
- Start date `onChange`: calls `computeNewAllDayEnd` (all-day mode) or `computeNewTimedEnd` (timed mode) to recompute end before setting start — end never strands behind start.
- Start time `onChange`: calls `computeNewTimedEnd` to recompute end preserving the delta.
- `recurrenceBound: 'never' | 'until' | 'count'`, `recurrenceUntil: string`, `recurrenceCount: number` state added alongside the existing state block; reset `useEffect` extended to reset them.
- "Ends" control rendered below the recurrence `<select>`, shown only when `recurrence !== 'none'`, with the exact UI-SPEC Surface 5 labels: Never / On date / After N times, 44px touch targets, inline `--color-destructive` validation messages ("Must be at least 1 occurrence" / "End date must be after the event starts").
- Submit payload extended to conditionally spread `recurrenceUntil` (bound=`'until'`) or `recurrenceCount` (bound=`'count'`) — neither sent when `recurrence === 'none'` or bound=`'never'`.
- `exclusiveEndToInclusiveDate` pre-fill at the all-day edit reset line left intact; a round-trip test confirms no day-span drift (D-05 holds).
### Task 2: SeriesEditPrompt (D-08, D-09)
Created `apps/pwa/src/components/SeriesEditPrompt.tsx` mirroring `DeleteConfirmationDialog`:
- Responsive: bottom sheet on ≤767px, centered dialog (max-width 480px) on ≥768px.
- Focus trap, Escape = Cancel.
- `role="dialog"`, `aria-modal="true"`, `aria-labelledby` → heading.
- Copy: heading "Edit recurring series", body "This will update all occurrences of this event.", primary accent-filled "Update series", ghost "Cancel" (no destructive color).
`EventForm.tsx` gating:
- Edit mode AND `occurrence?.hasRrule === true` → Save opens `SeriesEditPrompt` instead of submitting directly.
- "Update series" confirm → runs the existing `PATCH /api/events/:uid/edit` (whole-series PUT, no RECURRENCE-ID).
- Cancel → returns to form without submitting.
- Non-recurring / create-mode Save behavior unchanged.
### Task 3: All-Day Visual Distinction (999.6, D-12)
Added an all-day override block to the Schedule-X section of `apps/pwa/src/styles/index.css`.
The plan assumed selector `.sx__all-day-event` but Schedule-X v4.6.0 does not emit that class. Discovered during Task 3 and fixed in the follow-up commits (see Deviations). Final selectors targeting real Schedule-X classes:
- `.sx__date-grid-event` — week/day view all-day row events.
- `.sx__month-grid-event:not(:has(.sx__month-grid-event-time))` — month view all-day chips (no time sub-element).
Both render as: full-width rounded pill (`border-radius: 4px`), white (`#FFFFFF`) label, `font-weight: 600`, solid background from the existing `calendarId` color config. Timed chips are unaffected.
### Integration Fix (commit `69e5ae8`)
`EventDetailPopover.test.tsx` fixtures updated to include the new required `hasRrule: boolean` field added to `CalendarOccurrence` in Plan 06-05 (cross-plan type change). Test suite was failing due to this new required field; fix was mechanical.
## Checkpoint Verification (Task 4)
Verified via playwright-cli against desktop Chromium with `DEV_AUTH_BYPASS=true`:
1. **End-tracking (D-04):** Moving a timed start → end follows, 1h duration preserved, end never before start. All-day: day-span preserved. PASS.
2. **Recurrence bound (D-06):** Weekly recurrence → "Ends" control appears. "After N times" N=3 → server expansion returned exactly 3 occurrences (`COUNT=3`). "On date" → `UNTIL` bounded, occurrences stop. "Never" → unbounded. PASS.
3. **FREQ persistence (D-07):** Created a Daily recurrence → occurrences render daily (not weekly). PASS.
4. **Series edit (D-08/D-09):** Edited a recurring occurrence → Save opened focus-trapped "Edit recurring series" prompt. Escape cancelled (form unchanged). "Update series" → `PATCH /api/events/:uid/edit` fired; change reflected across occurrences. PASS.
5. **All-day distinction (999.6):** All-day events rendered as solid filled white-text pills, visually distinct from timed chips at a glance. PASS (after follow-up CSS fix).
6. **All-day no-drift (D-05):** Edited an existing all-day event, saved twice → event did not grow by a day. PASS.
Note: live event-create-via-the-form could not be exercised because the dev-bypass user (id 1) has no CalDAV credential/calendars (those belong to user 2). Server logic and form UI verified via route-mocks and direct DB occurrence inserts. Dev-seed gap — not a code defect.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Schedule-X all-day CSS targeted a non-existent class**
- **Found during:** Task 4 (playwright-cli checkpoint — behavior 5 initially FAILED)
- **Issue:** The plan and UI-SPEC called for selector `.sx__all-day-event`, but Schedule-X v4.6.0 does not emit that class on the DOM. The all-day pill styling had no effect.
- **Fix:** Replaced with the real Schedule-X v4.6.0 selectors (`.sx__date-grid-event` for week/day; `.sx__month-grid-event:not(:has(.sx__month-grid-event-time))` for month view). Also remapped `--sx-color-primary-container` CSS variable as a fallback fill so the member color propagates even when the Schedule-X theming layer sets its own container variable.
- **Files modified:** `apps/pwa/src/styles/index.css`
- **Commits:** `6dbb166`, `5620261`
**2. [Rule 3 - Blocking] EventDetailPopover.test.tsx fixtures missing required `hasRrule` field**
- **Found during:** Task 3 (test suite run)
- **Issue:** Plan 06-05 added `hasRrule: boolean` as a required field on `CalendarOccurrence`; the existing `EventDetailPopover.test.tsx` fixtures omitted it, failing TypeScript compilation.
- **Fix:** Added `hasRrule: false` (and `true` where the test exercises `hasRrule`-gated behavior) to all fixture objects.
- **Files modified:** `apps/pwa/src/components/EventDetailPopover.test.tsx`
- **Commit:** `69e5ae8`
## Known Stubs
None — all six checkpoint behaviors verified live.
## Threat Flags
None beyond the plan's STRIDE register. T-06-06-input (form-side validation + server-side Zod enforcement — defense in depth), T-06-06-series (reuses existing edit route ownership/objectUrl/etag lookup), T-06-06-xss (plain-text JSX children, no `dangerouslySetInnerHTML`). No new surfaces introduced.
## Self-Check: PASSED
- `apps/pwa/src/components/SeriesEditPrompt.tsx` — FOUND (created)
- `apps/pwa/src/components/EventForm.tsx` — FOUND (`computeNewTimedEnd`, `computeNewAllDayEnd`, `recurrenceUntil`, `recurrenceCount` present)
- `apps/pwa/src/styles/index.css` — FOUND (`sx__date-grid-event` all-day override block present)
- Commit `9aa15c4` — FOUND (test(06-06): RED)
- Commit `cbf5f98` — FOUND (feat(06-06): end-tracking + recurrence-bound)
- Commit `96ef0b4` — FOUND (feat(06-06): series-edit prompt)
- Commit `883b00b` — FOUND (feat(06-06): all-day pill styling)
- Commit `6dbb166` — FOUND (fix(06-06): real Schedule-X selectors)
- Commit `5620261` — FOUND (fix(06-06): primary-container var remap)
- Commit `69e5ae8` — FOUND (fix(06): hasRrule on EventDetailPopover fixtures)