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
3 changed files with 79 additions and 2 deletions
Showing only changes of commit 139ef00ed4 - Show all commits
+38 -1
View File
@@ -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
View File
@@ -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,
+19
View File
@@ -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 }),
}))