From ee7b329ec8a928618129a19c8443f84866811a74 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 11 Oct 2026 00:27:59 -0400 Subject: [PATCH] Embed the Regulars screen in the Settings Regular dinners tab At 768 px and up the Settings "Regular dinners" sub-tab (mock 2e) now renders RegularsManager, the body of /settings/regulars, in place of the Feature 11 placeholder, so the tab lists, adds, edits and fixes recipes with the same code. The settings fixtures answer the Regulars endpoint, and the tab is tested at 1440 and 1180 px. Task: 8a919836-f5aa-4b0e-92ae-a72397c74ee8 Co-Authored-By: Claude Opus 5.5 --- web/src/settings/RegularsSettingsPage.tsx | 5 +- web/src/settings/SettingsPage.test.tsx | 5 +- web/src/settings/SettingsTabs.module.css | 21 ---- web/src/settings/SettingsTabs.test.tsx | 134 ++++++++++++++++++++-- web/src/settings/SettingsTabs.tsx | 24 +--- web/src/test/ApiProviders.tsx | 15 ++- 6 files changed, 144 insertions(+), 60 deletions(-) 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), -- 2.55.0