Add the settings home screen (mock 1p)
/settings now renders the Settings home: the "Our kitchen" shared household banner and grouped cards for Household (Regular dinners, Google Calendar, Household), Alerts (Notifications & Google Chat) and Admin (AI assistant, with the Admin tag and grape-300 inset ring). Each row links to its settings page. The Household row shows the live timezone and dinner time from GET /api/v1/settings/household, and the AI assistant row the live provider and model from GET /api/v1/settings/ai. Regular dinners, Google Calendar and Notifications show no status line: the server has no dinners, Google Calendar or notification endpoint yet. Shell tests now render the routes inside the API and query providers. Maestro task: 97b50e29-b8a7-4e99-918d-99fa1b239313 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+15
-2
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApiClient, createVerifiedSession } from './api/index.ts'
|
||||
import { createNetworkStatus } from './pwa/networkStatus.ts'
|
||||
import { mockViewport } from './test/matchMedia.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
||||
import App, { routes } from './App.tsx'
|
||||
|
||||
afterEach(() => {
|
||||
@@ -46,7 +46,7 @@ describe('App routes', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
|
||||
it.each(routes.filter(({ path }) => path !== '/settings').map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
|
||||
'%s renders its placeholder page inside the shell',
|
||||
async (path, title) => {
|
||||
mockViewport(390)
|
||||
@@ -57,6 +57,19 @@ describe('App routes', () => {
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('/settings renders the settings home inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
'/api/v1/me': () => jsonResponse(200, ME),
|
||||
'/api/v1/settings/household': () => jsonResponse(200, { timezone: 'America/New_York', dinner_time: '17:30' }),
|
||||
'/api/v1/settings/ai': () => jsonResponse(200, { provider: 'anthropic', model: 'claude-sonnet-4-5' }),
|
||||
})
|
||||
renderAt('/settings')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Settings' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByText('Anthropic · claude-sonnet-4-5')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App bootstrap', () => {
|
||||
|
||||
+8
-2
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Route, Routes } from 'react-router'
|
||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
@@ -6,6 +6,7 @@ import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||
import { OfflinePill } from './pwa/OfflinePill.tsx'
|
||||
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
||||
import { SettingsPage } from './settings/SettingsPage.tsx'
|
||||
import { AppShell } from './shell/AppShell.tsx'
|
||||
|
||||
// Every route the app serves, with the heading its placeholder page shows.
|
||||
@@ -27,13 +28,18 @@ export const routes = [
|
||||
{ path: '/settings/calendar', title: 'Calendar' },
|
||||
] as const
|
||||
|
||||
// The screens built so far; every other route shows its placeholder page.
|
||||
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/settings': <SettingsPage />,
|
||||
}
|
||||
|
||||
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
{routes.map(({ path, title }) => (
|
||||
<Route key={path} path={path} element={<PlaceholderPage title={title} />} />
|
||||
<Route key={path} path={path} element={screens[path] ?? <PlaceholderPage title={title} />} />
|
||||
))}
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/* Settings home, handoff mock 1p. */
|
||||
.page {
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
padding-top: 6px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 26px;
|
||||
background: var(--color-accent-2-100);
|
||||
color: var(--color-accent-2-900);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent-2-300);
|
||||
color: var(--color-accent-2-900);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.bannerText {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.bannerTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
margin: 0;
|
||||
padding: 18px 4px 8px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.group {
|
||||
padding: 0;
|
||||
border-radius: 26px;
|
||||
}
|
||||
|
||||
.admin {
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent-300);
|
||||
}
|
||||
|
||||
.rows {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Rows in one card are split by a hairline inset to the row padding. */
|
||||
.item + .item {
|
||||
border-top: 1px solid var(--color-neutral-300);
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
.item + .item .row {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 26px;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.rowTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.statusSkeleton {
|
||||
width: 60%;
|
||||
height: 12px;
|
||||
margin-top: 3px;
|
||||
background: var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.tag {
|
||||
flex: none;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { formatClockTime } from './api.ts'
|
||||
import { SettingsPage } from './SettingsPage.tsx'
|
||||
|
||||
const HOUSEHOLD = { timezone: 'America/New_York', dinner_time: '17:30' }
|
||||
const AI = { provider: 'anthropic', model: 'claude-sonnet-4-5' }
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function stubSettings(household = () => jsonResponse(200, HOUSEHOLD), ai = () => jsonResponse(200, AI)) {
|
||||
return stubFetchByPath({ '/api/v1/settings/household': household, '/api/v1/settings/ai': ai })
|
||||
}
|
||||
|
||||
function CurrentPath() {
|
||||
return <p data-testid="path">{useLocation().pathname}</p>
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
const api = createApiClient(fakeLocation('/settings'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<MemoryRouter initialEntries={['/settings']}>
|
||||
<Routes>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<CurrentPath />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function row(name: string) {
|
||||
return screen.getByRole('link', { name: new RegExp(`^${name}`) })
|
||||
}
|
||||
|
||||
describe('SettingsPage', () => {
|
||||
it('renders the title and the shared household banner', async () => {
|
||||
stubSettings()
|
||||
renderSettings()
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Settings' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Our kitchen')).toBeInTheDocument()
|
||||
expect(screen.getByText('Shared household · changes apply to everyone')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Anthropic · claude-sonnet-4-5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('groups the rows under Household, Alerts and Admin', async () => {
|
||||
stubSettings()
|
||||
renderSettings()
|
||||
const group = (name: string) => within(screen.getByRole('region', { name }))
|
||||
expect(group('Household').getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([
|
||||
'/settings/regulars',
|
||||
'/settings/calendar',
|
||||
'/settings/household',
|
||||
])
|
||||
expect(group('Alerts').getByRole('link')).toHaveAttribute('href', '/settings/notifications')
|
||||
expect(group('Admin').getByRole('link')).toHaveAttribute('href', '/settings/ai')
|
||||
await screen.findByText('Anthropic · claude-sonnet-4-5')
|
||||
})
|
||||
|
||||
it('shows the live household and AI assistant status lines', async () => {
|
||||
const fetchMock = stubSettings()
|
||||
renderSettings()
|
||||
expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
||||
expect(within(row('AI assistant')).getByText('Anthropic · claude-sonnet-4-5')).toBeInTheDocument()
|
||||
expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
||||
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
||||
'/api/v1/settings/ai',
|
||||
'/api/v1/settings/household',
|
||||
])
|
||||
})
|
||||
|
||||
it('shows another provider and model as the settings name them', async () => {
|
||||
stubSettings(undefined, () => jsonResponse(200, { provider: 'openai_compatible', model: 'llama3.1:8b' }))
|
||||
renderSettings()
|
||||
expect(await screen.findByText('OpenAI-compatible · llama3.1:8b')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows no status line for rows the server has no endpoint for yet', async () => {
|
||||
stubSettings()
|
||||
renderSettings()
|
||||
await screen.findByText('Anthropic · claude-sonnet-4-5')
|
||||
expect(row('Regular dinners')).toHaveAccessibleName('Regular dinners')
|
||||
expect(row('Google Calendar')).toHaveAccessibleName('Google Calendar')
|
||||
expect(row('Notifications & Google Chat')).toHaveAccessibleName('Notifications & Google Chat')
|
||||
})
|
||||
|
||||
it('marks the AI assistant row Admin and rings its card in grape-300', async () => {
|
||||
stubSettings()
|
||||
renderSettings()
|
||||
expect(within(row('AI assistant')).getByText('Admin')).toBeInTheDocument()
|
||||
const adminCard = screen.getByRole('region', { name: 'Admin' }).querySelector('ul')!.parentElement!
|
||||
expect(declaredStyle(adminCard).boxShadow).toBe('inset 0 0 0 2px var(--color-accent-300)')
|
||||
const householdCard = screen.getByRole('region', { name: 'Household' }).querySelector('ul')!.parentElement!
|
||||
expect(declaredStyle(householdCard).boxShadow).toBe('')
|
||||
await screen.findByText('Anthropic · claude-sonnet-4-5')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Regular dinners', '/settings/regulars'],
|
||||
['Google Calendar', '/settings/calendar'],
|
||||
['Household', '/settings/household'],
|
||||
['Notifications & Google Chat', '/settings/notifications'],
|
||||
['AI assistant', '/settings/ai'],
|
||||
])('the %s row navigates to %s', async (name, path) => {
|
||||
stubSettings()
|
||||
renderSettings()
|
||||
await screen.findByText('Anthropic · claude-sonnet-4-5')
|
||||
fireEvent.click(row(name))
|
||||
expect(screen.getByTestId('path')).toHaveTextContent(path)
|
||||
})
|
||||
|
||||
it('gives every row a tap target of at least 44 px', async () => {
|
||||
stubSettings()
|
||||
renderSettings()
|
||||
await screen.findByText('Anthropic · claude-sonnet-4-5')
|
||||
for (const link of screen.getAllByRole('link')) expectMinHitArea(link)
|
||||
})
|
||||
|
||||
it('reports a failed status with a retry that loads it', async () => {
|
||||
let householdCalls = 0
|
||||
stubSettings(() => {
|
||||
householdCalls += 1
|
||||
return householdCalls === 1 ? errorResponse(500, 'internal') : jsonResponse(200, HOUSEHOLD)
|
||||
})
|
||||
renderSettings()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Settings could not load: internal message')
|
||||
expect(screen.getByText('Anthropic · claude-sonnet-4-5')).toBeInTheDocument()
|
||||
expect(row('Household')).toHaveAccessibleName('Household')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('refuses an AI provider it has no name for', async () => {
|
||||
stubSettings(undefined, () => jsonResponse(200, { provider: 'mystery', model: 'm1' }))
|
||||
renderSettings()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('unknown AI provider "mystery"')
|
||||
expect(row('AI assistant')).toHaveAccessibleName('AI assistant Admin')
|
||||
})
|
||||
|
||||
it('refuses a dinner time that is not HH:MM', async () => {
|
||||
stubSettings(() => jsonResponse(200, { timezone: 'UTC', dinner_time: '5:30pm' }))
|
||||
renderSettings()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('dinner_time "5:30pm" is not HH:MM')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatClockTime', () => {
|
||||
it.each([
|
||||
['00:05', '12:05 am'],
|
||||
['09:00', '9:00 am'],
|
||||
['12:00', '12:00 pm'],
|
||||
['17:30', '5:30 pm'],
|
||||
['23:59', '11:59 pm'],
|
||||
])('formats %s as %s', (time, formatted) => {
|
||||
expect(formatClockTime(time)).toBe(formatted)
|
||||
})
|
||||
|
||||
it('refuses a time that is not HH:MM', () => {
|
||||
expect(() => formatClockTime('24:00')).toThrow('not a 24-hour HH:MM time')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Bell, Calendar, ChevronRight, Crosshair, Heart, House } from 'lucide-react'
|
||||
import { useId, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import { aiProviderLabels, formatClockTime, useAISettings, useHouseholdSettings } from './api.ts'
|
||||
import styles from './SettingsPage.module.css'
|
||||
|
||||
// Settings home (handoff mock 1p). Each row opens its settings page.
|
||||
//
|
||||
// Regular dinners, Google Calendar and Notifications & Google Chat show no
|
||||
// status line yet: the server has no dinners, Google Calendar or
|
||||
// notification endpoint to read one from.
|
||||
export function SettingsPage() {
|
||||
const household = useHouseholdSettings()
|
||||
const ai = useAISettings()
|
||||
const failed = [household, ai].filter((query) => query.isError)
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h1 className={styles.title}>Settings</h1>
|
||||
<div className={styles.banner}>
|
||||
<span className={styles.avatar} aria-hidden="true">
|
||||
K
|
||||
</span>
|
||||
<p className={styles.bannerText}>
|
||||
<strong className={styles.bannerTitle}>Our kitchen</strong>
|
||||
<span>Shared household · changes apply to everyone</span>
|
||||
</p>
|
||||
</div>
|
||||
{failed.length > 0 && (
|
||||
<div className={styles.alert}>
|
||||
<ErrorBanner onRetry={() => failed.forEach((query) => void query.refetch())}>
|
||||
Settings could not load: {failed.map((query) => query.error?.message).join('; ')}
|
||||
</ErrorBanner>
|
||||
</div>
|
||||
)}
|
||||
<Group label="Household">
|
||||
<SettingsRow to="/settings/regulars" icon={Heart} title="Regular dinners" />
|
||||
<SettingsRow to="/settings/calendar" icon={Calendar} title="Google Calendar" />
|
||||
<SettingsRow
|
||||
to="/settings/household"
|
||||
icon={House}
|
||||
title="Household"
|
||||
status={status(household, (s) => `${s.timezone} · Dinner at ${formatClockTime(s.dinner_time)}`)}
|
||||
/>
|
||||
</Group>
|
||||
<Group label="Alerts">
|
||||
<SettingsRow to="/settings/notifications" icon={Bell} title="Notifications & Google Chat" />
|
||||
</Group>
|
||||
<Group label="Admin" admin>
|
||||
<SettingsRow
|
||||
to="/settings/ai"
|
||||
icon={Crosshair}
|
||||
title="AI assistant"
|
||||
status={status(ai, (s) => `${aiProviderLabels[s.provider]} · ${s.model}`)}
|
||||
tag={<Tag className={styles.tag}>Admin</Tag>}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// status is a row's status line: a skeleton while its query loads, the
|
||||
// formatted data once it has, and nothing when it failed (the error banner
|
||||
// says why).
|
||||
function status<T>(query: UseQueryResult<T>, format: (data: T) => string): ReactNode {
|
||||
if (query.isPending) return <Skeleton className={styles.statusSkeleton} />
|
||||
if (query.isError) return null
|
||||
return format(query.data)
|
||||
}
|
||||
|
||||
type GroupProps = {
|
||||
label: string
|
||||
// The Admin group's card carries the grape-300 inset ring.
|
||||
admin?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function Group({ label, admin = false, children }: GroupProps) {
|
||||
const id = useId()
|
||||
return (
|
||||
<section aria-labelledby={id}>
|
||||
<h2 id={id} className={styles.groupLabel}>
|
||||
{label}
|
||||
</h2>
|
||||
<Card className={[styles.group, admin && styles.admin].filter(Boolean).join(' ')}>
|
||||
<ul className={styles.rows}>{children}</ul>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
type SettingsRowProps = {
|
||||
to: string
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
status?: ReactNode
|
||||
tag?: ReactNode
|
||||
}
|
||||
|
||||
function SettingsRow({ to, icon, title, status, tag }: SettingsRowProps) {
|
||||
return (
|
||||
<li className={styles.item}>
|
||||
<Link to={to} className={styles.row}>
|
||||
<span className={styles.icon}>
|
||||
<Icon icon={icon} />
|
||||
</span>
|
||||
<span className={styles.body}>
|
||||
<span className={styles.rowTitle}>{title}</span>
|
||||
{status && <span className={styles.status}>{status}</span>}
|
||||
</span>
|
||||
{tag}
|
||||
<Icon icon={ChevronRight} />
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useApi } from '../api/index.ts'
|
||||
|
||||
export const HOUSEHOLD_SETTINGS_PATH = '/api/v1/settings/household'
|
||||
export const AI_SETTINGS_PATH = '/api/v1/settings/ai'
|
||||
|
||||
// HouseholdSettings is the part of GET /api/v1/settings/household the
|
||||
// settings pages read.
|
||||
export type HouseholdSettings = {
|
||||
timezone: string
|
||||
// Zero-padded 24-hour HH:MM.
|
||||
dinner_time: string
|
||||
}
|
||||
|
||||
// aiProviderLabels names every AI provider preset the server accepts
|
||||
// (internal/ai/llm/presets) as the household reads it.
|
||||
export const aiProviderLabels = {
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
openrouter: 'OpenRouter',
|
||||
xai: 'xAI',
|
||||
ollama: 'Ollama',
|
||||
openai_compatible: 'OpenAI-compatible',
|
||||
} as const
|
||||
|
||||
export type AIProvider = keyof typeof aiProviderLabels
|
||||
|
||||
// AISettings is the part of GET /api/v1/settings/ai the settings pages read.
|
||||
export type AISettings = {
|
||||
provider: AIProvider
|
||||
model: string
|
||||
}
|
||||
|
||||
function isAIProvider(value: string): value is AIProvider {
|
||||
return Object.hasOwn(aiProviderLabels, value)
|
||||
}
|
||||
|
||||
const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
|
||||
|
||||
// useHouseholdSettings refuses a dinner time that is not HH:MM, so
|
||||
// formatClockTime never sees one.
|
||||
export function useHouseholdSettings() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: ['settings', 'household'],
|
||||
queryFn: async () => {
|
||||
const settings = await apiFetch<HouseholdSettings>(HOUSEHOLD_SETTINGS_PATH)
|
||||
if (!clockTime.test(settings.dinner_time)) {
|
||||
throw new Error(`GET ${HOUSEHOLD_SETTINGS_PATH}: dinner_time "${settings.dinner_time}" is not HH:MM`)
|
||||
}
|
||||
return { timezone: settings.timezone, dinner_time: settings.dinner_time } satisfies HouseholdSettings
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// useAISettings refuses a provider this client has no name for, so the
|
||||
// settings never show a value the household cannot read.
|
||||
export function useAISettings() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: ['settings', 'ai'],
|
||||
queryFn: async () => {
|
||||
const settings = await apiFetch<{ provider: string; model: string }>(AI_SETTINGS_PATH)
|
||||
if (!isAIProvider(settings.provider)) {
|
||||
throw new Error(`GET ${AI_SETTINGS_PATH}: unknown AI provider "${settings.provider}"`)
|
||||
}
|
||||
return { provider: settings.provider, model: settings.model } satisfies AISettings
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// formatClockTime turns a 24-hour HH:MM time into the handoff's "5:30 pm".
|
||||
export function formatClockTime(time: string): string {
|
||||
const match = clockTime.exec(time)
|
||||
if (match === null) throw new Error(`not a 24-hour HH:MM time: "${time}"`)
|
||||
const hours = Number(match[1])
|
||||
const suffix = hours < 12 ? 'am' : 'pm'
|
||||
return `${hours % 12 === 0 ? 12 : hours % 12}:${match[2]} ${suffix}`
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AppRoutes } from '../App.tsx'
|
||||
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle, outerSize } from '../test/cssRules.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { stubFetchByPath } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import bottomBarStyles from './BottomBar.module.css'
|
||||
import railStyles from './Rail.module.css'
|
||||
@@ -16,13 +18,17 @@ const DESKTOP = 1440
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function renderAt(path: string, width: number) {
|
||||
const resize = mockViewport(width)
|
||||
stubFetchByPath(SETTINGS_API)
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<AppRoutes />
|
||||
<ApiProviders>
|
||||
<AppRoutes />
|
||||
</ApiProviders>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
return resize
|
||||
|
||||
@@ -2,7 +2,9 @@ import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { AppRoutes } from '../App.tsx'
|
||||
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { stubFetchByPath } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import styles from './BottomBar.module.css'
|
||||
|
||||
@@ -65,13 +67,17 @@ afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(Element.prototype, 'animate')
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function renderAt(path: string, reducedMotion = false) {
|
||||
mockViewport(MOBILE, { reducedMotion })
|
||||
stubFetchByPath(SETTINGS_API)
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<AppRoutes />
|
||||
<ApiProviders>
|
||||
<AppRoutes />
|
||||
</ApiProviders>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { fakeLocation, jsonResponse } from './fetchStub.ts'
|
||||
|
||||
// SETTINGS_API answers the settings home's requests, for stubFetchByPath.
|
||||
export const SETTINGS_API = {
|
||||
'/api/v1/settings/household': () => jsonResponse(200, { timezone: 'America/New_York', dinner_time: '17:30' }),
|
||||
'/api/v1/settings/ai': () => jsonResponse(200, { provider: 'anthropic', model: 'claude-sonnet-4-5' }),
|
||||
}
|
||||
|
||||
// ApiProviders gives the pages under test what App gives them: an API client
|
||||
// on https://mealquest.test and a fresh query client.
|
||||
export function ApiProviders({ children }: { children: ReactNode }) {
|
||||
const [api] = useState(() =>
|
||||
createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)),
|
||||
)
|
||||
const [queryClient] = useState(createQueryClient)
|
||||
return (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
}
|
||||
@@ -47,3 +47,17 @@ export function request(fetchMock: Mock<typeof fetch>, n: number) {
|
||||
export function fakeLocation(pathname = '/', search = ''): ApiLocation & { assign: Mock<(url: string | URL) => void> } {
|
||||
return { origin: 'https://mealquest.test', pathname, search, assign: vi.fn<(url: string | URL) => void>() }
|
||||
}
|
||||
|
||||
// stubFetchByPath replaces the global fetch with a mock that answers each
|
||||
// path from responses with a fresh response from its factory, so requests
|
||||
// sent in parallel need no order; a path not in responses fails the test.
|
||||
// Undo with vi.unstubAllGlobals.
|
||||
export function stubFetchByPath(responses: Record<string, () => Response>): Mock<typeof fetch> {
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const respond = Object.hasOwn(responses, String(input)) ? responses[String(input)] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${String(input)}`)
|
||||
return Promise.resolve(respond())
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user