- 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
11 KiB
phase, plan, subsystem, tags, dependency_graph, tech_stack, key_files, decisions, metrics
| phase | plan | subsystem | tags | dependency_graph | tech_stack | key_files | decisions | metrics | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 06-ux-polish | 05 | pwa/auth |
|
|
|
|
|
|
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 ErrorwithObject.setPrototypeOf(this, SessionExpiredError.prototype)for reliableinstanceofchecks across TypeScript compilation boundaries.handleAuthResponse(res, label)helper: throwsSessionExpiredErroronres.type === 'opaqueredirect' || res.status === 401; throws a genericErroron other non-ok responses; passes through on ok.redirect: 'manual'added to all six fetch wrappers (fetchEvents,createEvent,updateEvent,deleteEvent,fetchSyncStatus,fetchWritableCalendars) — matching the existingfetchMepattern.- Client type additions (exclusive client.ts ownership):
recurrenceUntil?: stringandrecurrenceCount?: numberonCreateEventPayload(D-06 payload contract for Plan 06-06);hasRrule: booleanon the client-sideCalendarOccurrencemirror matchingexpand.tsexactly (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-surfacebackground). - Loader2 spinner (24px,
--color-member-0, globalspinkeyframe). 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 callsclearLoginRedirect()thenmaybeRedirectToLogin().
- 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 oldrole="alert""Sign-in required" block; existingmaybeRedirectToLogin()effect still fires).CalendarContent/SkeletonCalendarrender only onmeQuery.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:
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:
- 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. - Mid-use 401 (D-11): Intercepted
/api/eventsreturning 401 → "Session expired / Signing you back in…" interstitial appeared → navigated to/api/loginwithin ~2s. No hang, no generic error. PASS.
Follow-Up Fix After Checkpoint
The checkpoint surfaced two related issues:
- Dead-end state unreachable:
CalendarShellnever renderedAuthSplash state="dead-end"— the render logic fell through to an empty fragment once the redirect guard was exhausted. - 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):
CalendarShellnow 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 withSessionExpiredError 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; nodefaultOptions.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)