diff --git a/web/src/settings/RegularsSettingsPage.tsx b/web/src/settings/RegularsSettingsPage.tsx index c816b49..3c9eae0 100644 --- a/web/src/settings/RegularsSettingsPage.tsx +++ b/web/src/settings/RegularsSettingsPage.tsx @@ -80,7 +80,10 @@ export function RegularsSettingsPage() { // Which sheet is open, for which Regular. type Open = { sheet: 'edit' | 'fix'; id: string } | null -function RegularsManager() { +// RegularsManager lists, adds, edits and fixes the recipes of the +// household's Regulars: this page's body, and the Settings "Regular +// dinners" sub-tab at tablet and desktop widths. +export function RegularsManager() { const regulars = useRegulars() const add = useAddRegular() const remove = useRemoveRegular() diff --git a/web/src/settings/SettingsPage.test.tsx b/web/src/settings/SettingsPage.test.tsx index 64b4f54..2ed62d4 100644 --- a/web/src/settings/SettingsPage.test.tsx +++ b/web/src/settings/SettingsPage.test.tsx @@ -104,7 +104,10 @@ describe('SettingsPage', () => { expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument() expect(within(row('AI assistant')).getByText('Anthropic · Claude Sonnet')).toBeInTheDocument() expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument() + // The rotation count and the hidden Regular dinners sub-tab both read + // the Regulars. expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([ + REGULARS_PATH, REGULARS_PATH, CALENDAR_PATH, '/api/v1/settings/ai', @@ -117,7 +120,7 @@ describe('SettingsPage', () => { }) it('shows the not-yet-available states while the server has no endpoint for them', async () => { - stubSettings() + stubSettings({ [REGULARS_PATH]: () => errorResponse(404, 'not_found') }) renderSettings() expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument() expect( diff --git a/web/src/settings/SettingsTabs.module.css b/web/src/settings/SettingsTabs.module.css index 16d6567..acc6dce 100644 --- a/web/src/settings/SettingsTabs.module.css +++ b/web/src/settings/SettingsTabs.module.css @@ -101,24 +101,3 @@ 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); -} diff --git a/web/src/settings/SettingsTabs.test.tsx b/web/src/settings/SettingsTabs.test.tsx index eb9da10..63a30b4 100644 --- a/web/src/settings/SettingsTabs.test.tsx +++ b/web/src/settings/SettingsTabs.test.tsx @@ -11,9 +11,11 @@ import { HOUSEHOLD_SETTINGS, NOTIFICATION_SETTINGS, RECIPE_SEARCH_SETTINGS, + SETTINGS_REGULARS, } from '../test/ApiProviders.tsx' import { InDataRouter } from '../test/dataRouter.tsx' -import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { dinnerBody, type DinnerBody } from '../test/dinners.ts' +import { fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' import { expectMinHitArea } from '../test/hitArea.ts' import { mockViewport } from '../test/matchMedia.ts' import { SettingsPage } from './SettingsPage.tsx' @@ -32,6 +34,7 @@ function stubAPI(responders: Record = {}): Mock 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS), 'GET /api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS), 'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK), + 'GET /api/v1/dinners?status=regular': () => jsonResponse(200, SETTINGS_REGULARS), ...responders, } const fetchMock = vi.fn((input, init) => { @@ -121,7 +124,7 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => { 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(await within(panel('Regular dinners')).findByRole('heading', { name: 'On rotation · 1' })).toBeInTheDocument() expect(panel('Household')).not.toBeVisible() fireEvent.keyDown(tab('Regular dinners'), { key: 'End' }) @@ -165,6 +168,123 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => { }) }) +const TACOS = dinnerBody('d1', 'Beef tacos', { method: 'stovetop', minutes_total: 40, difficulty: 'easy' }) +const PIZZA = dinnerBody('d3', 'Pizza night', { grounding_status: 'failed', grounding_error: 'no_recipe_found' }) + +// stubRegulars answers the Regulars from server.regulars, so a write's +// responder changes what the next read sees. +function stubRegulars(regulars: DinnerBody[], responders: (server: { regulars: DinnerBody[] }) => Record) { + const server = { regulars } + const fetchMock = stubAPI({ + 'GET /api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: server.regulars }), + ...responders(server), + }) + return { server, fetchMock } +} + +// writes lists the requests other than GETs, as method, path and body. +function writes(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([, init]) => (init?.method ?? 'GET') !== 'GET') + .map(([input, init]) => [init?.method, String(input), init?.body]) +} + +// openRegulars shows the Regular dinners tab once its Regulars have loaded. +async function openRegulars() { + fireEvent.click(tab('Regular dinners')) + const regulars = within(panel('Regular dinners')) + await regulars.findByRole('heading', { level: 2, name: /^On rotation/ }) + return regulars +} + +function regularRow(name: string) { + const item = within(screen.getByRole('list', { name: 'Regulars' })) + .getAllByRole('listitem') + .find((li) => within(li).queryByText(name) !== null) + if (item === undefined) throw new Error(`no Regular row named ${name}`) + return within(item) +} + +describe.each([1440, 1180])('SettingsPage Regular dinners tab at %i px (mock 2e)', (width) => { + it('lists the Regulars as /settings/regulars does, under the page title rather than its own', async () => { + stubRegulars([TACOS, PIZZA], () => ({})) + await renderAt(width) + const regulars = await openRegulars() + expect(regulars.getByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument() + expect(regularRow('Beef tacos').getByText('Stovetop · 40 min')).toBeInTheDocument() + expect(regularRow('Beef tacos').getByText('Recipe found')).toBeInTheDocument() + expect(regularRow('Pizza night').getByText('Needs a recipe')).toBeInTheDocument() + expect(regulars.getByRole('textbox', { name: 'Add a Regular' })).toBeInTheDocument() + expect(screen.getAllByRole('heading', { level: 1 }).map((h) => h.textContent)).toEqual(['Settings']) + expect(regulars.queryByRole('button', { name: 'Back to Settings' })).not.toBeInTheDocument() + }) + + it('adds a Regular by name', async () => { + const { fetchMock } = stubRegulars([TACOS], (server) => ({ + 'POST /api/v1/dinners': (body) => { + const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' }) + server.regulars = [...server.regulars, created] + return jsonResponse(201, { ...created, grounding_job_id: 'job-1' }) + }, + })) + await renderAt(width) + const regulars = await openRegulars() + fireEvent.change(regulars.getByRole('textbox', { name: 'Add a Regular' }), { target: { value: 'Chicken enchiladas' } }) + fireEvent.click(regulars.getByRole('button', { name: 'Add Regular' })) + + expect(await regulars.findByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument() + expect(regularRow('Chicken enchiladas').getByText('Finding recipe…')).toBeInTheDocument() + expect(writes(fetchMock)).toEqual([['POST', '/api/v1/dinners', JSON.stringify({ name: 'Chicken enchiladas' })]]) + }) + + it('edits a Regular in its sheet', async () => { + const { fetchMock } = stubRegulars([TACOS], (server) => ({ + 'PATCH /api/v1/dinners/d1': (body) => { + const edited = { ...TACOS, ...(body as object) } + server.regulars = [edited] + return jsonResponse(200, edited) + }, + })) + await renderAt(width) + await openRegulars() + fireEvent.click(regularRow('Beef tacos').getByRole('button', { name: /^Beef tacos/ })) + const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' })) + fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '25' } }) + fireEvent.click(sheet.getByRole('button', { name: 'Save changes' })) + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(writes(fetchMock)).toEqual([['PATCH', '/api/v1/dinners/d1', JSON.stringify({ minutes_total: 25 })]]) + expect(await regularRow('Beef tacos').findByText('Stovetop · 25 min')).toBeInTheDocument() + }) + + it('fixes the recipe of a Regular that needs one', async () => { + const { fetchMock } = stubRegulars([PIZZA], (server) => ({ + 'GET /api/v1/dinners/d3/recipe-candidates': () => + jsonResponse(200, { + candidates: [ + { candidate_id: 'https://example.com/pizza', title: 'Weeknight pizza', site_name: 'Example Kitchen', photo_url: null, minutes: 45 }, + ], + }), + 'POST /api/v1/dinners/d3/ground': () => { + const fixed = { ...PIZZA, grounding_status: 'grounded', grounding_error: null } + server.regulars = [fixed] + return jsonResponse(200, fixed) + }, + })) + await renderAt(width) + await openRegulars() + fireEvent.click(regularRow('Pizza night').getByRole('button', { name: /^Pizza night/ })) + const sheet = within(screen.getByRole('dialog', { name: 'Fix recipe' })) + fireEvent.click(await sheet.findByRole('button', { name: 'Use Weeknight pizza from Example Kitchen' })) + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(writes(fetchMock)).toEqual([ + ['POST', '/api/v1/dinners/d3/ground', JSON.stringify({ candidate_id: 'https://example.com/pizza' })], + ]) + expect(await regularRow('Pizza night').findByText('Recipe found')).toBeInTheDocument() + }) +}) + describe('SettingsPage reminder time across tabs', () => { it('shows a reminder time saved on Notifications in the Household tab, keeping its draft', async () => { const saved = { ...HOUSEHOLD_SETTINGS } @@ -195,10 +315,7 @@ describe('SettingsPage reminder time across tabs', () => { 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/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED), - }) + stubAPI({ 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED) }) const resize = await renderAt(767) expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai') expect(screen.queryByRole('tablist')).not.toBeInTheDocument() @@ -209,10 +326,7 @@ describe('SettingsPage across the 768 px breakpoint', () => { }) 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/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED), - }) + stubAPI({ 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED) }) const resize = await renderAt(1180) fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), { target: { value: 'A draft' }, diff --git a/web/src/settings/SettingsTabs.tsx b/web/src/settings/SettingsTabs.tsx index ab706be..f5d45ba 100644 --- a/web/src/settings/SettingsTabs.tsx +++ b/web/src/settings/SettingsTabs.tsx @@ -1,11 +1,11 @@ import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react' import { useBreakpoint, useOrientation } from '../shell/useBreakpoint.ts' -import { Card } from '../ui/Card.tsx' import { AIAssistantCard } from './AISettingsPage.tsx' import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx' import { LeaveGuard } from './LeaveGuard.tsx' import { ChatWebhookCard, NotificationsCard } from './NotificationsPage.tsx' import { RecipeSearchCard } from './RecipeSearchCard.tsx' +import { RegularsManager } from './RegularsSettingsPage.tsx' import styles from './SettingsTabs.module.css' type Tab = 'household' | 'regulars' | 'notifications-ai' @@ -101,13 +101,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) { {panel('household', styles.single, )} - {panel( - 'regulars', - styles.single, - // TODO(Feature 12): embed the Regular dinners screen here once Feature - // 12 builds it. - Managing the household's Regulars arrives in a later update., - )} + {panel('regulars', styles.single, )} {panel( 'notifications-ai', @@ -128,17 +122,3 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) { ) } - -// 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 ( - -

- {title} -

-

{children}

-
- ) -} diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx index febea05..37a602a 100644 --- a/web/src/test/ApiProviders.tsx +++ b/web/src/test/ApiProviders.tsx @@ -2,7 +2,8 @@ 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 { errorResponse, fakeLocation, jsonResponse } from './fetchStub.ts' +import { dinnerBody } from './dinners.ts' +import { fakeLocation, jsonResponse } from './fetchStub.ts' // AI_SETTINGS is a GET /api/v1/settings/ai body: Anthropic with a verified key. export const AI_SETTINGS = { @@ -99,12 +100,16 @@ export const CHAT_WEBHOOK = { last_test_status: null, } +// SETTINGS_REGULARS is a GET /api/v1/dinners?status=regular body: one +// Regular, its recipe found. +export const SETTINGS_REGULARS = { dinners: [dinnerBody('r1', 'Beef tacos')] } + // SETTINGS_API answers the settings home's requests, for stubFetchByPath, as -// today's server does: the dinners endpoint does not exist yet, and neither -// Google Calendar nor Google Chat is connected. The home's sub-tabs stay mounted on mobile, so -// it also reads the AI models and recipe search settings. +// today's server does: one Regular on rotation, and neither Google Calendar +// nor Google Chat connected. The home's sub-tabs stay mounted on mobile, so +// it also reads the Regulars list, the AI models and recipe search settings. export const SETTINGS_API = { - '/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'), + '/api/v1/dinners?status=regular': () => jsonResponse(200, SETTINGS_REGULARS), '/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED), '/api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS), '/api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK_NONE),