Feature 11: Settings home and Admin AI assistant #86

Merged
approver_agent merged 9 commits from feature/b28d9979-0f7a-4e81-bdff-2f16f3db2053-settings-admin-ai into dev 2026-10-06 12:45:06 -04:00
7 changed files with 61 additions and 18 deletions
Showing only changes of commit 5b3fccae96 - Show all commits
+8 -4
View File
@@ -1,11 +1,12 @@
import { QueryClientProvider } from '@tanstack/react-query' import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, within } from '@testing-library/react' 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 { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx' import { AI_SETTINGS, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts' import { declaredStyle } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts' import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts' import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts' import { mockViewport } from '../test/matchMedia.ts'
@@ -39,7 +40,8 @@ function CurrentPath() {
return <p data-testid="path">{useLocation().pathname}</p> return <p data-testid="path">{useLocation().pathname}</p>
} }
// renderSettings renders the settings home on a 390 px phone (mock 1p); // 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. // SettingsTabs.test.tsx covers 768 px and up.
function renderSettings() { function renderSettings() {
mockViewport(390) mockViewport(390)
@@ -47,12 +49,12 @@ function renderSettings() {
return render( return render(
<ApiProvider client={api}> <ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}> <QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={['/settings']}> <InDataRouter path="/settings">
<Routes> <Routes>
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<CurrentPath />} /> <Route path="*" element={<CurrentPath />} />
</Routes> </Routes>
</MemoryRouter> </InDataRouter>
</QueryClientProvider> </QueryClientProvider>
</ApiProvider>, </ApiProvider>,
) )
@@ -95,9 +97,11 @@ describe('SettingsPage', () => {
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([ expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
REGULARS_PATH, REGULARS_PATH,
'/api/v1/settings/ai', '/api/v1/settings/ai',
'/api/v1/settings/ai/models',
CALENDAR_PATH, CALENDAR_PATH,
'/api/v1/settings/household', '/api/v1/settings/household',
NOTIFICATIONS_PATH, NOTIFICATIONS_PATH,
'/api/v1/settings/recipe-search',
]) ])
}) })
+10 -2
View File
@@ -25,9 +25,17 @@ import styles from './SettingsPage.module.css'
import { SettingsTabs } from './SettingsTabs.tsx' import { SettingsTabs } from './SettingsTabs.tsx'
// Settings home: the list of settings pages on mobile (mock 1p), one page // Settings home: the list of settings pages on mobile (mock 1p), one page
// with sub-tabs from 768 px (mock 2e). // 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() { export function SettingsPage() {
return useBreakpoint() === 'mobile' ? <SettingsList /> : <SettingsTabs /> const mobile = useBreakpoint() === 'mobile'
return (
<>
{mobile && <SettingsList />}
<SettingsTabs hidden={mobile} />
</>
)
} }
// The mobile settings list (handoff mock 1p). Each row opens its settings // The mobile settings list (handoff mock 1p). Each row opens its settings
+2 -1
View File
@@ -59,7 +59,8 @@
color: var(--color-bg); color: var(--color-bg);
} }
/* A panel's display would override the hidden attribute. */ /* A page's or panel's display would override the hidden attribute. */
.page[hidden],
.single[hidden], .single[hidden],
.columns[hidden] { .columns[hidden] {
display: none; display: none;
+28 -5
View File
@@ -1,17 +1,15 @@
import { QueryClientProvider } from '@tanstack/react-query' import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, within } from '@testing-library/react' import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx' import { AI_MODELS, AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
import { InDataRouter } from '../test/dataRouter.tsx' import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts' import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts' import { mockViewport } from '../test/matchMedia.ts'
import { SettingsPage } from './SettingsPage.tsx' import { SettingsPage } from './SettingsPage.tsx'
const MODELS = { models: [{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }] }
type Responder = (body: unknown) => Response type Responder = (body: unknown) => Response
// stubAPI answers "METHOD path" from responders, each called with the // stubAPI answers "METHOD path" from responders, each called with the
@@ -22,7 +20,7 @@ function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch>
'GET /api/v1/me': () => jsonResponse(200, ME), 'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), 'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), 'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'GET /api/v1/settings/ai/models': () => jsonResponse(200, MODELS), 'GET /api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS), 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
...responders, ...responders,
} }
@@ -167,4 +165,29 @@ describe('SettingsPage across the 768 px breakpoint', () => {
expect(screen.getByRole('tablist', { name: 'Settings sections' })).toBeInTheDocument() expect(screen.getByRole('tablist', { name: 'Settings sections' })).toBeInTheDocument()
expect(screen.queryByRole('link')).not.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)
})
}) })
+3 -2
View File
@@ -19,7 +19,8 @@ const step: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 }
// Settings at tablet and desktop widths (handoff mock 2e): one page with // 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, // 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. // so a draft survives switching tabs, and one leave guard covers them all.
export function SettingsTabs() { // 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 [tab, setTab] = useState<Tab>('notifications-ai')
const [householdDirty, setHouseholdDirty] = useState(false) const [householdDirty, setHouseholdDirty] = useState(false)
const [aiDirty, setAIDirty] = useState(false) const [aiDirty, setAIDirty] = useState(false)
@@ -61,7 +62,7 @@ export function SettingsTabs() {
} }
return ( return (
<div className={styles.page}> <div className={styles.page} hidden={hidden}>
<header className={styles.header}> <header className={styles.header}>
<div className={styles.heading}> <div className={styles.heading}>
<h1 className={styles.title}>Settings</h1> <h1 className={styles.title}>Settings</h1>
+3 -3
View File
@@ -1,9 +1,9 @@
import { fireEvent, render, screen, within } from '@testing-library/react' 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 { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { AppRoutes } from '../App.tsx' import { AppRoutes } from '../App.tsx'
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx' import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts' import { declaredStyle } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { stubFetchByPath } from '../test/fetchStub.ts' import { stubFetchByPath } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts' import { mockViewport } from '../test/matchMedia.ts'
import styles from './BottomBar.module.css' import styles from './BottomBar.module.css'
@@ -74,11 +74,11 @@ function renderAt(path: string, reducedMotion = false) {
mockViewport(MOBILE, { reducedMotion }) mockViewport(MOBILE, { reducedMotion })
stubFetchByPath(SETTINGS_API) stubFetchByPath(SETTINGS_API)
render( render(
<MemoryRouter initialEntries={[path]}> <InDataRouter path={path}>
<ApiProviders> <ApiProviders>
<AppRoutes /> <AppRoutes />
</ApiProviders> </ApiProviders>
</MemoryRouter>, </InDataRouter>,
) )
} }
+7 -1
View File
@@ -16,6 +16,10 @@ export const AI_SETTINGS = {
updated_at: '2026-10-01T15:00:00.000Z', 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 // HOUSEHOLD_SETTINGS is a GET /api/v1/settings/household body: the seed
// times in New York. // times in New York.
export const HOUSEHOLD_SETTINGS = { export const HOUSEHOLD_SETTINGS = {
@@ -49,13 +53,15 @@ export const RECIPE_SEARCH_SETTINGS = {
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as // SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
// today's server does: the dinners, Google Calendar and notifications status // 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 = { export const SETTINGS_API = {
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'), '/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'), '/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'), '/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), '/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
'/api/v1/settings/ai': () => jsonResponse(200, AI_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), '/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
} }