docs(phase-06): finalize 06-04/05/06 summaries + tracking

- 06-04-SUMMARY: pulse keyframe checkpoint PASS (playwright-cli); CP-04.3 iOS device-only noted
- 06-05-SUMMARY: TDD RED/GREEN + dead-end guard follow-up fix; playwright-cli cold-load + 401 PASS
- 06-06-SUMMARY: end-tracking/recurrence-bound/series-prompt/all-day-pill; Schedule-X selector fix noted
- ROADMAP: mark 06-04/05/06 complete; phase 6 row updated to 6/6 Complete 2026-06-10
- STATE: phase 06 position/status updated; 3 new metric rows; 6 new decisions; phase-level UX fixes (AppNav/BottomTabBar) documented; residual device-only items added to Blockers
This commit is contained in:
Lucas Berger
2026-06-10 16:30:01 -04:00
parent 562026149f
commit fa90b7cf86
5 changed files with 423 additions and 13 deletions
@@ -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)