Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 5b3fccae96 Keep the settings sub-tabs mounted below 768 px
CI / ci (push) Successful in 1m59s
CI / ci (pull_request) Successful in 2m0s
Crossing the 768 px breakpoint used to swap SettingsTabs for the mobile
list, unmounting the AI assistant, Household and Recipe search drafts and
their leave guard. SettingsPage now keeps SettingsTabs mounted at every
width and hides it on mobile, so an unsaved draft survives a resize or
rotation and the leave guard still asks before the list navigates away.

Tests that render the mobile settings home now use a data router, which
the mounted leave guard needs, and the shared settings stub answers the
AI models and recipe search reads the hidden sub-tabs make.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:31:57 -04:00
dev_agentandClaude Opus 5.5 42d58aad94 Lock the AI assistant form while a prompt reset is pending
CI / ci (push) Successful in 1m59s
CI / ci (pull_request) Successful in 2m0s
Closing the Reset sheet while the reset request was in flight left the form
editable, so a prompt edit made then was replaced by the reset response and
the leave guard could report the form as saved. The form's fieldset is now
disabled while a prompt reset is pending as well as during a save, so no edit
can be made that the reset response would overwrite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:15:14 -04:00
dev_agentandClaude Opus 5.5 021ed570fe Lock settings forms while a save is pending
CI / ci (push) Successful in 2m0s
CI / ci (pull_request) Successful in 2m0s
The AI assistant, Household and Recipe search forms stayed editable during
a save, then replaced the whole draft with the server response, so an edit
made while the save was in flight was lost and the leave guard reported
the form as saved. Each form now sits in a fieldset that is disabled only
while its save is pending (display: contents, so layout is unchanged), so
no edit can be made that the response would overwrite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:53:15 -04:00
dev_agent 35438f1934 Desktop and tablet settings layout (mock 2e) (#85)
CI / ci (push) Successful in 1m59s
CI / ci (pull_request) Successful in 1m59s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 11:40:32 -04:00
14 changed files with 701 additions and 32 deletions
@@ -281,3 +281,33 @@
.cardActions > button {
min-height: 52px;
}
.cardHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
/* AI assistant card on the desktop settings (mock 2e): the card grows to
fill its column and the system prompt grows to fill the card. */
.fill {
flex: 1;
gap: 18px;
}
.fill .prompt,
.fill .textarea {
flex: 1;
}
.fill .textarea {
resize: none;
}
/* A form locked while its save or prompt reset is pending, so no edit is
made that the response would then replace. It lays out as if it were not
there. */
.lock {
display: contents;
}
+80
View File
@@ -407,6 +407,63 @@ describe('AISettingsPage', () => {
expect(unload()).toBe(true)
})
it('locks the form while a save is pending, and guards leaving until the save lands', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({ 'PATCH /api/v1/settings/ai': () => new Promise<Response>((resolve) => (answer = resolve)) })
await renderPage()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(prompt(), { target: { value: 'Keep it cozy.' } })
fireEvent.click(save())
expect(await screen.findByRole('button', { name: 'Saving…' })).toBeDisabled()
for (const field of [provider(), model(), slider(), prompt()]) expect(field).toBeDisabled()
expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...AI_SETTINGS, system_prompt: 'Keep it cozy.' }))
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
expect(prompt()).toBeEnabled()
expect(prompt()).toHaveValue('Keep it cozy.')
await waitFor(() => expect(unload()).toBe(false))
})
it('locks the form while a prompt reset is pending, even with the sheet closed', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({
'POST /api/v1/settings/ai/reset-prompt': () => new Promise<Response>((resolve) => (answer = resolve)),
})
await renderPage()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(slider(), { target: { value: '0.2' } })
fireEvent.click(screen.getByRole('button', { name: 'Reset' }))
const confirm = screen.getByRole('dialog', { name: 'Reset the system prompt?' })
fireEvent.click(within(confirm).getByRole('button', { name: 'Reset prompt' }))
expect(await within(confirm).findByRole('button', { name: 'Resetting…' })).toBeDisabled()
fireEvent.click(within(confirm).getByRole('button', { name: 'Cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
for (const field of [provider(), model(), slider(), prompt()]) expect(field).toBeDisabled()
expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled()
expect(save()).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...AI_SETTINGS, system_prompt: DEFAULT_PROMPT }))
await waitFor(() => expect(prompt()).toBeEnabled())
expect(prompt()).toHaveValue(DEFAULT_PROMPT)
expect(slider()).toHaveValue('0.2')
expect(unload()).toBe(true)
fireEvent.change(prompt(), { target: { value: 'After the reset.' } })
expect(prompt()).toHaveValue('After the reset.')
})
it('keeps 44 px tap targets', async () => {
stubAPI()
await renderPage()
@@ -680,6 +737,29 @@ describe('Recipe search card', () => {
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('locks the card while a save is pending, and guards leaving until the save lands', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({ 'PATCH /api/v1/settings/recipe-search': () => new Promise<Response>((resolve) => (answer = resolve)) })
await renderCard()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(url(), { target: { value: 'https://search.example' } })
fireEvent.click(saveSearch())
expect(await within(card()).findByRole('button', { name: 'Saving…' })).toBeDisabled()
for (const field of [url(), searxng(), brave()]) expect(field).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, base_url: 'https://search.example' }))
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
expect(url()).toBeEnabled()
expect(url()).toHaveValue('https://search.example')
await waitFor(() => expect(unload()).toBe(false))
})
it('keeps 44 px tap targets', async () => {
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) })
await renderCard()
+41 -10
View File
@@ -3,6 +3,7 @@ import { useEffect, useId, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router'
import { ApiError } from '../api/index.ts'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
@@ -10,6 +11,7 @@ import { Select } from '../ui/Select.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Slider } from '../ui/Slider.tsx'
import { Tag } from '../ui/Tag.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import {
aiKeyPolicy,
@@ -39,22 +41,53 @@ const TYPE_MODEL = '\u0000type'
// sees the key's last four characters. Recipe search is its own card, saved
// on its own.
export function AISettingsPage() {
const settings = useAISettings()
const [aiDirty, setAIDirty] = useState(false)
const [recipeSearchDirty, setRecipeSearchDirty] = useState(false)
return (
<div className={styles.page}>
<Header />
<AIAssistantSettings onDirtyChange={setAIDirty} />
<RecipeSearchCard onDirtyChange={setRecipeSearchDirty} />
<LeaveGuard dirty={aiDirty || recipeSearchDirty} />
</div>
)
}
type AISettingsProps = {
// Called with whether the form holds unsaved changes.
onDirtyChange: (dirty: boolean) => void
}
// AIAssistantCard is the AI assistant's fields as a card, for the desktop
// settings (handoff mock 2e): the system prompt grows to fill the card.
export function AIAssistantCard({ onDirtyChange }: AISettingsProps) {
const titleId = useId()
return (
<Card className={`${styles.card} ${styles.fill}`} aria-labelledby={titleId} role="region">
<div className={styles.cardHeader}>
<h2 id={titleId} className={styles.cardTitle}>
AI assistant
</h2>
<Tag>Admin</Tag>
</div>
<AIAssistantSettings onDirtyChange={onDirtyChange} />
</Card>
)
}
// AIAssistantSettings is the AI assistant's form once its settings load.
function AIAssistantSettings({ onDirtyChange }: AISettingsProps) {
const settings = useAISettings()
return (
<>
{settings.isPending && <Skeleton className={styles.loading} />}
{settings.isError && (
<ErrorBanner onRetry={() => void settings.refetch()}>
AI settings could not load: {settings.error.message}
</ErrorBanner>
)}
{settings.isSuccess && <AISettingsForm saved={settings.data} onDirtyChange={setAIDirty} />}
<RecipeSearchCard onDirtyChange={setRecipeSearchDirty} />
<LeaveGuard dirty={aiDirty || recipeSearchDirty} />
</div>
{settings.isSuccess && <AISettingsForm saved={settings.data} onDirtyChange={onDirtyChange} />}
</>
)
}
@@ -103,10 +136,8 @@ function patchOf(saved: AISettings, draft: Draft): AISettingsPatch {
return patch
}
type AISettingsFormProps = {
type AISettingsFormProps = AISettingsProps & {
saved: AISettings
// Called with whether the form holds unsaved changes.
onDirtyChange: (dirty: boolean) => void
}
function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) {
@@ -170,7 +201,7 @@ function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) {
}
return (
<>
<fieldset className={styles.lock} disabled={save.isPending || resetPrompt.isPending}>
<div className={styles.columns}>
<Select
label="Provider"
@@ -294,7 +325,7 @@ function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) {
>
This restores MealQuest's default system prompt for everyone in the household.
</Confirm>
</>
</fieldset>
)
}
@@ -229,3 +229,9 @@
.check {
flex: none;
}
/* A form locked while its save is pending, so no edit is made that the
saved settings would then replace. It lays out as if it were not there. */
.lock {
display: contents;
}
@@ -1,5 +1,5 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, ApiError, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
@@ -293,6 +293,34 @@ describe('HouseholdSettingsPage', () => {
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('locks the form while a save is pending, and guards leaving until the save lands', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({
'PATCH /api/v1/settings/household': () => new Promise<Response>((resolve) => (answer = resolve)),
})
await renderPage()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(input('Dinner time'), { target: { value: '18:15' } })
fireEvent.click(save())
expect(await screen.findByRole('button', { name: 'Saving…' })).toBeDisabled()
for (const field of ['Dinner time', 'Day-of reminder', 'Plan draft day', 'Google Chat Supply run time']) {
expect(input(field)).toBeDisabled()
}
expect(screen.getByRole('button', { name: 'Change' })).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, dinner_time: '18:15' }))
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
expect(input('Dinner time')).toBeEnabled()
expect(input('Dinner time')).toHaveValue('18:15')
await waitFor(() => expect(unload()).toBe(false))
})
it('leaves without asking when nothing changed', async () => {
stubAPI()
await renderPage()
+23 -9
View File
@@ -29,21 +29,37 @@ const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday',
// reminders and weekly AI runs happen at. Every field is edited as a draft
// and saved together; only the changed fields are sent.
export function HouseholdSettingsPage() {
const settings = useHouseholdSettings()
const [dirty, setDirty] = useState(false)
return (
<div className={styles.page}>
<Header />
<p className={styles.shared}>Changes apply to everyone in the household.</p>
<HouseholdSettingsSections onDirtyChange={setDirty} />
<LeaveGuard dirty={dirty} />
</div>
)
}
type HouseholdSettingsProps = {
// Called with whether the form holds unsaved changes.
onDirtyChange: (dirty: boolean) => void
}
// HouseholdSettingsSections is the screen's form once its settings load,
// without the screen's header, so the desktop settings (mock 2e) can embed
// it.
export function HouseholdSettingsSections({ onDirtyChange }: HouseholdSettingsProps) {
const settings = useHouseholdSettings()
return (
<>
{settings.isPending && <Skeleton className={styles.loading} />}
{settings.isError && (
<ErrorBanner onRetry={() => void settings.refetch()}>
Household settings could not load: {settings.error.message}
</ErrorBanner>
)}
{settings.isSuccess && <HouseholdSettingsForm saved={settings.data} onDirtyChange={setDirty} />}
<LeaveGuard dirty={dirty} />
</div>
{settings.isSuccess && <HouseholdSettingsForm saved={settings.data} onDirtyChange={onDirtyChange} />}
</>
)
}
@@ -71,10 +87,8 @@ function patchOf(saved: HouseholdSettings, draft: HouseholdSettings): HouseholdS
return patch as HouseholdSettingsPatch
}
type HouseholdSettingsFormProps = {
type HouseholdSettingsFormProps = HouseholdSettingsProps & {
saved: HouseholdSettings
// Called with whether the form holds unsaved changes.
onDirtyChange: (dirty: boolean) => void
}
function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormProps) {
@@ -99,7 +113,7 @@ function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormPr
}
return (
<>
<fieldset className={styles.lock} disabled={save.isPending}>
<Section title="Time zone">
<TimeZoneField
value={draft.timezone}
@@ -188,7 +202,7 @@ function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormPr
{save.isPending ? 'Saving…' : 'Save changes'}
</Button>
</div>
</>
</fieldset>
)
}
+2 -2
View File
@@ -119,7 +119,7 @@ function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSetting
}
return (
<>
<fieldset className={styles.lock} disabled={save.isPending}>
<Segmented
label="Recipe search provider"
options={providerOptions}
@@ -173,7 +173,7 @@ function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSetting
{save.isPending ? 'Saving…' : 'Save recipe search'}
</Button>
</div>
</>
</fieldset>
)
}
+12 -3
View File
@@ -1,13 +1,15 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
import { 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 { AI_SETTINGS, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { formatClockTime, modelDisplayName } from './api.ts'
import { SettingsPage } from './SettingsPage.tsx'
@@ -16,6 +18,7 @@ const AI = AI_SETTINGS
afterEach(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
const REGULARS_PATH = '/api/v1/dinners?status=regular'
@@ -37,17 +40,21 @@ function CurrentPath() {
return <p data-testid="path">{useLocation().pathname}</p>
}
// renderSettings renders the settings home on a 390 px phone (mock 1p) in a
// data router (the hidden sub-tabs' leave guard needs one);
// SettingsTabs.test.tsx covers 768 px and up.
function renderSettings() {
mockViewport(390)
const api = createApiClient(fakeLocation('/settings'), createNetworkStatus(window), createVerifiedSession(localStorage))
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={['/settings']}>
<InDataRouter path="/settings">
<Routes>
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<CurrentPath />} />
</Routes>
</MemoryRouter>
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
@@ -90,9 +97,11 @@ describe('SettingsPage', () => {
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
REGULARS_PATH,
'/api/v1/settings/ai',
'/api/v1/settings/ai/models',
CALENDAR_PATH,
'/api/v1/settings/household',
NOTIFICATIONS_PATH,
'/api/v1/settings/recipe-search',
])
})
+19 -3
View File
@@ -8,6 +8,7 @@ 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 { useBreakpoint } from '../shell/useBreakpoint.ts'
import {
aiStatus,
calendarStatus,
@@ -21,12 +22,27 @@ import {
useRegularDinnersCount,
} from './api.ts'
import styles from './SettingsPage.module.css'
import { SettingsTabs } from './SettingsTabs.tsx'
// Settings home (handoff mock 1p). Each row opens its settings page and
// shows a live status line. Regular dinners, Google Calendar and
// Settings home: the list of settings pages on mobile (mock 1p), one page
// with sub-tabs from 768 px (mock 2e). The sub-tabs stay mounted, hidden, on
// mobile, so an unsaved draft survives the viewport crossing 768 px and its
// leave guard keeps asking before the list navigates away.
export function SettingsPage() {
const mobile = useBreakpoint() === 'mobile'
return (
<>
{mobile && <SettingsList />}
<SettingsTabs hidden={mobile} />
</>
)
}
// The mobile settings list (handoff mock 1p). Each row opens its settings
// page and shows a live status line. Regular dinners, Google Calendar and
// Notifications & Google Chat read endpoints their features have not served
// yet; until then each shows its not-yet-available state (see api.ts).
export function SettingsPage() {
function SettingsList() {
const household = useHouseholdSettings()
const ai = useAISettings()
const regulars = useRegularDinnersCount()
+118
View File
@@ -0,0 +1,118 @@
/* Settings at tablet and desktop widths, handoff mock 2e. */
.page {
box-sizing: border-box;
min-height: 100%;
display: flex;
flex-direction: column;
gap: 20px;
}
.header {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px;
}
.heading {
flex: 1;
min-width: 0;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 40px;
font-weight: 400;
line-height: 1.1;
}
.shared {
margin: 6px 0 0;
font-family: var(--font-body);
font-size: 14px;
color: var(--color-neutral-800);
}
.tabs {
display: flex;
gap: 6px;
}
.tab {
min-width: 44px;
min-height: 44px;
padding: 0 16px;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-text);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.selected {
background: var(--color-text);
color: var(--color-bg);
}
/* A page's or panel's display would override the hidden attribute. */
.page[hidden],
.single[hidden],
.columns[hidden] {
display: none;
}
.single {
max-width: 560px;
display: flex;
flex-direction: column;
gap: 18px;
}
/* Notifications on the left; the AI assistant and Recipe search on the
right, the AI assistant card growing to fill the column. */
.columns {
flex: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
align-items: stretch;
gap: 20px;
}
.column {
min-width: 0;
display: flex;
flex-direction: column;
gap: 20px;
}
/* Mock 2e's cards: 24 px padding, 30 px corners. */
.column > * {
padding: 24px;
border-radius: 30px;
}
.placeholder {
display: flex;
flex-direction: column;
gap: 14px;
}
.cardTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.hint {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-neutral-700);
}
+193
View File
@@ -0,0 +1,193 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { AI_MODELS, AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { SettingsPage } from './SettingsPage.tsx'
type Responder = (body: unknown) => Response
// stubAPI answers "METHOD path" from responders, each called with the
// request's decoded JSON body; the settings the page loads answer their
// fixtures unless overridden. Any other request fails the test.
function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch> {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'GET /api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
return Promise.resolve(respond(body))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
afterEach(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
// renderAt renders /settings at width px in a data router (the page's leave
// guard needs one), after loading /me for the CSRF token writes need.
// It returns the function that resizes the viewport.
async function renderAt(width: number) {
const resize = mockViewport(width)
const api = createApiClient(fakeLocation('/settings'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings">
<SettingsPage />
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
return resize
}
const tab = (name: string) => screen.getByRole('tab', { name })
// panel is the panel a tab controls, shown or hidden.
function panel(name: string): HTMLElement {
const id = tab(name).getAttribute('aria-controls')
const element = id === null ? null : document.getElementById(id)
if (element === null) throw new Error(`tab "${name}" controls no panel`)
expect(element).toHaveAttribute('role', 'tabpanel')
return element
}
describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
it('shows the title, the shared line and the sub-tabs, Notifications & AI first', async () => {
stubAPI()
await renderAt(width)
expect(screen.getByRole('heading', { level: 1, name: 'Settings' })).toBeInTheDocument()
expect(screen.getByText('Changes apply to everyone in the household.')).toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
const tabs = within(screen.getByRole('tablist', { name: 'Settings sections' })).getAllByRole('tab')
expect(tabs.map((t) => t.textContent)).toEqual(['Household', 'Regular dinners', 'Notifications & AI'])
expect(tab('Notifications & AI')).toHaveAttribute('aria-selected', 'true')
tabs.forEach(expectMinHitArea)
const columns = panel('Notifications & AI')
expect(columns).toBeVisible()
const [left, right] = Array.from(columns.children)
expect(within(left as HTMLElement).getByRole('region', { name: 'Notifications & Google Chat' })).toBeInTheDocument()
const ai = within(right as HTMLElement).getByRole('region', { name: 'AI assistant' })
expect(within(ai).getByText('Admin')).toBeInTheDocument()
expect(await within(ai).findByRole('combobox', { name: 'Provider' })).toHaveValue('anthropic')
expect(within(ai).getByRole('textbox', { name: 'System prompt' })).toHaveValue(AI_SETTINGS.system_prompt)
expect(within(right as HTMLElement).getByRole('region', { name: 'Recipe search' })).toBeInTheDocument()
})
it('switches tabs by click and by arrow keys', async () => {
stubAPI()
await renderAt(width)
fireEvent.click(tab('Household'))
expect(tab('Household')).toHaveAttribute('aria-selected', 'true')
expect(panel('Household')).toBeVisible()
expect(panel('Notifications & AI')).not.toBeVisible()
expect(await within(panel('Household')).findByRole('button', { name: 'Save changes' })).toBeInTheDocument()
fireEvent.keyDown(tab('Household'), { key: 'ArrowRight' })
expect(tab('Regular dinners')).toHaveAttribute('aria-selected', 'true')
expect(tab('Regular dinners')).toHaveFocus()
expect(panel('Regular dinners')).toBeVisible()
expect(within(panel('Regular dinners')).getByRole('region', { name: 'Regular dinners' })).toBeInTheDocument()
expect(panel('Household')).not.toBeVisible()
fireEvent.keyDown(tab('Regular dinners'), { key: 'End' })
expect(tab('Notifications & AI')).toHaveAttribute('aria-selected', 'true')
fireEvent.keyDown(tab('Notifications & AI'), { key: 'ArrowRight' })
expect(tab('Household')).toHaveAttribute('aria-selected', 'true')
expect(tab('Household')).toHaveFocus()
})
it('saves the AI assistant card through the AI settings API', async () => {
const fetchMock = stubAPI({
'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, ...(body as object) }),
})
await renderAt(width)
const ai = within(screen.getByRole('region', { name: 'AI assistant' }))
const prompt = await ai.findByRole('textbox', { name: 'System prompt' })
const save = ai.getByRole('button', { name: 'Save changes' })
expect(save).toBeDisabled()
fireEvent.change(prompt, { target: { value: 'Plan dinners for us.' } })
fireEvent.click(save)
expect(await ai.findByText('Changes saved.')).toBeInTheDocument()
const writes = fetchMock.mock.calls.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
expect(writes.map(([input, init]) => [init?.method, String(input), init?.body])).toEqual([
['PATCH', '/api/v1/settings/ai', JSON.stringify({ system_prompt: 'Plan dinners for us.' })],
])
expect(prompt).toHaveValue('Plan dinners for us.')
expect(save).toBeDisabled()
})
it('keeps an unsaved draft while another tab is shown', async () => {
stubAPI()
await renderAt(width)
const prompt = await screen.findByRole('textbox', { name: 'System prompt' })
fireEvent.change(prompt, { target: { value: 'A draft' } })
fireEvent.click(tab('Household'))
fireEvent.click(tab('Notifications & AI'))
expect(screen.getByRole('textbox', { name: 'System prompt' })).toHaveValue('A draft')
})
})
describe('SettingsPage across the 768 px breakpoint', () => {
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
stubAPI({
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'GET /api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
})
const resize = await renderAt(767)
expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai')
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
resize(768)
expect(screen.getByRole('tablist', { name: 'Settings sections' })).toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
})
it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => {
stubAPI({
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'GET /api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
})
const resize = await renderAt(1180)
fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), {
target: { value: 'A draft' },
})
resize(390)
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('link', { name: /^AI assistant/ }))
const confirm = await screen.findByRole('dialog', { name: 'Leave without saving?' })
fireEvent.click(within(confirm).getByRole('button', { name: 'Keep editing' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
resize(1180)
expect(screen.getByRole('textbox', { name: 'System prompt' })).toHaveValue('A draft')
const unload = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(unload)
expect(unload.defaultPrevented).toBe(true)
})
})
+138
View File
@@ -0,0 +1,138 @@
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
import { Card } from '../ui/Card.tsx'
import { AIAssistantCard } from './AISettingsPage.tsx'
import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx'
import { LeaveGuard } from './LeaveGuard.tsx'
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
import styles from './SettingsTabs.module.css'
type Tab = 'household' | 'regulars' | 'notifications-ai'
const tabs: readonly { value: Tab; label: string }[] = [
{ value: 'household', label: 'Household' },
{ value: 'regulars', label: 'Regular dinners' },
{ value: 'notifications-ai', label: 'Notifications & AI' },
]
const step: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 }
// Settings at tablet and desktop widths (handoff mock 2e): one page with
// sub-tabs instead of the mobile list. Every tab stays mounted while hidden,
// so a draft survives switching tabs, and one leave guard covers them all.
// hidden hides the page but keeps it mounted, drafts and leave guard included.
export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
const [tab, setTab] = useState<Tab>('notifications-ai')
const [householdDirty, setHouseholdDirty] = useState(false)
const [aiDirty, setAIDirty] = useState(false)
const [recipeSearchDirty, setRecipeSearchDirty] = useState(false)
const refs = useRef<(HTMLButtonElement | null)[]>([])
const id = useId()
const tabId = (value: Tab) => `${id}-tab-${value}`
const panelId = (value: Tab) => `${id}-panel-${value}`
// Arrow keys move between the tabs, wrapping at the ends; Home and End
// jump to the first and last. Moving selects the tab.
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const selected = tabs.findIndex((t) => t.value === tab)
const delta = step[event.key]
let next: number
if (delta !== undefined) next = (selected + delta + tabs.length) % tabs.length
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = tabs.length - 1
else return
const nextTab = tabs[next]
if (!nextTab) throw new Error(`Settings tabs: no tab at index ${next}`)
event.preventDefault()
setTab(nextTab.value)
refs.current[next]?.focus()
}
function panel(value: Tab, className: string | undefined, children: ReactNode) {
return (
<div
id={panelId(value)}
role="tabpanel"
aria-labelledby={tabId(value)}
hidden={tab !== value}
className={className}
>
{children}
</div>
)
}
return (
<div className={styles.page} hidden={hidden}>
<header className={styles.header}>
<div className={styles.heading}>
<h1 className={styles.title}>Settings</h1>
<p className={styles.shared}>Changes apply to everyone in the household.</p>
</div>
<div role="tablist" aria-label="Settings sections" className={styles.tabs} onKeyDown={onKeyDown}>
{tabs.map(({ value, label }, index) => (
<button
key={value}
ref={(el) => {
refs.current[index] = el
}}
id={tabId(value)}
type="button"
role="tab"
aria-selected={tab === value}
aria-controls={panelId(value)}
tabIndex={tab === value ? 0 : -1}
className={tab === value ? `${styles.tab} ${styles.selected}` : styles.tab}
onClick={() => setTab(value)}
>
{label}
</button>
))}
</div>
</header>
{panel('household', styles.single, <HouseholdSettingsSections onDirtyChange={setHouseholdDirty} />)}
{panel(
'regulars',
styles.single,
// TODO(Feature 12): embed the Regular dinners screen here once Feature
// 12 builds it.
<Placeholder title="Regular dinners">Managing the household's Regulars arrives in a later update.</Placeholder>,
)}
{panel(
'notifications-ai',
styles.columns,
<>
<div className={styles.column}>
{/* TODO(Feature 19): the Notifications matrix and Google Chat
webhook cards replace this placeholder. */}
<Placeholder title="Notifications & Google Chat">
Notification and Google Chat webhook settings arrive in a later update.
</Placeholder>
</div>
<div className={styles.column}>
<AIAssistantCard onDirtyChange={setAIDirty} />
<RecipeSearchCard onDirtyChange={setRecipeSearchDirty} />
</div>
</>,
)}
<LeaveGuard dirty={householdDirty || aiDirty || recipeSearchDirty} />
</div>
)
}
// Placeholder is a neutral card holding a section's place until the feature
// that owns it renders it.
function Placeholder({ title, children }: { title: string; children: ReactNode }) {
const titleId = useId()
return (
<Card className={styles.placeholder} aria-labelledby={titleId} role="region">
<h2 id={titleId} className={styles.cardTitle}>
{title}
</h2>
<p className={styles.hint}>{children}</p>
</Card>
)
}
+3 -3
View File
@@ -1,9 +1,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 { InDataRouter } from '../test/dataRouter.tsx'
import { stubFetchByPath } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import styles from './BottomBar.module.css'
@@ -74,11 +74,11 @@ function renderAt(path: string, reducedMotion = false) {
mockViewport(MOBILE, { reducedMotion })
stubFetchByPath(SETTINGS_API)
render(
<MemoryRouter initialEntries={[path]}>
<InDataRouter path={path}>
<ApiProviders>
<AppRoutes />
</ApiProviders>
</MemoryRouter>,
</InDataRouter>,
)
}
+7 -1
View File
@@ -16,6 +16,10 @@ export const AI_SETTINGS = {
updated_at: '2026-10-01T15:00:00.000Z',
}
// AI_MODELS is a GET /api/v1/settings/ai/models body: the model AI_SETTINGS
// chooses.
export const AI_MODELS = { models: [{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }] }
// HOUSEHOLD_SETTINGS is a GET /api/v1/settings/household body: the seed
// times in New York.
export const HOUSEHOLD_SETTINGS = {
@@ -49,13 +53,15 @@ export const RECIPE_SEARCH_SETTINGS = {
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
// today's server does: the dinners, Google Calendar and notifications status
// endpoints do not exist yet.
// endpoints do not exist yet. The home's sub-tabs stay mounted on mobile, so
// it also reads the AI models and recipe search settings.
export const SETTINGS_API = {
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'/api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
}