diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 8626068..b6a93d0 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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', () => { diff --git a/web/src/App.tsx b/web/src/App.tsx index 687c59f..400d3a5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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> = { + '/settings': , +} + // AppRoutes is the shell and its pages, shown once /api/v1/me has loaded. export function AppRoutes() { return ( }> {routes.map(({ path, title }) => ( - } /> + } /> ))} diff --git a/web/src/settings/SettingsPage.module.css b/web/src/settings/SettingsPage.module.css new file mode 100644 index 0000000..54dc598 --- /dev/null +++ b/web/src/settings/SettingsPage.module.css @@ -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; +} diff --git a/web/src/settings/SettingsPage.test.tsx b/web/src/settings/SettingsPage.test.tsx new file mode 100644 index 0000000..1c95140 --- /dev/null +++ b/web/src/settings/SettingsPage.test.tsx @@ -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

{useLocation().pathname}

+} + +function renderSettings() { + const api = createApiClient(fakeLocation('/settings'), createNetworkStatus(window), createVerifiedSession(localStorage)) + return render( + + + + + } /> + } /> + + + + , + ) +} + +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') + }) +}) diff --git a/web/src/settings/SettingsPage.tsx b/web/src/settings/SettingsPage.tsx new file mode 100644 index 0000000..81a41b2 --- /dev/null +++ b/web/src/settings/SettingsPage.tsx @@ -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 ( +
+

Settings

+
+ +

+ Our kitchen + Shared household · changes apply to everyone +

+
+ {failed.length > 0 && ( +
+ failed.forEach((query) => void query.refetch())}> + Settings could not load: {failed.map((query) => query.error?.message).join('; ')} + +
+ )} + + + + `${s.timezone} · Dinner at ${formatClockTime(s.dinner_time)}`)} + /> + + + + + + `${aiProviderLabels[s.provider]} · ${s.model}`)} + tag={Admin} + /> + +
+ ) +} + +// 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(query: UseQueryResult, format: (data: T) => string): ReactNode { + if (query.isPending) return + 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 ( +
+

+ {label} +

+ +
    {children}
+
+
+ ) +} + +type SettingsRowProps = { + to: string + icon: LucideIcon + title: string + status?: ReactNode + tag?: ReactNode +} + +function SettingsRow({ to, icon, title, status, tag }: SettingsRowProps) { + return ( +
  • + + + + + + {title} + {status && {status}} + + {tag} + + +
  • + ) +} diff --git a/web/src/settings/api.ts b/web/src/settings/api.ts new file mode 100644 index 0000000..6e93f4b --- /dev/null +++ b/web/src/settings/api.ts @@ -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(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}` +} diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx index 03d7bfd..153c5de 100644 --- a/web/src/shell/AppShell.test.tsx +++ b/web/src/shell/AppShell.test.tsx @@ -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( - + + + , ) return resize diff --git a/web/src/shell/BottomBar.test.tsx b/web/src/shell/BottomBar.test.tsx index 3ff7db6..b8c5d3b 100644 --- a/web/src/shell/BottomBar.test.tsx +++ b/web/src/shell/BottomBar.test.tsx @@ -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( - + + + , ) } diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx new file mode 100644 index 0000000..cc7e63a --- /dev/null +++ b/web/src/test/ApiProviders.tsx @@ -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 ( + + {children} + + ) +} diff --git a/web/src/test/fetchStub.ts b/web/src/test/fetchStub.ts index 9a7a932..27c7908 100644 --- a/web/src/test/fetchStub.ts +++ b/web/src/test/fetchStub.ts @@ -47,3 +47,17 @@ export function request(fetchMock: Mock, 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 Response>): Mock { + const fetchMock = vi.fn((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 +}