From c0088edf443f960ab4f472142ef57d318a1595a0 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 12:03:52 -0400 Subject: [PATCH] feat(04-01): wire BrowserRouter + BottomTabBar + empty Lists surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- apps/pwa/src/App.tsx | 35 ++- apps/pwa/src/api/listsClient.ts | 47 +++ apps/pwa/src/components/AppNav.tsx | 38 ++- apps/pwa/src/components/BottomTabBar.tsx | 88 ++++++ .../pwa/src/components/CalendarShell.test.tsx | 5 +- apps/pwa/src/hooks/useListSSE.test.ts | 2 + apps/pwa/src/routes/ListDetail.test.tsx | 2 + apps/pwa/src/routes/ListDetail.tsx | 46 +++ apps/pwa/src/routes/ListsIndex.tsx | 267 ++++++++++++++++++ apps/pwa/src/store/listsStore.ts | 36 +++ 10 files changed, 563 insertions(+), 3 deletions(-) create mode 100644 apps/pwa/src/api/listsClient.ts create mode 100644 apps/pwa/src/components/BottomTabBar.tsx create mode 100644 apps/pwa/src/routes/ListDetail.tsx create mode 100644 apps/pwa/src/routes/ListsIndex.tsx create mode 100644 apps/pwa/src/store/listsStore.ts diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index b32d068..a0fc658 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -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 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 { ListsIndex } from './routes/ListsIndex.js' +import { ListDetail } from './routes/ListDetail.js' +import { BottomTabBar } from './components/BottomTabBar.js' export default function App() { - return + return ( + + + } /> + } /> + } /> + } /> + + + + ) } diff --git a/apps/pwa/src/api/listsClient.ts b/apps/pwa/src/api/listsClient.ts new file mode 100644 index 0000000..fbd2b57 --- /dev/null +++ b/apps/pwa/src/api/listsClient.ts @@ -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 { + 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 { + return apiFetch('/lists').then((r) => r.json() as Promise) +} diff --git a/apps/pwa/src/components/AppNav.tsx b/apps/pwa/src/components/AppNav.tsx index 99c4887..faa3cc2 100644 --- a/apps/pwa/src/components/AppNav.tsx +++ b/apps/pwa/src/components/AppNav.tsx @@ -11,6 +11,8 @@ * - 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' 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[] }) { + 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 ( + ) +} diff --git a/apps/pwa/src/components/CalendarShell.test.tsx b/apps/pwa/src/components/CalendarShell.test.tsx index 6d10b02..e5a61f5 100644 --- a/apps/pwa/src/components/CalendarShell.test.tsx +++ b/apps/pwa/src/components/CalendarShell.test.tsx @@ -18,6 +18,7 @@ import React from 'react' import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' 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) @@ -128,7 +129,9 @@ function makeQueryClient() { function renderWithClient(ui: React.ReactElement) { const client = makeQueryClient() return render( - {ui}, + + {ui} + , ) } diff --git a/apps/pwa/src/hooks/useListSSE.test.ts b/apps/pwa/src/hooks/useListSSE.test.ts index 5a27b26..a7844a5 100644 --- a/apps/pwa/src/hooks/useListSSE.test.ts +++ b/apps/pwa/src/hooks/useListSSE.test.ts @@ -10,6 +10,8 @@ * Run: pnpm --filter @familysync/pwa test */ +import { describe, it } from 'vitest' + describe('useListSSE — D-11 bounded backoff', () => { it.todo('starts connected when EventSource fires the open event') it.todo('attempts to reconnect with exponential backoff on error') diff --git a/apps/pwa/src/routes/ListDetail.test.tsx b/apps/pwa/src/routes/ListDetail.test.tsx index da69cd3..1440f26 100644 --- a/apps/pwa/src/routes/ListDetail.test.tsx +++ b/apps/pwa/src/routes/ListDetail.test.tsx @@ -9,6 +9,8 @@ * Run: pnpm --filter @familysync/pwa test */ +import { describe, it } from 'vitest' + describe('ListDetail — D-07 optimistic update + rollback', () => { it.todo('checking an item immediately updates the UI before server responds') it.todo('unchecking an item immediately updates the UI before server responds') diff --git a/apps/pwa/src/routes/ListDetail.tsx b/apps/pwa/src/routes/ListDetail.tsx new file mode 100644 index 0000000..c753314 --- /dev/null +++ b/apps/pwa/src/routes/ListDetail.tsx @@ -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 ( +
+
+ List view coming soon +
+
+ Full list detail with live sync is being built in Phase 4 Plan 4. +
+
+ ) +} diff --git a/apps/pwa/src/routes/ListsIndex.tsx b/apps/pwa/src/routes/ListsIndex.tsx new file mode 100644 index 0000000..883ebd8 --- /dev/null +++ b/apps/pwa/src/routes/ListsIndex.tsx @@ -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 ( +
+
+ No lists yet +
+
+ Tap + to create your first shared list… +
+
+ ) +} + +// ── List Card (placeholder for Plan 04-03 full implementation) ───────────── + +function ListCard({ list }: { list: List }) { + return ( +
+
+
+ {list.name} +
+ {list.isShared && ( +
+ Shared +
+ )} +
+
+ ) +} + +// ── 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 ( +
+ {/* Header */} +
+

+ Lists +

+ + {/* FAB / inline button — non-functional placeholder until Plan 04-03 */} + +
+ + {/* Content area */} +
+ {/* Loading state */} + {isLoading && ( +
+ Loading lists… +
+ )} + + {/* Error state */} + {isError && !isLoading && ( +
+
+ Could not load lists +
+ +
+ )} + + {/* Empty state */} + {!isLoading && !isError && lists.length === 0 && } + + {/* List cards */} + {!isLoading && !isError && lists.length > 0 && ( +
+ {lists.map((list) => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/apps/pwa/src/store/listsStore.ts b/apps/pwa/src/store/listsStore.ts new file mode 100644 index 0000000..86a34a3 --- /dev/null +++ b/apps/pwa/src/store/listsStore.ts @@ -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()((set) => ({ + activeTab: 'calendar', + createListSheetOpen: false, + + setActiveTab: (tab) => set({ activeTab: tab }), + setCreateListSheetOpen: (open) => set({ createListSheetOpen: open }), +}))