diff --git a/apps/pwa/src/components/CalendarShell.tsx b/apps/pwa/src/components/CalendarShell.tsx
index fea479b..6cee341 100644
--- a/apps/pwa/src/components/CalendarShell.tsx
+++ b/apps/pwa/src/components/CalendarShell.tsx
@@ -28,7 +28,7 @@
* success + events → ScheduleXCalendar
*/
-import { useState, useEffect, useMemo } from 'react'
+import { useState, useEffect, useMemo, useRef } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ScheduleXCalendar, useCalendarApp } from '@schedule-x/react'
import {
@@ -83,8 +83,11 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void
const selectedView = useCalendarStore((s) => s.selectedView)
const setEventForm = useCalendarStore((s) => s.setEventForm)
const eventFormOpen = useCalendarStore((s) => s.eventFormOpen)
+ const sessionExpired = useCalendarStore((s) => s.sessionExpired)
const { start, end } = calendarRange
const queryClient = useQueryClient()
+ // Ref to ensure the session-expiry redirect timer fires only once per expiry
+ const sessionExpiredRedirectFired = useRef(false)
// Fetch current user to build per-member color config
const meQuery = useQuery({
@@ -208,6 +211,26 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void
}
}, [meQuery.isSuccess])
+ // Session-expiry redirect (D-11) — fires when a mid-use 401 is detected by the
+ // global QueryCache/MutationCache handler and arms the Zustand sessionExpired flag.
+ // Re-arms the one-shot guard (clearLoginRedirect) so maybeRedirectToLogin fires
+ // afresh, then schedules the top-level navigation after ~1.5s (UI-SPEC §Surface 2
+ // "≤2s before redirect, no dismiss button").
+ useEffect(() => {
+ if (!sessionExpired) return
+ if (sessionExpiredRedirectFired.current) return
+ sessionExpiredRedirectFired.current = true
+
+ clearLoginRedirect()
+ const timer = setTimeout(() => {
+ maybeRedirectToLogin()
+ }, 1500)
+
+ return () => {
+ clearTimeout(timer)
+ }
+ }, [sessionExpired])
+
// ── Render helpers ────────────────────────────────────────────────────────
// Determine which content to show in the calendar area.
@@ -238,6 +261,20 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void
return
}
+ // ── Session-expiry interstitial (D-11) ─────────────────────────────────────
+ // When a mid-use query or mutation returns 401, the global QueryCache/MutationCache
+ // handler sets sessionExpired=true. Show the "Session expired" interstitial while the
+ // 1.5s timer fires (wired above in the sessionExpired useEffect).
+ if (sessionExpired) {
+ return (
+
+ )
+ }
+
// ── Calendar content ───────────────────────────────────────────────────────
// The content panel (right of sidebar on desktop, full-width on phone).
diff --git a/apps/pwa/src/main.tsx b/apps/pwa/src/main.tsx
index cd42445..dd5c0ab 100644
--- a/apps/pwa/src/main.tsx
+++ b/apps/pwa/src/main.tsx
@@ -10,11 +10,32 @@ import './styles/index.css'
import React from 'react'
import ReactDOM from 'react-dom/client'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@tanstack/react-query'
import App from './App.js'
import { ErrorBoundary } from './components/ErrorBoundary.js'
+import { SessionExpiredError } from './api/client.js'
+import { useCalendarStore } from './store/calendarStore.js'
+
+/**
+ * Global session-expiry error handler (D-11, Plan 06-05).
+ *
+ * When any query or mutation throws a SessionExpiredError, arm the interstitial
+ * via the Zustand store's imperative getter — safe to call outside React since
+ * Zustand's create() returns a store with a .getState() API.
+ *
+ * QueryCache and MutationCache onError callbacks are the correct v5 pattern for
+ * global error handling (NOT defaultOptions.onError, which was removed in v5).
+ * Confirmed via Context7 /tanstack/query docs for v5.101.0.
+ */
+function onGlobalError(error: unknown): void {
+ if (error instanceof SessionExpiredError) {
+ useCalendarStore.getState().setSessionExpired(true)
+ }
+}
const queryClient = new QueryClient({
+ queryCache: new QueryCache({ onError: onGlobalError }),
+ mutationCache: new MutationCache({ onError: onGlobalError }),
defaultOptions: {
queries: {
retry: 1,
diff --git a/apps/pwa/src/store/calendarStore.ts b/apps/pwa/src/store/calendarStore.ts
index 7c07dfb..34fa668 100644
--- a/apps/pwa/src/store/calendarStore.ts
+++ b/apps/pwa/src/store/calendarStore.ts
@@ -50,6 +50,12 @@ export interface CalendarStore {
/** UID of the most recently enqueued write. SyncStateToast polls sync-status for this. */
lastSyncedUid: string | null
+ // ── Session-expiry flag (Plan 06-05 / D-11) ─────────────────────────────
+ // Set to true by the global QueryCache/MutationCache onError handler in main.tsx
+ // when a SessionExpiredError is detected from any query or mutation.
+ // Drives the "Session expired / Signing you back in…" interstitial in CalendarShell.
+ sessionExpired: boolean
+
setSelectedView: (view: string) => void
setSelectedDate: (date: string) => void
setOpenEventId: (id: string | null) => void
@@ -77,6 +83,14 @@ export interface CalendarStore {
* Pass null to dismiss the toast.
*/
setLastSyncedUid: (uid: string | null) => void
+
+ /**
+ * Arm the session-expiry interstitial (D-11).
+ * Called imperatively from the global QueryCache/MutationCache onError handler
+ * (outside React, via getState().setSessionExpired) when a SessionExpiredError
+ * is caught from any query or mutation.
+ */
+ setSessionExpired: (expired: boolean) => void
}
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -141,6 +155,9 @@ export const useCalendarStore = create((set) => ({
deleteDialogUid: null,
lastSyncedUid: null,
+ // Session-expiry flag — false by default; set by global QueryCache/MutationCache handler
+ sessionExpired: false,
+
setSelectedView: (view: string) => {
set({ selectedView: view })
// Persist to localStorage keyed by breakpoint group
@@ -166,4 +183,6 @@ export const useCalendarStore = create((set) => ({
set({ deleteDialogOpen: open, deleteDialogUid: uid }),
setLastSyncedUid: (uid: string | null) => set({ lastSyncedUid: uid }),
+
+ setSessionExpired: (expired: boolean) => set({ sessionExpired: expired }),
}))