diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c33d566..5b5ed88 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -500,6 +500,19 @@ wires Web Push, "Turn on notifications" asks for nothing. Offline on the saved p settings cannot be read (the service worker never caches `/api/v1/settings`), so the saved pages open as they are. +Regular dinners (`/settings/regulars`) lists the Regulars from +`GET /api/v1/dinners?status=regular`, each with its photo, "method · +minutes", favorite heart and grounding badge ("Finding recipe…" while +`pending`, "Recipe found" when `grounded` or `manual`, "Needs a recipe" when +`failed`). The add field takes a name or a pasted `https://` recipe link. +Tapping a Regular opens an edit sheet (name, method, minutes, difficulty; +remove archives it after a confirmation); tapping one that needs a recipe +opens "Fix recipe" (pick one of the recipe candidates, paste a link or type +the ingredients and steps in). Adds, removals and the heart show at once and +are undone if the server refuses them. While any Regular is `pending`, the +list is read again every 3 seconds (not while a change is in flight), and +polling stops once none is. + ## Boundaries and gotchas - **Secrets never reach the client** — API keys, OAuth tokens and the Chat diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 94b54b0..e06992c 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -7,6 +7,7 @@ import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stub import App, { routes } from './App.tsx' import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx' import { InDataRouter } from './test/dataRouter.tsx' +import { dinnerBody } from './test/dinners.ts' afterEach(() => { Reflect.deleteProperty(window, 'matchMedia') @@ -27,7 +28,13 @@ function renderAppAt(path: string, location = fakeLocation(path)) { } // The routes that render a built screen rather than a placeholder page. -const SCREENS = { '/onboarding': true, '/settings': true, '/settings/ai': true, '/settings/household': true } +const SCREENS = { + '/onboarding': true, + '/settings': true, + '/settings/ai': true, + '/settings/household': true, + '/settings/regulars': true, +} // SIGNED_IN answers what every route reads first: the session, then the // household settings, onboarding finished. @@ -103,6 +110,18 @@ describe('App routes', () => { expect(screen.getByRole('main')).toContainElement(heading) expect(await screen.findByLabelText('Dinner time')).toHaveValue('17:30') }) + + it('/settings/regulars renders the Regular dinners screen inside the shell', async () => { + mockViewport(390) + stubFetchByPath({ + ...SIGNED_IN, + '/api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: [dinnerBody('d1', 'Beef tacos')] }), + }) + renderAt('/settings/regulars') + const heading = await screen.findByRole('heading', { level: 1, name: 'Regular dinners' }) + expect(screen.getByRole('main')).toContainElement(heading) + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + }) }) describe('App first-run redirect', () => { diff --git a/web/src/App.tsx b/web/src/App.tsx index b038eb2..1becaca 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,6 +10,7 @@ import { OfflinePill } from './pwa/OfflinePill.tsx' import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx' import { AISettingsPage } from './settings/AISettingsPage.tsx' import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx' +import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx' import { SettingsPage } from './settings/SettingsPage.tsx' import { AppShell } from './shell/AppShell.tsx' @@ -37,6 +38,7 @@ const screens: Partial> = { '/settings': , '/settings/ai': , '/settings/household': , + '/settings/regulars': , } // AppRoutes is the shell and its pages, shown once /api/v1/me has loaded. diff --git a/web/src/dinners/api.ts b/web/src/dinners/api.ts index 28ed091..095ca20 100644 --- a/web/src/dinners/api.ts +++ b/web/src/dinners/api.ts @@ -5,9 +5,55 @@ import { isRecord } from '../api/errors.ts' export const DINNERS_PATH = '/api/v1/dinners' export const REGULARS_PATH = `${DINNERS_PATH}?status=regular` -// Regular is the part of a dinner the Regulars list reads. pending is true -// while an optimistic add waits for the server; its id is not the server's. -export type Regular = { id: string; name: string; pending?: true } +// GROUNDING_POLL_MS is how often the Regulars are read again while any of +// them is still finding its recipe. +export const GROUNDING_POLL_MS = 3000 + +// Methods and Difficulties are the values the dinners API allows. +export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const +export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const +const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const + +export type Method = (typeof METHODS)[number] +export type Difficulty = (typeof DIFFICULTIES)[number] +export type GroundingStatus = (typeof GROUNDING_STATUSES)[number] + +// Regular is the part of a dinner the Regulars screens read. photo_id is +// the stored photo's id, from photo_url. pending is true while an +// optimistic add waits for the server; its id is not the server's. +export type Regular = { + id: string + name: string + method: Method | null + minutes_total: number | null + difficulty: Difficulty | null + is_favorite: boolean + photo_id: string | undefined + grounding_status: GroundingStatus + pending?: true +} + +// NewRegular is a POST /api/v1/dinners body: a name or a recipe link. +export type NewRegular = { name: string } | { recipe_url: string } + +// RegularPatch is a PATCH /api/v1/dinners/{id} body; every field is optional. +export type RegularPatch = Partial<{ name: string; method: Method; minutes_total: number; difficulty: Difficulty }> + +// ManualRecipe is a recipe the household types in. +export type ManualRecipe = { ingredients: string[]; steps: string[]; minutes?: number; method?: Method } + +// RecipeFix is a POST /api/v1/dinners/{id}/ground body: exactly one way to +// ground the dinner. +export type RecipeFix = { candidate_id: string } | { recipe_url: string } | { manual: ManualRecipe } + +// RecipeCandidate is one recipe page a dinner could be grounded in. +export type RecipeCandidate = { + candidate_id: string + title: string + site_name: string + photo_id: string | undefined + minutes: number | null +} // CachedRegular is a Regular as the cache holds it. removing is true while // an optimistic removal waits for the server: the entry keeps its place, so @@ -18,22 +64,91 @@ function shown(list: CachedRegular[]): Regular[] { return list.filter((regular) => !regular.removing) } +function restored(regular: CachedRegular): CachedRegular { + const copy = { ...regular } + delete copy.removing + return copy +} + // REGULARS_KEY prefixes every Regulars query, so a change refreshes them all // (the settings home's count included). export const REGULARS_KEY = ['dinners', 'regular'] as const const REGULARS_LIST_KEY = [...REGULARS_KEY, 'list'] as const const REGULARS_MUTATION_KEY = ['dinners', 'regular', 'change'] as const +function oneOf(where: string, field: string, values: readonly T[], value: unknown): T { + if (typeof value !== 'string' || !(values as readonly string[]).includes(value)) { + throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not one of ${values.join(', ')}`) + } + return value as T +} + +function nullable(value: unknown, parse: (value: unknown) => T): T | null { + return value === null ? null : parse(value) +} + +function wholeNumber(where: string, field: string, value: unknown): number { + if (typeof value !== 'number' || !Number.isInteger(value)) throw new Error(`${where}: ${field} is not a whole number`) + return value +} + +const PHOTO_URL = /^\/media\/photos\/([^/]+)-400\.jpg$/ + +// photoId reads the photo id out of a photo_url, which the server always +// sends as /media/photos/{id}-400.jpg or null. +function photoId(where: string, value: unknown): string | undefined { + if (value === null) return undefined + const match = typeof value === 'string' ? PHOTO_URL.exec(value) : null + if (!match?.[1]) throw new Error(`${where}: photo_url ${JSON.stringify(value)} is not a stored photo`) + return decodeURIComponent(match[1]) +} + function parseDinner(where: string, body: unknown): Regular { if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') { throw new Error(`${where}: response is not a dinner`) } - return { id: body.id, name: body.name } + if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`) + return { + id: body.id, + name: body.name, + method: nullable(body.method, (v) => oneOf(where, 'method', METHODS, v)), + minutes_total: nullable(body.minutes_total, (v) => wholeNumber(where, 'minutes_total', v)), + difficulty: nullable(body.difficulty, (v) => oneOf(where, 'difficulty', DIFFICULTIES, v)), + is_favorite: body.is_favorite, + photo_id: photoId(where, body.photo_url), + grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status), + } } -// useRegulars is the household's Regulars, in the server's order. +function parseCandidate(where: string, body: unknown): RecipeCandidate { + if ( + !isRecord(body) || + typeof body.candidate_id !== 'string' || + typeof body.title !== 'string' || + typeof body.site_name !== 'string' + ) { + throw new Error(`${where}: response is not a recipe candidate`) + } + return { + candidate_id: body.candidate_id, + title: body.title, + site_name: body.site_name, + photo_id: photoId(where, body.photo_url), + minutes: nullable(body.minutes, (v) => wholeNumber(where, 'minutes', v)), + } +} + +function dinnerPath(id: string): string { + return `${DINNERS_PATH}/${encodeURIComponent(id)}` +} + +// useRegulars is the household's Regulars, in the server's order. While any +// of them is still finding its recipe it is read again every +// GROUNDING_POLL_MS, except while a change is in flight (whose settling +// reads it again anyway), so a poll never overwrites an optimistic change. export function useRegulars() { const { apiFetch } = useApi() + const queryClient = useQueryClient() return useQuery({ queryKey: REGULARS_LIST_KEY, queryFn: async () => { @@ -43,6 +158,11 @@ export function useRegulars() { return body.dinners.map((dinner): CachedRegular => parseDinner(where, dinner)) }, select: shown, + refetchInterval: (query) => { + const list = query.state.data + if (list === undefined || queryClient.isMutating({ mutationKey: REGULARS_MUTATION_KEY }) > 0) return false + return list.some((regular) => regular.grounding_status === 'pending') ? GROUNDING_POLL_MS : false + }, }) } @@ -57,6 +177,11 @@ function setRegulars(queryClient: QueryClient, change: (list: CachedRegular[]) = queryClient.setQueryData(REGULARS_LIST_KEY, (list) => change(list ?? [])) } +// replace puts the server's answer for a dinner in its place in the list. +function replace(queryClient: QueryClient, dinner: Regular) { + setRegulars(queryClient, (list) => list.map((regular) => (regular.id === dinner.id ? dinner : regular))) +} + // settle refetches the Regulars once the last change in flight is done, so // an earlier change's refetch never drops a later change's optimistic entry. function settle(queryClient: QueryClient) { @@ -67,24 +192,37 @@ function settle(queryClient: QueryClient) { let pendingIds = 0 -// useAddRegular adds a Regular by name (POST /api/v1/dinners). It shows in -// the list at once and is taken out again if the server refuses it. +// useAddRegular adds a Regular by name or recipe link (POST +// /api/v1/dinners). It shows in the list at once — a link under the link +// itself until the server names it — and is taken out again if the server +// refuses it. export function useAddRegular() { const { apiFetch } = useApi() const queryClient = useQueryClient() return useMutation({ mutationKey: REGULARS_MUTATION_KEY, - mutationFn: async (name: string) => - parseDinner(`POST ${DINNERS_PATH}`, await apiFetch(DINNERS_PATH, { method: 'POST', body: { name } })), - onMutate: async (name) => { + mutationFn: async (add: NewRegular) => + parseDinner(`POST ${DINNERS_PATH}`, await apiFetch(DINNERS_PATH, { method: 'POST', body: add })), + onMutate: async (add) => { const id = `pending-${++pendingIds}` - await changeOptimistically(queryClient, (list) => [...list, { id, name, pending: true }]) + const optimistic: CachedRegular = { + id, + name: 'name' in add ? add.name : add.recipe_url, + method: null, + minutes_total: null, + difficulty: null, + is_favorite: false, + photo_id: undefined, + grounding_status: 'pending', + pending: true, + } + await changeOptimistically(queryClient, (list) => [...list, optimistic]) return { id } }, - onSuccess: (dinner, _name, context) => { + onSuccess: (dinner, _add, context) => { setRegulars(queryClient, (list) => list.map((regular) => (regular.id === context.id ? dinner : regular))) }, - onError: (_error, _name, context) => { + onError: (_error, _add, context) => { if (context === undefined) return setRegulars(queryClient, (list) => list.filter((regular) => regular.id !== context.id)) }, @@ -101,7 +239,7 @@ export function useRemoveRegular() { return useMutation({ mutationKey: REGULARS_MUTATION_KEY, mutationFn: async (regular: Regular) => { - await apiFetch(`${DINNERS_PATH}/${encodeURIComponent(regular.id)}`, { method: 'DELETE' }) + await apiFetch(dinnerPath(regular.id), { method: 'DELETE' }) }, onMutate: async (regular) => { await changeOptimistically(queryClient, (list) => @@ -112,8 +250,82 @@ export function useRemoveRegular() { setRegulars(queryClient, (list) => list.filter((r) => r.id !== regular.id)) }, onError: (_error, regular) => { - setRegulars(queryClient, (list) => list.map((r) => (r.id === regular.id ? { id: r.id, name: r.name } : r))) + setRegulars(queryClient, (list) => list.map((r) => (r.id === regular.id ? restored(r) : r))) }, onSettled: () => settle(queryClient), }) } + +// useEditRegular saves changes to a Regular (PATCH /api/v1/dinners/{id}) +// and shows the server's answer once it is saved. +export function useEditRegular() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationKey: REGULARS_MUTATION_KEY, + mutationFn: async ({ id, patch }: { id: string; patch: RegularPatch }) => + parseDinner(`PATCH ${DINNERS_PATH}/{id}`, await apiFetch(dinnerPath(id), { method: 'PATCH', body: patch })), + onSuccess: (dinner) => replace(queryClient, dinner), + onSettled: () => settle(queryClient), + }) +} + +// useToggleFavorite toggles a Regular's favorite heart (POST +// /api/v1/dinners/{id}/favorite). The heart changes at once and changes +// back if the server refuses. +export function useToggleFavorite() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + const set = (id: string, is_favorite: boolean) => + setRegulars(queryClient, (list) => list.map((r) => (r.id === id ? { ...r, is_favorite } : r))) + return useMutation({ + mutationKey: REGULARS_MUTATION_KEY, + mutationFn: async (regular: Regular) => + parseDinner( + `POST ${DINNERS_PATH}/{id}/favorite`, + await apiFetch(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }), + ), + onMutate: async (regular) => { + await changeOptimistically(queryClient, (list) => + list.map((r) => (r.id === regular.id ? { ...r, is_favorite: !regular.is_favorite } : r)), + ) + }, + onSuccess: (dinner) => replace(queryClient, dinner), + onError: (_error, regular) => set(regular.id, regular.is_favorite), + onSettled: () => settle(queryClient), + }) +} + +// useGroundRegular fixes a Regular's recipe (POST /api/v1/dinners/{id}/ground) +// with a candidate, a pasted link or a recipe typed in. A candidate or link +// is read in the request, which can take up to a minute and a half. +export function useGroundRegular() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationKey: REGULARS_MUTATION_KEY, + mutationFn: async ({ id, fix }: { id: string; fix: RecipeFix }) => + parseDinner( + `POST ${DINNERS_PATH}/{id}/ground`, + await apiFetch(`${dinnerPath(id)}/ground`, { method: 'POST', body: fix }), + ), + onSuccess: (dinner) => replace(queryClient, dinner), + onSettled: () => settle(queryClient), + }) +} + +// useRecipeCandidates is up to three recipe pages the dinner could be +// grounded in, best first (GET /api/v1/dinners/{id}/recipe-candidates). +export function useRecipeCandidates(id: string) { + const { apiFetch } = useApi() + return useQuery({ + queryKey: ['dinners', 'candidates', id], + queryFn: async () => { + const path = `${dinnerPath(id)}/recipe-candidates` + const where = `GET ${DINNERS_PATH}/{id}/recipe-candidates` + const body = await apiFetch(path) + if (!isRecord(body) || !Array.isArray(body.candidates)) throw new Error(`${where}: response has no candidates list`) + return body.candidates.map((candidate) => parseCandidate(where, candidate)) + }, + }) +} diff --git a/web/src/dinners/useRefusals.ts b/web/src/dinners/useRefusals.ts new file mode 100644 index 0000000..485e9e3 --- /dev/null +++ b/web/src/dinners/useRefusals.ts @@ -0,0 +1,26 @@ +import { useRef, useState } from 'react' + +// Refusal is one change the server turned down. key is unique to that +// attempt, so two attempts of the same change in flight each keep their own +// refusal. change names what was tried, so trying it again once it has been +// refused clears the earlier refusal and no other. +export type Refusal = { key: number; change: string; text: string } + +// useRefusals keeps each change's own refusal: the mutation hooks only +// report their latest change, which would hide a refusal while another +// change is in flight. track(change, what, answer) clears change's earlier +// refusal and records "Could not {what}: …" if answer rejects. +export function useRefusals() { + const [refusals, setRefusals] = useState([]) + const attempts = useRef(0) + + function track(change: string, what: string, answer: Promise) { + const key = ++attempts.current + setRefusals((all) => all.filter((refusal) => refusal.change !== change)) + answer.catch((error: Error) => { + setRefusals((all) => [...all, { key, change, text: `Could not ${what}: ${error.message}` }]) + }) + } + + return { refusals, track } +} diff --git a/web/src/onboarding/OnboardingPage.test.tsx b/web/src/onboarding/OnboardingPage.test.tsx index 41615b2..e6209d4 100644 --- a/web/src/onboarding/OnboardingPage.test.tsx +++ b/web/src/onboarding/OnboardingPage.test.tsx @@ -5,6 +5,7 @@ import App from '../App.tsx' import { createNetworkStatus } from '../pwa/networkStatus.ts' import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' import { declaredStyle } from '../test/cssRules.ts' +import { dinnerBody, type DinnerBody } from '../test/dinners.ts' import { InDataRouter } from '../test/dataRouter.tsx' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' import { expectMinHitArea, hitArea } from '../test/hitArea.ts' @@ -14,7 +15,7 @@ afterEach(() => { vi.unstubAllGlobals() }) -type Dinner = { id: string; name: string } +type Dinner = DinnerBody // A request the fake server holds until the test answers it. type Held = { method: string; path: string; body: unknown; answer: (response: Response) => void } @@ -53,8 +54,8 @@ function stubDinnersServer(regulars: Dinner[] = []) { // Regular to the list later GETs read. function created(server: ReturnType, request: Held, id: string) { const { name } = request.body as { name: string } - server.regulars.push({ id, name }) - request.answer(jsonResponse(201, { id, name, status: 'regular', grounding_status: 'pending', grounding_job_id: 'job-1' })) + server.regulars.push(dinnerBody(id, name)) + request.answer(jsonResponse(201, { ...dinnerBody(id, name, { grounding_status: 'pending' }), grounding_job_id: 'job-1' })) } // renderAt renders the whole app, so /me supplies the CSRF token every @@ -125,7 +126,7 @@ describe('onboarding step 1', () => { describe('onboarding step 2 (mock 1h)', () => { it('shows the step as the mock does', async () => { - await regularsStep([{ id: 'd1', name: 'Beef tacos' }]) + await regularsStep([dinnerBody('d1', 'Beef tacos')]) expect(screen.getByText('Step 2 of 3')).toBeInTheDocument() expect(screen.getByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument() @@ -198,7 +199,7 @@ describe('onboarding step 2 (mock 1h)', () => { }) it('takes an add back out and says why when the server refuses it', async () => { - const server = await regularsStep([{ id: 'd1', name: 'Chili' }]) + const server = await regularsStep([dinnerBody('d1', 'Chili')]) fireEvent.change(input(), { target: { value: 'chili' } }) fireEvent.submit(input()) await vi.waitFor(() => expect(regularNames()).toEqual(['Chili', 'chili'])) @@ -273,8 +274,8 @@ describe('onboarding step 2 (mock 1h)', () => { it('removes a Regular at once and archives it on the server', async () => { const server = await regularsStep([ - { id: 'd1', name: 'Beef tacos' }, - { id: 'd2', name: 'Pizza night' }, + dinnerBody('d1', 'Beef tacos'), + dinnerBody('d2', 'Pizza night'), ]) fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' })) @@ -288,8 +289,8 @@ describe('onboarding step 2 (mock 1h)', () => { it('puts a removed Regular back in its place when the server refuses', async () => { const server = await regularsStep([ - { id: 'd1', name: 'Beef tacos' }, - { id: 'd2', name: 'Pizza night' }, + dinnerBody('d1', 'Beef tacos'), + dinnerBody('d2', 'Pizza night'), ]) fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' })) await vi.waitFor(() => expect(server.held).toHaveLength(1)) @@ -301,9 +302,9 @@ describe('onboarding step 2 (mock 1h)', () => { it('puts a refused removal back and says why while another removal is still unanswered', async () => { const server = await regularsStep([ - { id: 'd1', name: 'Beef tacos' }, - { id: 'd2', name: 'Pizza night' }, - { id: 'd3', name: 'Chili' }, + dinnerBody('d1', 'Beef tacos'), + dinnerBody('d2', 'Pizza night'), + dinnerBody('d3', 'Chili'), ]) fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' })) await vi.waitFor(() => expect(server.held).toHaveLength(1)) @@ -325,9 +326,9 @@ describe('onboarding step 2 (mock 1h)', () => { it('puts two refused removals back in their places without rereading the list', async () => { const server = await regularsStep([ - { id: 'd1', name: 'Beef tacos' }, - { id: 'd2', name: 'Pizza night' }, - { id: 'd3', name: 'Chili' }, + dinnerBody('d1', 'Beef tacos'), + dinnerBody('d2', 'Pizza night'), + dinnerBody('d3', 'Chili'), ]) server.reads.fail = true fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' })) @@ -351,8 +352,8 @@ describe('onboarding step 2 (mock 1h)', () => { it('enables "Next: notifications" once the server has saved 3 Regulars', async () => { const server = await regularsStep([ - { id: 'd1', name: 'Beef tacos' }, - { id: 'd2', name: 'Pizza night' }, + dinnerBody('d1', 'Beef tacos'), + dinnerBody('d2', 'Pizza night'), ]) expect(next()).toBeDisabled() @@ -377,8 +378,8 @@ describe('onboarding step 2 (mock 1h)', () => { it('keeps "Next: notifications" disabled when the third add is refused', async () => { const server = await regularsStep([ - { id: 'd1', name: 'Beef tacos' }, - { id: 'd2', name: 'Pizza night' }, + dinnerBody('d1', 'Beef tacos'), + dinnerBody('d2', 'Pizza night'), ]) fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' })) await screen.findByRole('heading', { level: 2, name: 'Your regulars · 3' }) @@ -393,9 +394,9 @@ describe('onboarding step 2 (mock 1h)', () => { it('keeps "Next: notifications" disabled while a fourth add is unanswered', async () => { const server = await regularsStep([ - { id: 'd1', name: 'Beef tacos' }, - { id: 'd2', name: 'Pizza night' }, - { id: 'd3', name: 'Chili' }, + dinnerBody('d1', 'Beef tacos'), + dinnerBody('d2', 'Pizza night'), + dinnerBody('d3', 'Chili'), ]) expect(next()).toBeEnabled() diff --git a/web/src/onboarding/RegularsStep.tsx b/web/src/onboarding/RegularsStep.tsx index c867732..c238670 100644 --- a/web/src/onboarding/RegularsStep.tsx +++ b/web/src/onboarding/RegularsStep.tsx @@ -1,7 +1,8 @@ -import { useRef, useState, type FormEvent } from 'react' +import { useState, type FormEvent } from 'react' import { Plus } from 'lucide-react' import { useNavigate } from 'react-router' import { useAddRegular, useRegulars, useRemoveRegular, type Regular } from '../dinners/api.ts' +import { useRefusals } from '../dinners/useRefusals.ts' import { Button } from '../ui/Button.tsx' import { Chip } from '../ui/Chip.tsx' import { ErrorBanner } from '../ui/ErrorBanner.tsx' @@ -31,12 +32,6 @@ function sameName(a: string, b: string): boolean { return a.trim().toLowerCase() === b.trim().toLowerCase() } -// Refusal is one add or removal the server turned down. key is unique to -// that attempt, so two attempts of the same change in flight each keep their -// own refusal. change names what was tried, so trying it again once it has -// been refused clears the earlier refusal and no other. -type Refusal = { key: number; change: string; text: string } - // RegularsStep is onboarding step 2, handoff mock 1h: the household adds // the dinners it makes on repeat. Each add and remove shows at once and is // undone if the server refuses it. @@ -46,11 +41,7 @@ export function RegularsStep() { const add = useAddRegular() const remove = useRemoveRegular() const [draft, setDraft] = useState('') - // Each change keeps its own refusal: the mutation hooks only report their - // latest change, which would hide a refusal while another change is in - // flight. - const [refusals, setRefusals] = useState([]) - const attempts = useRef(0) + const { refusals, track } = useRefusals() const list = regulars.data ?? [] const picks = commonPicks.filter((pick) => !list.some((regular) => sameName(regular.name, pick))) // Next waits for every add to be answered, so a refused add's error shows @@ -58,16 +49,8 @@ export function RegularsStep() { const adding = list.some((regular) => regular.pending) const saved = list.filter((regular) => !regular.pending).length - function track(change: string, what: string, answer: Promise) { - const key = ++attempts.current - setRefusals((all) => all.filter((refusal) => refusal.change !== change)) - answer.catch((error: Error) => { - setRefusals((all) => [...all, { key, change, text: `Could not ${what}: ${error.message}` }]) - }) - } - function addRegular(name: string) { - track(`add:${name.toLowerCase()}`, `add ${name}`, add.mutateAsync(name)) + track(`add:${name.toLowerCase()}`, `add ${name}`, add.mutateAsync({ name })) } function removeRegular(regular: Regular) { diff --git a/web/src/settings/RegularsSettingsPage.module.css b/web/src/settings/RegularsSettingsPage.module.css new file mode 100644 index 0000000..e952791 --- /dev/null +++ b/web/src/settings/RegularsSettingsPage.module.css @@ -0,0 +1,295 @@ +/* Settings · Regular dinners. */ +.page { + box-sizing: border-box; + min-height: 100%; + max-width: 560px; + display: flex; + flex-direction: column; + gap: 18px; + padding-top: 6px; +} + +.header { + display: flex; + align-items: center; + gap: 16px; +} + +.kicker { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-accent-700); +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; +} + +.shared { + margin: 0; + padding: 14px 16px; + border-radius: 26px; + background: var(--color-accent-2-100); + color: var(--color-accent-2-900); + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; +} + +/* The add field: the input fills the row beside the grape + button. */ +.add { + display: flex; + align-items: center; + gap: 8px; +} + +.add > :first-child { + flex: 1; + min-width: 0; +} + +.add .addInput { + width: 100%; +} + +.loading { + height: 320px; + border-radius: 26px; +} + +.section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.groupLabel { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.card { + padding: 6px; + border: 1px solid var(--color-neutral-300); +} + +.rows { + margin: 0; + padding: 0; + list-style: none; +} + +.item + .item { + border-top: 1px solid var(--color-divider); +} + +.pending { + opacity: 0.6; +} + +.photo { + width: 48px; + height: 48px; +} + +.meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; + margin-top: 4px; +} + +/* Beat the Tag's own tones whatever order the sheets load in. */ +.meta .finding { + background: var(--color-neutral-200); + color: var(--color-neutral-700); +} + +.meta .found { + background: var(--color-accent-2-200); + color: var(--color-accent-2-800); +} + +.meta .needs { + background: var(--color-accent-100); + color: var(--color-accent-800); +} + +.filled { + color: var(--color-accent); + fill: currentColor; +} + +.hint, +.problem, +.status { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.4; +} + +.hint { + color: var(--color-neutral-700); +} + +.problem { + font-weight: 700; + color: var(--color-accent-700); +} + +.status { + color: var(--color-accent-2-800); +} + +.status:empty { + display: none; +} + +.sheet { + display: flex; + flex-direction: column; + gap: 14px; + max-width: 560px; + margin: 0 auto; +} + +.sheetTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.columns { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 10px; + align-items: end; +} + +/* Beside a dropdown, a minutes field matches its height. */ +.columns .paired { + width: 100%; + height: 56px; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.label { + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.textarea { + box-sizing: border-box; + width: 100%; + min-height: 120px; + padding: 14px 18px; + border: 1px solid var(--color-neutral-300); + border-radius: 22px; + background: var(--color-surface); + color: var(--color-text); + font-family: var(--font-body); + font-size: 15px; + line-height: 1.5; + resize: vertical; +} + +.actions { + display: grid; + gap: 10px; +} + +.actions > button { + min-height: 52px; +} + +.candidatesLoading { + height: 200px; + border-radius: 22px; +} + +.candidates { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.candidate { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-width: 44px; + min-height: 72px; + padding: 8px 16px 8px 8px; + border: 1px solid var(--color-neutral-300); + border-radius: 22px; + background: var(--color-surface); + color: var(--color-text); + text-align: start; + cursor: pointer; +} + +.candidate:hover { + background: var(--color-neutral-200); +} + +.candidatePhoto { + flex: none; + width: 56px; + height: 56px; +} + +.candidateBody { + display: flex; + flex-direction: column; + min-width: 0; +} + +.candidateTitle { + font-family: var(--font-body); + font-size: 15px; + font-weight: 700; +} + +.candidateSite { + font-family: var(--font-body); + font-size: 12px; + font-weight: 600; + color: var(--color-neutral-700); +} + +/* A form locked while its save is pending. It lays out as if it were not + there. */ +.lock { + display: contents; +} diff --git a/web/src/settings/RegularsSettingsPage.test.tsx b/web/src/settings/RegularsSettingsPage.test.tsx new file mode 100644 index 0000000..83a8718 --- /dev/null +++ b/web/src/settings/RegularsSettingsPage.test.tsx @@ -0,0 +1,490 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { Route, Routes } from 'react-router' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { GROUNDING_POLL_MS } from '../dinners/api.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { InDataRouter } from '../test/dataRouter.tsx' +import { dinnerBody, type DinnerBody } from '../test/dinners.ts' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { RegularsSettingsPage } from './RegularsSettingsPage.tsx' + +const REGULARS = '/api/v1/dinners?status=regular' + +type Responder = (body: unknown) => Response | Promise + +// stubServer stands in for the dinners API: GET of the Regulars answers +// the current server.regulars, so a test changes what the next read sees; +// every other request is answered by responders, keyed "METHOD path" and +// called with the request's decoded JSON body. Any other request fails the +// test. +function stubServer(regulars: DinnerBody[], responders: Record = {}) { + const server = { regulars, fetchMock: undefined as unknown as Mock } + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + [`GET ${REGULARS}`]: () => jsonResponse(200, { dinners: server.regulars }), + ...responders, + } + server.fetchMock = vi.fn((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', server.fetchMock) + return server +} + +// sent lists the requests other than GETs, as "METHOD path" and body. +function sent(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([, init]) => (init?.method ?? 'GET') !== 'GET') + .map(([input, init]) => ({ + request: `${init?.method} ${String(input)}`, + body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined, + })) +} + +function reads(fetchMock: Mock) { + return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === REGULARS) + .length +} + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +// renderPage loads /me (for the CSRF token every write needs) and renders +// the page at /settings/regulars once its Regulars have loaded. +async function renderPage() { + const api = createApiClient(fakeLocation('/settings/regulars'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + render( + + + + + } /> + + + + , + ) + await screen.findByRole('heading', { level: 2, name: /^On rotation/ }) +} + +const TACOS = dinnerBody('d1', 'Beef tacos', { + method: 'stovetop', + minutes_total: 40, + difficulty: 'easy', + is_favorite: true, + photo_url: '/media/photos/p1-400.jpg', +}) +const CHILI = dinnerBody('d2', 'Chili', { method: 'crockpot', minutes_total: 480, grounding_status: 'pending' }) +const PIZZA = dinnerBody('d3', 'Pizza night', { grounding_status: 'failed', grounding_error: 'no_recipe_found' }) +const ROAST = dinnerBody('d4', 'Pot roast', { method: 'oven', grounding_status: 'manual' }) + +function rows() { + return within(screen.getByRole('list', { name: 'Regulars' })).queryAllByRole('listitem') +} + +function row(name: string) { + const item = rows().find((li) => within(li).queryByText(name) !== null) + if (item === undefined) throw new Error(`no Regular row named ${name}`) + return item +} + +function addField() { + return screen.getByRole('textbox', { name: 'Add a Regular' }) +} + +function add(text: string) { + fireEvent.change(addField(), { target: { value: text } }) + fireEvent.click(screen.getByRole('button', { name: 'Add Regular' })) +} + +function openRow(name: string) { + fireEvent.click(within(row(name)).getByRole('button', { name: new RegExp(`^${name}`) })) +} + +describe('RegularsSettingsPage', () => { + it('lists each Regular with its photo, method and minutes, favorite heart and grounding badge', async () => { + stubServer([TACOS, CHILI, PIZZA, ROAST]) + await renderPage() + + expect(screen.getByRole('heading', { level: 1, name: 'Regular dinners' })).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 4' })).toBeInTheDocument() + expect(rows()).toHaveLength(4) + + const tacos = within(row('Beef tacos')) + expect(tacos.getByRole('img', { name: 'Beef tacos' })).toHaveAttribute('src', '/media/photos/p1-400.jpg') + expect(tacos.getByText('Stovetop · 40 min')).toBeInTheDocument() + expect(tacos.getByText('Recipe found')).toBeInTheDocument() + expect(tacos.getByRole('button', { name: 'Favorite Beef tacos' })).toHaveAttribute('aria-pressed', 'true') + + const chili = within(row('Chili')) + expect(chili.getByText('Crock pot · 480 min')).toBeInTheDocument() + expect(chili.getByText('Finding recipe…')).toBeInTheDocument() + expect(chili.getByRole('button', { name: 'Favorite Chili' })).toHaveAttribute('aria-pressed', 'false') + + const pizza = within(row('Pizza night')) + // No photo yet: the striped placeholder stands in. + expect(pizza.getByRole('img', { name: 'Pizza night' })).toHaveTextContent('dish photo') + expect(pizza.getByText('Needs a recipe')).toBeInTheDocument() + + expect(within(row('Pot roast')).getByText('Oven')).toBeInTheDocument() + expect(within(row('Pot roast')).getByText('Recipe found')).toBeInTheDocument() + }) + + it('keeps every control at least 44 px', async () => { + stubServer([TACOS]) + await renderPage() + expectMinHitArea(screen.getByRole('button', { name: 'Add Regular' })) + expectMinHitArea(screen.getByRole('button', { name: 'Favorite Beef tacos' })) + expectMinHitArea(within(row('Beef tacos')).getByRole('button', { name: /^Beef tacos/ })) + expectMinHitArea(screen.getByRole('button', { name: 'Back to Settings' })) + }) + + it('says so when there are no Regulars yet', async () => { + stubServer([]) + await renderPage() + expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 0' })).toBeInTheDocument() + expect(screen.getByText(/No Regulars yet/)).toBeInTheDocument() + }) + + it('reports Regulars that could not load, and retries', async () => { + const server = stubServer([], { [`GET ${REGULARS}`]: () => errorResponse(500, 'internal') }) + const api = createApiClient(fakeLocation('/settings/regulars'), createNetworkStatus(window), createVerifiedSession(localStorage)) + render( + + + + + + + , + ) + expect(await screen.findByRole('alert')).toHaveTextContent('Your Regulars could not load: internal message') + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(reads(server.fetchMock)).toBe(2)) + }) + + it('adds a Regular by name, showing it at once and finding its recipe', async () => { + const server = stubServer([TACOS], { + '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 renderPage() + + add(' Chicken enchiladas ') + expect(addField()).toHaveValue('') + expect(await screen.findByText('Chicken enchiladas')).toBeInTheDocument() + expect(within(row('Chicken enchiladas')).getByText('Finding recipe…')).toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual([{ request: 'POST /api/v1/dinners', body: { name: 'Chicken enchiladas' } }]) + await waitFor(() => expect(row('Chicken enchiladas')).not.toHaveAttribute('aria-busy')) + expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument() + }) + + it('adds a Regular from a pasted recipe link', async () => { + const link = 'https://example.com/recipes/12345/beef-stew/' + const server = stubServer([], { + 'POST /api/v1/dinners': () => { + const created = dinnerBody('d9', 'Beef stew', { grounding_status: 'pending', recipe_url: link }) + server.regulars = [created] + return jsonResponse(201, { ...created, grounding_job_id: 'job-1' }) + }, + }) + await renderPage() + + add(link) + expect(await screen.findByText('Beef stew')).toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual([{ request: 'POST /api/v1/dinners', body: { recipe_url: link } }]) + expect(within(row('Beef stew')).getByText('Finding recipe…')).toBeInTheDocument() + }) + + it('takes back an add the server refuses and says why', async () => { + stubServer([TACOS], { 'POST /api/v1/dinners': () => errorResponse(409, 'duplicate_dinner') }) + await renderPage() + + add('Beef tacos!') + expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Beef tacos!: duplicate_dinner message') + expect(rows()).toHaveLength(1) + }) + + it('toggles the favorite heart', async () => { + const server = stubServer([TACOS], { + 'POST /api/v1/dinners/d1/favorite': () => { + server.regulars = [{ ...TACOS, is_favorite: false }] + return jsonResponse(200, server.regulars[0]) + }, + }) + await renderPage() + + fireEvent.click(screen.getByRole('button', { name: 'Favorite Beef tacos' })) + await waitFor(() => + expect(screen.getByRole('button', { name: 'Favorite Beef tacos' })).toHaveAttribute('aria-pressed', 'false'), + ) + expect(sent(server.fetchMock)).toEqual([{ request: 'POST /api/v1/dinners/d1/favorite', body: undefined }]) + await waitFor(() => expect(reads(server.fetchMock)).toBe(2)) + expect(screen.getByRole('button', { name: 'Favorite Beef tacos' })).toHaveAttribute('aria-pressed', 'false') + }) + + it('edits a Regular: name, method, minutes and difficulty', async () => { + const server = stubServer([TACOS], { + 'PATCH /api/v1/dinners/d1': (body) => { + const edited = { ...TACOS, ...(body as object) } + server.regulars = [edited] + return jsonResponse(200, edited) + }, + }) + await renderPage() + + openRow('Beef tacos') + const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' })) + expect(sheet.getByLabelText('Name')).toHaveValue('Beef tacos') + expect(sheet.getByLabelText('Method')).toHaveValue('stovetop') + expect(sheet.getByLabelText('Minutes')).toHaveValue(40) + expect(sheet.getByLabelText('Difficulty')).toHaveValue('easy') + expect(sheet.getByRole('button', { name: 'Save changes' })).toBeDisabled() + + fireEvent.change(sheet.getByLabelText('Name'), { target: { value: 'Fish tacos' } }) + fireEvent.change(sheet.getByLabelText('Method'), { target: { value: 'grill' } }) + fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '25' } }) + fireEvent.change(sheet.getByLabelText('Difficulty'), { target: { value: 'medium' } }) + fireEvent.click(sheet.getByRole('button', { name: 'Save changes' })) + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(sent(server.fetchMock)).toEqual([ + { + request: 'PATCH /api/v1/dinners/d1', + body: { name: 'Fish tacos', method: 'grill', minutes_total: 25, difficulty: 'medium' }, + }, + ]) + expect(within(row('Fish tacos')).getByText('Grill · 25 min')).toBeInTheDocument() + }) + + it('sends only the changed fields, refuses bad minutes and shows a refused edit', async () => { + const server = stubServer([TACOS], { 'PATCH /api/v1/dinners/d1': () => errorResponse(400, 'invalid_dinner') }) + await renderPage() + + openRow('Beef tacos') + const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' })) + fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '0' } }) + expect(sheet.getByText('Minutes must be a whole number from 1 to 1440.')).toBeInTheDocument() + expect(sheet.getByRole('button', { name: 'Save changes' })).toBeDisabled() + + fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '45' } }) + fireEvent.click(sheet.getByRole('button', { name: 'Save changes' })) + expect(await sheet.findByRole('alert')).toHaveTextContent('Not saved: invalid_dinner message') + expect(sent(server.fetchMock)).toEqual([{ request: 'PATCH /api/v1/dinners/d1', body: { minutes_total: 45 } }]) + }) + + it('removes a Regular only once confirmed', async () => { + const server = stubServer([TACOS, ROAST], { + 'DELETE /api/v1/dinners/d1': () => { + server.regulars = [ROAST] + return jsonResponse(200, { ...TACOS, status: 'archived' }) + }, + }) + await renderPage() + + openRow('Beef tacos') + fireEvent.click(screen.getByRole('button', { name: 'Remove from Regulars' })) + const confirm = within(screen.getByRole('dialog', { name: 'Remove Beef tacos?' })) + fireEvent.click(confirm.getByRole('button', { name: 'Keep it' })) + expect(screen.getByRole('dialog', { name: 'Edit Beef tacos' })).toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual([]) + + fireEvent.click(screen.getByRole('button', { name: 'Remove from Regulars' })) + fireEvent.click(screen.getByRole('button', { name: 'Remove' })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await waitFor(() => expect(rows()).toHaveLength(1)) + expect(within(rows()[0]!).getByText('Pot roast')).toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual([{ request: 'DELETE /api/v1/dinners/d1', body: undefined }]) + }) + + it('puts a refused removal back and says why', async () => { + stubServer([TACOS], { 'DELETE /api/v1/dinners/d1': () => errorResponse(500, 'internal') }) + await renderPage() + + openRow('Beef tacos') + fireEvent.click(screen.getByRole('button', { name: 'Remove from Regulars' })) + fireEvent.click(screen.getByRole('button', { name: 'Remove' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message') + expect(rows()).toHaveLength(1) + }) + + describe('Fix recipe', () => { + const CANDIDATES = { + candidates: [ + { + candidate_id: 'https://example.com/pizza', + title: 'Weeknight pizza', + site_name: 'Example Kitchen', + photo_url: '/media/photos/p7-400.jpg', + minutes: 45, + }, + { candidate_id: 'https://cook.test/pizza', title: 'Sheet-pan pizza', site_name: 'Cook Test', photo_url: null, minutes: null }, + { candidate_id: 'https://food.test/p', title: 'Pizza margherita', site_name: 'Food Test', photo_url: null, minutes: 30 }, + ], + } + + function grounded(server: { regulars: DinnerBody[] }, overrides: Partial) { + const fixed = { ...PIZZA, grounding_error: null, ...overrides } + server.regulars = [fixed] + return jsonResponse(200, fixed) + } + + function fixSheet() { + return within(screen.getByRole('dialog', { name: 'Fix recipe' })) + } + + it('opens from a Needs a recipe row and picks one of up to three matches', async () => { + const server = stubServer([PIZZA], { + 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES), + 'POST /api/v1/dinners/d3/ground': () => + grounded(server, { grounding_status: 'grounded', photo_url: '/media/photos/p7-400.jpg', method: 'oven', minutes_total: 45 }), + }) + await renderPage() + + openRow('Pizza night') + const matches = within(await fixSheet().findByRole('list', { name: 'Recipe matches' })) + const options = matches.getAllByRole('button') + expect(options.map((option) => option.getAttribute('aria-label'))).toEqual([ + 'Use Weeknight pizza from Example Kitchen', + 'Use Sheet-pan pizza from Cook Test', + 'Use Pizza margherita from Food Test', + ]) + expect(within(options[0]!).getByRole('img', { name: 'Weeknight pizza' })).toHaveAttribute('src', '/media/photos/p7-400.jpg') + expect(within(options[0]!).getByText('Example Kitchen')).toBeInTheDocument() + expectMinHitArea(options[0]!) + + fireEvent.click(options[0]!) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(sent(server.fetchMock)).toEqual([ + { request: 'POST /api/v1/dinners/d3/ground', body: { candidate_id: 'https://example.com/pizza' } }, + ]) + expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument() + expect(within(row('Pizza night')).getByText('Oven · 45 min')).toBeInTheDocument() + }) + + it('says so when no match was found', async () => { + stubServer([PIZZA], { 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, { candidates: [] }) }) + await renderPage() + openRow('Pizza night') + expect(await fixSheet().findByText('No matches were found. Paste a recipe link or type it in.')).toBeInTheDocument() + }) + + it('grounds a pasted recipe link, and says why a link was refused', async () => { + let answers = 0 + const server = stubServer([PIZZA], { + 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES), + 'POST /api/v1/dinners/d3/ground': () => + ++answers === 1 ? errorResponse(422, 'ungroundable') : grounded(server, { grounding_status: 'grounded' }), + }) + await renderPage() + + openRow('Pizza night') + fireEvent.click(fixSheet().getByRole('radio', { name: 'Paste a link' })) + fireEvent.change(fixSheet().getByLabelText('Recipe link'), { target: { value: ' https://example.com/blog/pizza ' } }) + fireEvent.click(fixSheet().getByRole('button', { name: 'Use this recipe' })) + expect(await fixSheet().findByRole('alert')).toHaveTextContent('Not fixed: ungroundable message') + + fireEvent.change(fixSheet().getByLabelText('Recipe link'), { target: { value: 'https://example.com/pizza-2' } }) + fireEvent.click(fixSheet().getByRole('button', { name: 'Use this recipe' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(sent(server.fetchMock)).toEqual([ + { request: 'POST /api/v1/dinners/d3/ground', body: { recipe_url: 'https://example.com/blog/pizza' } }, + { request: 'POST /api/v1/dinners/d3/ground', body: { recipe_url: 'https://example.com/pizza-2' } }, + ]) + expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument() + }) + + it('saves ingredients and steps typed in', async () => { + const server = stubServer([PIZZA], { + 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES), + 'POST /api/v1/dinners/d3/ground': () => + grounded(server, { grounding_status: 'manual', method: 'oven', minutes_total: 35 }), + }) + await renderPage() + + openRow('Pizza night') + fireEvent.click(fixSheet().getByRole('radio', { name: 'Type it in' })) + const save = fixSheet().getByRole('button', { name: 'Save recipe' }) + expect(save).toBeDisabled() + fireEvent.change(fixSheet().getByLabelText('Ingredients, one per line'), { + target: { value: '1 pizza dough\n\n 1 cup tomato sauce \n2 cups mozzarella' }, + }) + fireEvent.change(fixSheet().getByLabelText('Steps, one per line'), { + target: { value: 'Heat the oven to 250°C.\nTop the dough and bake 12 minutes.\n' }, + }) + fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: 'oven' } }) + fireEvent.change(fixSheet().getByLabelText('Minutes'), { target: { value: '35' } }) + fireEvent.click(save) + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(sent(server.fetchMock)).toEqual([ + { + request: 'POST /api/v1/dinners/d3/ground', + body: { + manual: { + ingredients: ['1 pizza dough', '1 cup tomato sauce', '2 cups mozzarella'], + steps: ['Heat the oven to 250°C.', 'Top the dough and bake 12 minutes.'], + minutes: 35, + method: 'oven', + }, + }, + }, + ]) + expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument() + expect(within(row('Pizza night')).getByText('Oven · 35 min')).toBeInTheDocument() + }) + + it('switches to editing the Regular', async () => { + stubServer([PIZZA], { 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES) }) + await renderPage() + openRow('Pizza night') + fireEvent.click(fixSheet().getByRole('button', { name: 'Edit or remove Pizza night' })) + expect(screen.getByRole('dialog', { name: 'Edit Pizza night' })).toBeInTheDocument() + }) + }) + + it('refreshes grounding status while any Regular is finding its recipe, then stops', async () => { + // Only intervals are faked: TanStack Query polls with setInterval, and + // Testing Library's waits keep their real timers. + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + const server = stubServer([TACOS, CHILI]) + await renderPage() + expect(within(row('Chili')).getByText('Finding recipe…')).toBeInTheDocument() + expect(reads(server.fetchMock)).toBe(1) + + await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS)) + await waitFor(() => expect(reads(server.fetchMock)).toBe(2)) + expect(within(row('Chili')).getByText('Finding recipe…')).toBeInTheDocument() + + server.regulars = [TACOS, { ...CHILI, grounding_status: 'grounded', photo_url: '/media/photos/p2-400.jpg' }] + await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS)) + expect(await within(row('Chili')).findByText('Recipe found')).toBeInTheDocument() + expect(within(row('Chili')).getByRole('img', { name: 'Chili' })).toHaveAttribute('src', '/media/photos/p2-400.jpg') + expect(reads(server.fetchMock)).toBe(3) + + // Nothing is pending any more, so nothing is read again. + await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3)) + expect(reads(server.fetchMock)).toBe(3) + }) + + it('does not poll when no Regular is finding its recipe', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + const server = stubServer([TACOS, PIZZA]) + await renderPage() + await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3)) + expect(reads(server.fetchMock)).toBe(1) + }) +}) diff --git a/web/src/settings/RegularsSettingsPage.tsx b/web/src/settings/RegularsSettingsPage.tsx new file mode 100644 index 0000000..98db463 --- /dev/null +++ b/web/src/settings/RegularsSettingsPage.tsx @@ -0,0 +1,617 @@ +import { ChevronLeft, Heart, Plus } from 'lucide-react' +import { useId, useState, type FormEvent, type ReactNode } from 'react' +import { useNavigate } from 'react-router' +import { + DIFFICULTIES, + METHODS, + useAddRegular, + useEditRegular, + useGroundRegular, + useRecipeCandidates, + useRegulars, + useRemoveRegular, + useToggleFavorite, + type Difficulty, + type GroundingStatus, + type ManualRecipe, + type Method, + type NewRegular, + type RecipeFix, + type Regular, + type RegularPatch, +} from '../dinners/api.ts' +import { useRefusals } from '../dinners/useRefusals.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' +import { ListRow } from '../ui/ListRow.tsx' +import { Photo } from '../ui/Photo.tsx' +import { Segmented } from '../ui/Segmented.tsx' +import { Select } from '../ui/Select.tsx' +import { Sheet } from '../ui/Sheet.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { Tag } from '../ui/Tag.tsx' +import { TextInput } from '../ui/TextInput.tsx' +import styles from './RegularsSettingsPage.module.css' + +// The most minutes a dinner may take, as the dinners API allows: a day. +const MAX_MINUTES = 1440 + +export const methodLabels: Record = { + stovetop: 'Stovetop', + oven: 'Oven', + crockpot: 'Crock pot', + grill: 'Grill', + other: 'Other', +} + +const difficultyLabels: Record = { easy: 'Easy', medium: 'Medium', hard: 'Hard' } + +// A pasted link is added as a recipe link; anything else is a dinner name. +const LINK = /^https?:\/\//i + +// Settings · Regular dinners: the household's Regulars, each with its photo, +// method and minutes, favorite heart and whether its recipe was found. The +// field at the top adds one by name or pasted recipe link; tapping one +// edits or removes it, or fixes its recipe when none was found. +export function RegularsSettingsPage() { + const navigate = useNavigate() + return ( +
+
+ void navigate('/settings')}> + + +
+

