Milestone v1.0: FamilySync MVP #1
@@ -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 <AuthSplash state="redirecting" />
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<AuthSplash
|
||||
state="redirecting"
|
||||
heading="Session expired"
|
||||
body="Signing you back in…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Calendar content ───────────────────────────────────────────────────────
|
||||
|
||||
// The content panel (right of sidebar on desktop, full-width on phone).
|
||||
|
||||
+22
-1
@@ -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,
|
||||
|
||||
@@ -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<CalendarStore>((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<CalendarStore>((set) => ({
|
||||
set({ deleteDialogOpen: open, deleteDialogUid: uid }),
|
||||
|
||||
setLastSyncedUid: (uid: string | null) => set({ lastSyncedUid: uid }),
|
||||
|
||||
setSessionExpired: (expired: boolean) => set({ sessionExpired: expired }),
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user