/** * App — BrowserRouter shell with react-router declarative routing. * * Routes: * / → redirect to /calendar * /calendar → CalendarShell * /lists → ListsIndex * /lists/:listId → ListDetail (placeholder for Plan 04-04) * * Layout: * AppNav is rendered as a PERSISTENT sibling of (outside any Route), * so it survives route transitions (FIX 3). AppNav provides: * - Phone (≤767px): 48px top bar (the header above the main content area) * - Desktop (≥768px): 240px left sidebar with nav links + colour legend * * BottomTabBar is also a sibling of so the tab state remains consistent. * On desktop (≥768px) BottomTabBar is hidden via CSS (FIX 4). * * Auth flow: * /api/me is fetched once at the App level. While loading or on error the * full-screen AuthSplash overlay is shown (CalendarShell's AuthSplash is * positioned fixed with z-index so it covers AppNav too). AppNav renders * with empty / partial data until auth resolves — this is fine because the * AuthSplash overlay hides the AppNav during that window. * * navigateFallback ('/index.html') in vite.config.ts covers SPA deep-links to * /lists/* — the SW denylist only excludes /callback, /api/, and /health, so * /lists/* is served from cache correctly. * * Phase 5 additions: * - PermissionDeniedBanner: shown below AppNav when OS permission revoked (D-10) * - SettingsSheet: avatar-triggered bottom sheet with master notifications toggle (D-09) * * Phase 6 fix (FIX 3): * - AppNav lifted from CalendarShell to this level so /lists keeps the nav chrome. * - /api/me query shared so AppNav has user data on all routes. */ import { useState, useMemo } from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { CalendarShell } from './components/CalendarShell.js'; import { ListsIndex } from './routes/ListsIndex.js'; import { ListDetail } from './routes/ListDetail.js'; import { BottomTabBar } from './components/BottomTabBar.js'; import { AppNav } from './components/AppNav.js'; import { PushPermissionPrompt } from './components/PushPermissionPrompt.js'; import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js'; import { SettingsSheet } from './components/SettingsSheet.js'; import { fetchMe } from './api/client.js'; function isPhone(): boolean { return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches; } export default function App() { const [settingsOpen, setSettingsOpen] = useState(false); const phone = isPhone(); // Fetch current user once at the app shell level so AppNav has member data on // ALL routes. This is the same query key (['me']) used by CalendarShell, so // TanStack Query deduplicates the request — no double fetch. const meQuery = useQuery({ queryKey: ['me'], queryFn: fetchMe, retry: false, staleTime: 5 * 60 * 1000, }); // Derive members for AppNav from the shared /api/me response const members = useMemo(() => { if (!meQuery.data?.user) return []; return [ { id: String(meQuery.data.user.id), name: meQuery.data.user.displayName ?? 'Member', color: meQuery.data.user.color, }, ]; }, [meQuery.data]); // Outer layout: phone = column, desktop = row (AppNav sidebar + content) const outerStyle: React.CSSProperties = { height: '100dvh', display: 'flex', flexDirection: phone ? 'column' : 'row', background: 'var(--color-surface)', color: 'var(--color-text-primary)', fontFamily: 'var(--font-family-base)', overflow: 'hidden', }; // Content area: fills remaining space next to / below AppNav const contentStyle: React.CSSProperties = { flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'relative', }; return ( {/* Permission-denied banner: shown when OS revoked and user had notifications on (D-10) */}
{/* Persistent AppNav — phone: top bar; desktop: left sidebar. Renders on ALL routes so nav chrome survives route transitions (FIX 3). */} setSettingsOpen(true)} /> {/* Main content area — all routes render here */}
} /> } /> } /> } />
{/* BottomTabBar — phone-only (hidden ≥768px via CSS, FIX 4) */} {/* Post-install permission prompt (D-08): renders only when isInstalled() is true and Notification.permission === 'default' and not dismissed */} {/* Settings sheet — master notifications toggle (D-09), opened from avatar */} setSettingsOpen(false)} />
); }