Settings

+

Regular dinners

+
+
+

Changes apply to everyone in the household.

+ +
+ ) +} + +// Which sheet is open, for which Regular. +type Open = { sheet: 'edit' | 'fix'; id: string } | null + +function RegularsManager() { + const regulars = useRegulars() + const add = useAddRegular() + const remove = useRemoveRegular() + const favorite = useToggleFavorite() + const { refusals, track } = useRefusals() + const [draft, setDraft] = useState('') + const [open, setOpen] = useState(null) + const headingId = useId() + const list = regulars.data ?? [] + // A Regular removed meanwhile closes its sheet. + const opened = open && list.find((regular) => regular.id === open.id && !regular.pending) + + function onSubmit(event: FormEvent) { + event.preventDefault() + const text = draft.trim() + if (text === '') return + const body: NewRegular = LINK.test(text) ? { recipe_url: text } : { name: text } + track(`add:${text.toLowerCase()}`, `add ${text}`, add.mutateAsync(body)) + setDraft('') + } + + function onRemove(regular: Regular) { + setOpen(null) + track(`remove:${regular.id}`, `remove ${regular.name}`, remove.mutateAsync(regular)) + } + + function onFavorite(regular: Regular) { + track(`favorite:${regular.id}`, `change the favorite heart on ${regular.name}`, favorite.mutateAsync(regular)) + } + + return ( + <> +
+ setDraft(event.target.value)} + /> + + + + + {refusals.map((refusal) => ( +

+ {refusal.text} +

+ ))} + + {regulars.isPending && } + {regulars.isError && ( + void regulars.refetch()}> + Your Regulars could not load: {regulars.error.message} + + )} + {regulars.isSuccess && ( +
+

+ On rotation · {list.length} +

+ {list.length === 0 ? ( +

No Regulars yet. Add the dinners your household makes on repeat.

+ ) : ( + +
    + {list.map((regular) => ( + + setOpen({ sheet: regular.grounding_status === 'failed' ? 'fix' : 'edit', id: regular.id }) + } + onFavorite={() => onFavorite(regular)} + /> + ))} +
+
+ )} +
+ )} + + {opened && open.sheet === 'edit' && ( + setOpen(null)} + onRemove={() => onRemove(opened)} + /> + )} + {opened && open.sheet === 'fix' && ( + setOpen(null)} + onEdit={() => setOpen({ sheet: 'edit', id: opened.id })} + /> + )} + + ) +} + +// details is a Regular's "method · minutes" line, or null when neither is +// known yet. +export function details(regular: Regular): string | null { + const parts = [ + regular.method && methodLabels[regular.method], + regular.minutes_total !== null && `${regular.minutes_total} min`, + ].filter((part): part is string => typeof part === 'string') + return parts.length === 0 ? null : parts.join(' · ') +} + +const badges: Record = { + pending: { text: 'Finding recipe…', className: styles.finding }, + grounded: { text: 'Recipe found', className: styles.found }, + manual: { text: 'Recipe found', className: styles.found }, + failed: { text: 'Needs a recipe', className: styles.needs }, +} + +function GroundingBadge({ status }: { status: GroundingStatus }) { + const badge = badges[status] + return {badge.text} +} + +type RegularRowProps = { + regular: Regular + onOpen: () => void + onFavorite: () => void +} + +function RegularRow({ regular, onOpen, onFavorite }: RegularRowProps) { + const line = details(regular) + return ( +
  • + } + title={regular.name} + meta={ + + {line && {line}} + + + } + onClick={regular.pending ? undefined : onOpen} + trailing={ + + + + } + /> +
  • + ) +} + +type SheetFrameProps = { + title: string + onClose: () => void + children: ReactNode +} + +function SheetFrame({ title, onClose, children }: SheetFrameProps) { + const titleId = useId() + return ( + +
    +

    + {title} +

    + {children} +
    +
    + ) +} + +// minutesProblem is why a typed minutes value cannot be saved, or null. +function minutesProblem(text: string): string | null { + if (text === '') return null + const minutes = Number(text) + if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_MINUTES) { + return `Minutes must be a whole number from 1 to ${MAX_MINUTES}.` + } + return null +} + +type Draft = { name: string; method: Method | ''; minutes: string; difficulty: Difficulty | '' } + +function draftOf(regular: Regular): Draft { + return { + name: regular.name, + method: regular.method ?? '', + minutes: regular.minutes_total === null ? '' : String(regular.minutes_total), + difficulty: regular.difficulty ?? '', + } +} + +// patchOf is the PATCH body for the fields draft changes. A field the +// Regular has no value for stays unset until one is chosen. +function patchOf(regular: Regular, draft: Draft): RegularPatch { + const saved = draftOf(regular) + const patch: RegularPatch = {} + if (draft.name.trim() !== saved.name) patch.name = draft.name.trim() + if (draft.method !== saved.method && draft.method !== '') patch.method = draft.method + if (draft.minutes !== saved.minutes && draft.minutes !== '') patch.minutes_total = Number(draft.minutes) + if (draft.difficulty !== saved.difficulty && draft.difficulty !== '') patch.difficulty = draft.difficulty + return patch +} + +type EditRegularSheetProps = { + regular: Regular + onClose: () => void + onRemove: () => void +} + +// EditRegularSheet renames a Regular and sets its method, minutes and +// difficulty, or removes it from the rotation once confirmed. +function EditRegularSheet({ regular, onClose, onRemove }: EditRegularSheetProps) { + const [draft, setDraft] = useState(() => draftOf(regular)) + const [confirming, setConfirming] = useState(false) + const edit = useEditRegular() + const patch = patchOf(regular, draft) + const minutesError = minutesProblem(draft.minutes) + const nameError = draft.name.trim() === '' ? 'A Regular needs a name.' : null + const canSave = Object.keys(patch).length > 0 && minutesError === null && nameError === null + + function change(next: Partial) { + edit.reset() + setDraft((current) => ({ ...current, ...next })) + } + + function onSave(event: FormEvent) { + event.preventDefault() + if (!canSave) return + edit.mutate({ id: regular.id, patch }, { onSuccess: onClose }) + } + + if (confirming) { + return ( + +

    + It leaves your Regulars, so suggestions and plans stop using it. You can add it again later. +

    +
    + + +
    +
    + ) + } + + return ( + +
    +
    + change({ name: event.target.value })} + /> + {nameError &&

    {nameError}

    } +
    + + change({ minutes: event.target.value })} + /> +
    + {minutesError &&

    {minutesError}

    } + + {edit.isError && ( +

    + Not saved: {edit.error.message} +

    + )} +
    + + +
    +
    +
    +
    + ) +} + +type FixPath = 'pick' | 'link' | 'manual' + +const fixPaths = [ + { value: 'pick', label: 'Pick one' }, + { value: 'link', label: 'Paste a link' }, + { value: 'manual', label: 'Type it in' }, +] as const + +type FixRecipeSheetProps = { + regular: Regular + onClose: () => void + onEdit: () => void +} + +// lines is text split into its non-blank lines, trimmed. +function lines(text: string): string[] { + return text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line !== '') +} + +// FixRecipeSheet grounds a Regular whose recipe was not found: in one of +// up to three recipe pages found for its name, in a pasted recipe link, or +// in ingredients and steps typed in. +function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) { + const [path, setPath] = useState('pick') + const ground = useGroundRegular() + + function fix(body: RecipeFix) { + ground.mutate({ id: regular.id, fix: body }, { onSuccess: onClose }) + } + + return ( + +

    + We couldn't find a recipe for {regular.name}. Pick a match below, paste a recipe link or type it in. +

    + { + ground.reset() + setPath(next) + }} + /> +
    + {path === 'pick' && fix({ candidate_id })} />} + {path === 'link' && fix({ recipe_url })} />} + {path === 'manual' && fix({ manual })} />} +
    +

    + {ground.isPending && 'Finding recipe… this can take a minute.'} +

    + {ground.isError && ( +

    + Not fixed: {ground.error.message} +

    + )} + +
    + ) +} + +function PickCandidate({ id, onPick }: { id: string; onPick: (candidateId: string) => void }) { + const candidates = useRecipeCandidates(id) + if (candidates.isPending) return + if (candidates.isError) { + return ( + void candidates.refetch()}> + Recipe matches could not load: {candidates.error.message} + + ) + } + if (candidates.data.length === 0) { + return

    No matches were found. Paste a recipe link or type it in.

    + } + return ( +
      + {candidates.data.map((candidate) => ( +
    • + +
    • + ))} +
    + ) +} + +function PasteLink({ onSubmit }: { onSubmit: (recipeUrl: string) => void }) { + const [link, setLink] = useState('') + const url = link.trim() + return ( +
    { + event.preventDefault() + if (url !== '') onSubmit(url) + }} + > + setLink(event.target.value)} + /> + + + ) +} + +function ManualEntry({ onSubmit }: { onSubmit: (manual: ManualRecipe) => void }) { + const [ingredients, setIngredients] = useState('') + const [steps, setSteps] = useState('') + const [minutes, setMinutes] = useState('') + const [method, setMethod] = useState('') + const ingredientsId = useId() + const stepsId = useId() + const minutesError = minutesProblem(minutes) + const ingredientLines = lines(ingredients) + const canSave = ingredientLines.length > 0 && minutesError === null + + function onSave(event: FormEvent) { + event.preventDefault() + if (!canSave) return + const manual: ManualRecipe = { ingredients: ingredientLines, steps: lines(steps) } + if (minutes !== '') manual.minutes = Number(minutes) + if (method !== '') manual.method = method + onSubmit(manual) + } + + return ( +
    +
    + +