feat(04-01): wire BrowserRouter + BottomTabBar + empty Lists surface

- App.tsx: BrowserRouter with /calendar, /lists, /lists/:listId routes; / redirects to /calendar
- BottomTabBar.tsx: fixed-bottom 56px tab bar with Calendar + Lists NavLinks, active accent
- AppNav.tsx: add Calendar/Lists NavLinks to desktop sidebar (≥768px)
- ListsIndex.tsx: full-height surface with isLoading/isError/empty state branches; "+ New List" FAB placeholder
- ListDetail.tsx: placeholder stub for /lists/:listId (Plan 04-04 fills in)
- listsStore.ts: Zustand UI-only store (activeTab, createListSheetOpen)
- listsClient.ts: fetchLists + List/ListItem types (initial; Plans 04-02/03 expand)
- Fix Wave-0 RED stubs: add vitest imports so stubs execute (todo) not error on import
- Fix CalendarShell.test.tsx: wrap renderWithClient in MemoryRouter (AppNav uses NavLink)
This commit is contained in:
Lucas Berger
2026-06-09 12:03:52 -04:00
parent 2f25b15949
commit c0088edf44
10 changed files with 563 additions and 3 deletions
+34 -1
View File
@@ -1,5 +1,38 @@
/**
* App — BrowserRouter shell with react-router declarative routing.
*
* Routes:
* / → redirect to /calendar
* /calendar → CalendarShell
* /lists → ListsIndex
* /lists/:listId → ListDetail (placeholder for Plan 04-04)
*
* BottomTabBar is rendered as a sibling of <Routes> 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.
*
* 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.
*/
import { BrowserRouter, Routes, Route, Navigate } from 'react-router'
import { CalendarShell } from './components/CalendarShell.js' import { CalendarShell } from './components/CalendarShell.js'
import { ListsIndex } from './routes/ListsIndex.js'
import { ListDetail } from './routes/ListDetail.js'
import { BottomTabBar } from './components/BottomTabBar.js'
export default function App() { export default function App() {
return <CalendarShell /> return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to="/calendar" replace />} />
<Route path="/calendar" element={<CalendarShell />} />
<Route path="/lists" element={<ListsIndex />} />
<Route path="/lists/:listId" element={<ListDetail />} />
</Routes>
<BottomTabBar />
</BrowserRouter>
)
} }
+47
View File
@@ -0,0 +1,47 @@
/**
* Typed API client for the FamilySync lists API.
*
* credentials: 'include' is required so the OIDC session cookie is sent with
* every request (same pattern as client.ts).
*
* This file provides only the functions needed in Plan 04-01 (ListsIndex empty
* state). Plans 04-02 and 04-03 expand it with createList, deleteList, item CRUD.
*/
const BASE = '/api'
async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
...init,
})
if (!res.ok) throw new Error(`${init?.method ?? 'GET'} ${path} failed: ${res.status}`)
return res
}
// ── Types ──────────────────────────────────────────────────────────────────
export interface List {
id: number
name: string
isShared: boolean
ownerId: number
}
export interface ListItem {
id: number
listId: number
text: string
checked: boolean
rank: string
}
export interface ListsResponse {
lists: List[]
}
// ── Functions ──────────────────────────────────────────────────────────────
export async function fetchLists(): Promise<ListsResponse> {
return apiFetch('/lists').then((r) => r.json() as Promise<ListsResponse>)
}
+37 -1
View File
@@ -11,6 +11,8 @@
* - Grid is primary focal point; AppNav is secondary chrome * - Grid is primary focal point; AppNav is secondary chrome
*/ */
import { NavLink } from 'react-router'
import { CalendarDays, List } from 'lucide-react'
import { ColorLegend, type LegendMember } from './ColorLegend.js' import { ColorLegend, type LegendMember } from './ColorLegend.js'
interface AppNavProps { interface AppNavProps {
@@ -102,8 +104,23 @@ function PhoneNav({
) )
} }
/** Tablet/Desktop: 240px left sidebar — app name + color legend */ /** Tablet/Desktop: 240px left sidebar — app name + nav links + color legend */
function DesktopNav({ members }: { members: LegendMember[] }) { function DesktopNav({ members }: { members: LegendMember[] }) {
const navLinkStyle = ({ isActive }: { isActive: boolean }): React.CSSProperties => ({
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: 'var(--space-2) var(--space-3, 12px)',
borderRadius: 'var(--space-1, 4px)',
textDecoration: 'none',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: isActive ? 600 : 400,
color: isActive ? 'var(--color-member-0)' : 'var(--color-text-primary)',
background: isActive ? 'color-mix(in srgb, var(--color-member-0) 10%, transparent)' : 'transparent',
minHeight: '44px',
transition: 'color 0.1s ease, background 0.1s ease',
})
return ( return (
<nav <nav
style={{ style={{
@@ -133,6 +150,25 @@ function DesktopNav({ members }: { members: LegendMember[] }) {
FamilySync FamilySync
</div> </div>
{/* Section nav links */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-1, 4px)',
marginBottom: 'var(--space-6)',
}}
>
<NavLink to="/calendar" style={navLinkStyle} aria-label="Calendar">
<CalendarDays size={18} aria-hidden="true" />
Calendar
</NavLink>
<NavLink to="/lists" style={navLinkStyle} aria-label="Lists">
<List size={18} aria-hidden="true" />
Lists
</NavLink>
</div>
{/* Color legend */} {/* Color legend */}
<div <div
style={{ style={{
+88
View File
@@ -0,0 +1,88 @@
/**
* BottomTabBar — fixed-bottom navigation for phone (≤767px).
*
* UI-SPEC §BottomTabBar:
* - Fixed bottom, full-width, 56px + env(safe-area-inset-bottom)
* - Background: var(--color-surface-dim)
* - Border-top: 1px solid var(--color-border)
* - Two equal tabs: Calendar (CalendarDays icon) and Lists (List icon)
* - Active tab: icon + label in var(--color-member-0), 2px border-bottom indicator
* - Inactive tab: icon + label in var(--color-text-muted)
* - Touch target: min 44px guaranteed by 56px bar height
* - z-index: 200 (below dialogs at 300)
*
* Uses react-router NavLink for real-URL active detection (D-17).
* Only rendered on phone — DesktopNav handles desktop navigation.
*/
import { NavLink } from 'react-router'
import { CalendarDays, List } from 'lucide-react'
const tabBase: React.CSSProperties = {
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '3px',
textDecoration: 'none',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
lineHeight: 'var(--text-label-line-height, 1.4)',
fontFamily: 'var(--font-family-base)',
color: 'var(--color-text-muted)',
minHeight: '44px',
borderBottom: '2px solid transparent',
transition: 'color 0.1s ease, border-color 0.1s ease',
userSelect: 'none',
WebkitTapHighlightColor: 'transparent',
}
const tabActiveOverride: React.CSSProperties = {
color: 'var(--color-member-0)',
borderBottom: '2px solid var(--color-member-0)',
}
export function BottomTabBar() {
return (
<nav
aria-label="Main navigation"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 'calc(56px + env(safe-area-inset-bottom, 0px))',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
display: 'flex',
background: 'var(--color-surface-dim)',
borderTop: '1px solid var(--color-border)',
zIndex: 200,
}}
>
<NavLink
to="/calendar"
aria-label="Calendar"
style={({ isActive }) => ({
...tabBase,
...(isActive ? tabActiveOverride : {}),
})}
>
<CalendarDays size={22} aria-hidden="true" />
<span>Calendar</span>
</NavLink>
<NavLink
to="/lists"
aria-label="Lists"
style={({ isActive }) => ({
...tabBase,
...(isActive ? tabActiveOverride : {}),
})}
>
<List size={22} aria-hidden="true" />
<span>Lists</span>
</NavLink>
</nav>
)
}
@@ -18,6 +18,7 @@ import React from 'react'
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react' import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router'
// window.matchMedia is polyfilled in src/test-setup.ts (loaded via vitest.config setupFiles) // window.matchMedia is polyfilled in src/test-setup.ts (loaded via vitest.config setupFiles)
@@ -128,7 +129,9 @@ function makeQueryClient() {
function renderWithClient(ui: React.ReactElement) { function renderWithClient(ui: React.ReactElement) {
const client = makeQueryClient() const client = makeQueryClient()
return render( return render(
<QueryClientProvider client={client}>{ui}</QueryClientProvider>, <MemoryRouter initialEntries={['/calendar']}>
<QueryClientProvider client={client}>{ui}</QueryClientProvider>
</MemoryRouter>,
) )
} }
+2
View File
@@ -10,6 +10,8 @@
* Run: pnpm --filter @familysync/pwa test * Run: pnpm --filter @familysync/pwa test
*/ */
import { describe, it } from 'vitest'
describe('useListSSE — D-11 bounded backoff', () => { describe('useListSSE — D-11 bounded backoff', () => {
it.todo('starts connected when EventSource fires the open event') it.todo('starts connected when EventSource fires the open event')
it.todo('attempts to reconnect with exponential backoff on error') it.todo('attempts to reconnect with exponential backoff on error')
+2
View File
@@ -9,6 +9,8 @@
* Run: pnpm --filter @familysync/pwa test * Run: pnpm --filter @familysync/pwa test
*/ */
import { describe, it } from 'vitest'
describe('ListDetail — D-07 optimistic update + rollback', () => { describe('ListDetail — D-07 optimistic update + rollback', () => {
it.todo('checking an item immediately updates the UI before server responds') it.todo('checking an item immediately updates the UI before server responds')
it.todo('unchecking an item immediately updates the UI before server responds') it.todo('unchecking an item immediately updates the UI before server responds')
+46
View File
@@ -0,0 +1,46 @@
/**
* ListDetail — Single list view (/lists/:listId).
*
* PLACEHOLDER: This is a stub route component for Plan 04-01.
* The full implementation (items, SSE, drag-to-reorder, etc.) is built in Plan 04-04.
*
* Purpose: allows react-router's /lists/:listId route to resolve without error
* so the BottomTabBar NavLink and any deep-link won't 404.
*/
export function ListDetail() {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
gap: '12px',
fontFamily: 'var(--font-family-base)',
padding: '0 var(--space-4, 16px)',
textAlign: 'center',
paddingBottom: 'calc(56px + env(safe-area-inset-bottom, 0px))',
}}
>
<div
style={{
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
}}
>
List view coming soon
</div>
<div
style={{
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-muted)',
}}
>
Full list detail with live sync is being built in Phase 4 Plan 4.
</div>
</div>
)
}
+267
View File
@@ -0,0 +1,267 @@
/**
* ListsIndex — Lists overview route (/lists).
*
* UI-SPEC §ListsIndex:
* - Full-height scrollable column with 16px horizontal padding
* - "Lists" heading
* - FAB ("+ New List") — fixed bottom-right on phone, top-right inline on desktop
* - Empty state: ListsEmptyState when no lists
* - State branches: isLoading → skeleton, isError → error+retry, data → list cards
*
* Data flow:
* TanStack Query ['lists'] → fetchLists → render list or empty state
*
* Note: Create + delete logic landed in Plans 04-02/04-03.
* The FAB is a non-functional placeholder here (Plan 04-03 wires CreateListSheet).
*/
import { useQuery } from '@tanstack/react-query'
import { Plus } from 'lucide-react'
import { fetchLists, type List } from '../api/listsClient.js'
// ── Empty State ────────────────────────────────────────────────────────────
function ListsEmptyState() {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
padding: '48px var(--space-4)',
gap: 'var(--space-4)',
textAlign: 'center',
fontFamily: 'var(--font-family-base)',
}}
>
<div
style={{
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
lineHeight: 'var(--text-heading-line-height, 1.25)',
}}
>
No lists yet
</div>
<div
style={{
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-text-muted)',
lineHeight: 'var(--text-body-line-height, 1.5)',
maxWidth: '280px',
}}
>
Tap + to create your first shared list&hellip;
</div>
</div>
)
}
// ── List Card (placeholder for Plan 04-03 full implementation) ─────────────
function ListCard({ list }: { list: List }) {
return (
<div
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: '8px',
padding: 'var(--space-4) var(--space-6, 24px)',
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
minHeight: '56px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontFamily: 'var(--font-family-base)',
cursor: 'pointer',
}}
>
<div>
<div
style={{
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
lineHeight: 'var(--text-heading-line-height, 1.25)',
}}
>
{list.name}
</div>
{list.isShared && (
<div
style={{
marginTop: '4px',
display: 'inline-block',
background: 'var(--color-surface-dim)',
color: 'var(--color-text-muted)',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
padding: '2px 8px',
borderRadius: '4px',
}}
>
Shared
</div>
)}
</div>
</div>
)
}
// ── Main Component ─────────────────────────────────────────────────────────
export function ListsIndex() {
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['lists'],
queryFn: fetchLists,
retry: 2,
staleTime: 30 * 1000,
})
const lists = data?.lists ?? []
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 0,
fontFamily: 'var(--font-family-base)',
// Bottom padding to clear the 56px fixed tab bar on phone
paddingBottom: 'calc(56px + env(safe-area-inset-bottom, 0px))',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: 'var(--space-6, 24px) var(--space-4, 16px) var(--space-4, 16px)',
flexShrink: 0,
}}
>
<h1
style={{
margin: 0,
fontSize: 'var(--text-display-size, 24px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
lineHeight: 'var(--text-display-line-height, 1.2)',
}}
>
Lists
</h1>
{/* FAB / inline button — non-functional placeholder until Plan 04-03 */}
<button
type="button"
aria-label="New list"
style={{
width: '44px',
height: '44px',
borderRadius: '50%',
background: 'var(--color-member-0)',
color: '#fff',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Plus size={22} aria-hidden="true" />
</button>
</div>
{/* Content area */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '0 var(--space-4, 16px)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Loading state */}
{isLoading && (
<div
style={{
padding: 'var(--space-8, 32px) 0',
color: 'var(--color-text-muted)',
fontSize: 'var(--text-body-size, 15px)',
textAlign: 'center',
}}
>
Loading lists&hellip;
</div>
)}
{/* Error state */}
{isError && !isLoading && (
<div
style={{
padding: 'var(--space-8, 32px) 0',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 'var(--space-4)',
}}
>
<div
style={{
color: 'var(--color-destructive)',
fontSize: 'var(--text-body-size, 15px)',
}}
>
Could not load lists
</div>
<button
type="button"
onClick={() => refetch()}
style={{
background: 'var(--color-member-0)',
color: '#fff',
border: 'none',
borderRadius: 'var(--space-1, 4px)',
padding: 'var(--space-2, 8px) var(--space-4, 16px)',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
cursor: 'pointer',
minHeight: '44px',
}}
>
Retry
</button>
</div>
)}
{/* Empty state */}
{!isLoading && !isError && lists.length === 0 && <ListsEmptyState />}
{/* List cards */}
{!isLoading && !isError && lists.length > 0 && (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-4, 16px)',
paddingBottom: 'var(--space-4, 16px)',
}}
>
{lists.map((list) => (
<ListCard key={list.id} list={list} />
))}
</div>
)}
</div>
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Zustand UI-state store for the lists surface.
*
* Owns ONLY UI-shape state — no server data ever enters this store.
* Server state (lists, items) lives in TanStack Query.
*
* Convention: follows calendarStore.ts pattern — no persist middleware,
* no immer. State is ephemeral; lists UI state does not need persistence.
*
* State contract:
* activeTab — which bottom-tab is selected (drives tab active-state)
* createListSheetOpen — whether the "New List" sheet/dialog is open
*/
import { create } from 'zustand'
// ── Types ──────────────────────────────────────────────────────────────────
export interface ListsStore {
// UI-only state — no server data
activeTab: 'calendar' | 'lists'
createListSheetOpen: boolean
setActiveTab: (tab: 'calendar' | 'lists') => void
setCreateListSheetOpen: (open: boolean) => void
}
// ── Store ──────────────────────────────────────────────────────────────────
export const useListsStore = create<ListsStore>()((set) => ({
activeTab: 'calendar',
createListSheetOpen: false,
setActiveTab: (tab) => set({ activeTab: tab }),
setCreateListSheetOpen: (open) => set({ createListSheetOpen: open }),
}))