From 051874ba12985f02faf6d6170f31ab0d93c3d3f5 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 10 Jun 2026 16:02:02 -0400 Subject: [PATCH] fix(06): make AppNav persistent across routes so Lists keeps the nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lift AppNav from CalendarShell to App.tsx as a sibling of - App.tsx fetches /api/me (same query key as CalendarShell — deduplicated by TanStack Query) - App.tsx provides the outer layout (phone: column, desktop: row) with AppNav always rendered - CalendarShell simplified: no longer manages AppNav, outer flex layout stays in App.tsx - AuthSplash gains overlay prop (position:fixed inset:0 z-index:999) so it covers AppNav when needed - CalendarShell uses AuthSplash with overlay=true so auth splashes cover full viewport - Remove onOpenSettings prop from CalendarShell (wired directly in App.tsx to SettingsSheet) - Desktop sidebar nav (FamilySync brand, Calendar/Lists links) now persists on /lists route --- apps/pwa/src/App.tsx | 110 ++++++++++++++-- apps/pwa/src/components/AuthSplash.tsx | 12 +- apps/pwa/src/components/CalendarShell.tsx | 153 +++++++++------------- 3 files changed, 167 insertions(+), 108 deletions(-) diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index 6812027..4938c31 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -7,10 +7,21 @@ * /lists → ListsIndex * /lists/:listId → ListDetail (placeholder for Plan 04-04) * - * BottomTabBar is rendered as a sibling of so it persists across route - * changes. On desktop (≥768px) AppNav sidebar handles navigation — BottomTabBar - * is only visible on phone via CSS, but we render it in the tree at all sizes so - * the tab state remains consistent. + * 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 @@ -19,36 +30,109 @@ * 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 } from 'react' +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) */} - - } /> - setSettingsOpen(true)} />} +
+ {/* 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 */} diff --git a/apps/pwa/src/components/AuthSplash.tsx b/apps/pwa/src/components/AuthSplash.tsx index a4d3992..55e5f26 100644 --- a/apps/pwa/src/components/AuthSplash.tsx +++ b/apps/pwa/src/components/AuthSplash.tsx @@ -38,12 +38,20 @@ export interface AuthSplashProps { * Defaults to "Taking you to the sign-in page…" (cold-load copy per UI-SPEC §Surface 1). */ body?: string + /** + * When true, renders with position:fixed inset:0 z-index:999 so the splash + * covers the entire viewport including the persistent AppNav (FIX 3). + * Use this when CalendarShell (a child of the app shell) needs to show an + * auth interstitial that hides the nav chrome. + */ + overlay?: boolean } export function AuthSplash({ state, heading = 'Signing you in', body = 'Taking you to the sign-in page…', + overlay = false, }: AuthSplashProps) { const isDeadEnd = state === 'dead-end' const showSpinner = !isDeadEnd @@ -53,7 +61,9 @@ export function AuthSplash({ role="status" aria-label="Signing you in" style={{ - height: '100dvh', + ...(overlay + ? { position: 'fixed', inset: 0, zIndex: 999 } + : { height: '100dvh' }), display: 'flex', flexDirection: 'column', alignItems: 'center', diff --git a/apps/pwa/src/components/CalendarShell.tsx b/apps/pwa/src/components/CalendarShell.tsx index c8346c5..3e92240 100644 --- a/apps/pwa/src/components/CalendarShell.tsx +++ b/apps/pwa/src/components/CalendarShell.tsx @@ -17,9 +17,10 @@ * - Navigation: Schedule-X's built-in header is the sole navigation bar (Today/‹›/view switcher * + weekday-name row). The custom ViewToolbar and calendar-controls plugin have been removed. * - * Layout: - * - Phone (≤767px): AppNav top bar → Schedule-X (header + grid) - * - Tablet/Desktop (≥768px): AppNav left sidebar (240px) + main area (Schedule-X header + grid) + * Layout (FIX 3): + * AppNav has been lifted to App.tsx and is no longer rendered here. CalendarShell fills + * its parent container (a flex:1 area in App.tsx). Auth splashes use position:fixed to + * overlay everything including AppNav. * * State branches: * isLoading (initial) → SkeletonCalendar @@ -51,7 +52,6 @@ import { EventDetailPopover } from './EventDetailPopover.js' import { EventForm } from './EventForm.js' import { DeleteConfirmationDialog } from './DeleteConfirmationDialog.js' import { SyncStateToast } from './SyncStateToast.js' -import { AppNav } from './AppNav.js' import { ColorLegend } from './ColorLegend.js' import { SkeletonCalendar } from './SkeletonCalendar.js' import { InstallPrompt } from './InstallPrompt.js' @@ -73,7 +73,7 @@ function isPhone(): boolean { // ── Component ────────────────────────────────────────────────────────────── -export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void } = {}) { +export function CalendarShell() { // Use per-field selectors so CalendarShell does NOT subscribe to openEventId. // Without selectors, any popover open/close triggers a full re-render here, // which rebuilds the Schedule-X config and causes a visible calendar flash (Bug B). @@ -93,7 +93,8 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void // AuthSplash shows the tap-to-retry recovery instead of spinning forever (D-11). const [loginRedirectExhausted, setLoginRedirectExhausted] = useState(false) - // Fetch current user to build per-member color config + // Fetch current user to build per-member color config. + // Same query key (['me']) as App.tsx — TanStack Query deduplicates, no double fetch. const meQuery = useQuery({ queryKey: ['me'], queryFn: fetchMe, @@ -124,7 +125,7 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void // Create plugins once (stable across renders) const eventsService = useState(() => createEventsServicePlugin())[0] - // Build members list from /api/me for AppNav + ColorLegend + // Build members list from /api/me for ColorLegend (AppNav uses the same data from App.tsx) const members = useMemo(() => { if (!meQuery.data?.user) return [] return [ @@ -255,6 +256,9 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void // Gate the calendar render on auth state so no calendar shell, skeleton, or // "Sign-in required" alert paints before Authelia (D-10 success criterion 5). // + // Auth splashes use position:fixed (inset:0, z-index:999) to overlay the entire + // viewport including the persistent AppNav in App.tsx (FIX 3). + // // loading: meQuery is still pending — show the neutral "Signing you in" splash. // No skeleton, no CalendarContent — nothing app-specific until authed. // isError: meQuery failed — the maybeRedirectToLogin() useEffect fires immediately @@ -263,18 +267,18 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void // returns false and this splash stays — the dead-end "Tap to try again" is // rendered by AuthSplash's dead-end state (wired in Task 3 via sessionExpired). if (meQuery.isLoading) { - return + return } if (meQuery.isError) { // loginRedirectExhausted: the one-shot guard was already set (prior navigation), // so maybeRedirectToLogin() returned false — show dead-end tap-to-retry (D-11). - // Otherwise the useEffect at line 196 already fired maybeRedirectToLogin() and + // Otherwise the useEffect above already fired maybeRedirectToLogin() and // the browser is navigating — show the redirecting splash while it does. if (loginRedirectExhausted) { - return + return } - return + return } // ── Session-expiry interstitial (D-11) ───────────────────────────────────── @@ -287,13 +291,14 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void state="redirecting" heading="Session expired" body="Signing you back in…" + overlay /> ) } // ── Calendar content ─────────────────────────────────────────────────────── - // The content panel (right of sidebar on desktop, full-width on phone). + // The content panel fills its parent container (a flex:1 area set by App.tsx). // // This is a plain JSX value, NOT a nested `function CalendarContent()` rendered // as ``. A component defined inside render has a new identity @@ -395,98 +400,28 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void ) - // ── Full layout ──────────────────────────────────────────────────────────── + // ── Layout ───────────────────────────────────────────────────────────────── + // CalendarShell fills its container (App.tsx provides the outer layout with AppNav). + // Both phone and desktop use the same flex column structure here since AppNav is + // now outside this component. - if (phone) { - // Phone: stacked layout — AppNav top bar + content below + FAB - return ( -
- - - {calendarContent} - - - {/* New Event FAB — phone: bottom-right floating action button (UI-SPEC §Interaction Contract) */} - - - {/* EventForm modal — conditionally rendered while eventFormOpen */} - {eventFormOpen && } - - {/* DeleteConfirmationDialog — always mounted; renders nothing when deleteDialogOpen is false */} - - - {/* SyncStateToast — always mounted; renders nothing when lastSyncedUid is null */} - -
- ) - } - - // Tablet/Desktop: sidebar + main area return (
- {/* Left sidebar: AppNav (includes ColorLegend on desktop) */} - + - {/* Main content area */} -
- - - {/* New Event toolbar button — tablet/desktop (UI-SPEC §Interaction Contract) */} + {/* New Event toolbar button — desktop only (phone uses FAB below) */} + {!phone && (
void New Event
+ )} - {calendarContent} -
+ {calendarContent} + + {/* Phone: New Event FAB — bottom-right floating action button (UI-SPEC §Interaction Contract) */} + {phone && ( + + )} {/* EventDetailPopover — standalone mode driven by Zustand openEventId */}