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
+2
View File
@@ -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')
+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>
)
}