From 01038b376a8ee5ae6ae5d5979b8491ccb2e0e644 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Fri, 9 Oct 2026 12:13:57 -0400 Subject: [PATCH 1/2] Feature 18 release-review fixes: true Quest-log state and Find ideas with a draft (#160) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> (cherry picked from commit 14f7141e4445c8392c478650a639c9861ced05a7) --- web/src/ideas/CookFromPantryPage.test.tsx | 49 +++++++++++++++++++ web/src/ideas/CookFromPantryPage.tsx | 27 +++++++---- web/src/ideas/QuickDinnersPage.test.tsx | 22 +++++++++ web/src/ideas/QuickDinnersPage.tsx | 19 +++++--- web/src/ideas/api.ts | 59 +++++++++++++++++------ 5 files changed, 146 insertions(+), 30 deletions(-) diff --git a/web/src/ideas/CookFromPantryPage.test.tsx b/web/src/ideas/CookFromPantryPage.test.tsx index cb892e0..6733bf8 100644 --- a/web/src/ideas/CookFromPantryPage.test.tsx +++ b/web/src/ideas/CookFromPantryPage.test.tsx @@ -203,6 +203,22 @@ describe('CookFromPantryPage', () => { expect(chips()).toHaveLength(4) }) + it('enables Find ideas for text typed and not yet added, and searches it', async () => { + const { fetchMock } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) }) + await renderPage() + + fireEvent.change(input(), { target: { value: ' ' } }) + expect(findButton()).toBeDisabled() + fireEvent.change(input(), { target: { value: 'Chicken thighs' } }) + expect(chips()).toEqual([]) + expect(findButton()).toBeEnabled() + + fireEvent.click(findButton()) + await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(1)) + expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: ['Chicken thighs'], quick_only: false }) + expect(chips()).toEqual(['Chicken thighs']) + }) + it('removes a chip with its cross, or the last one with Backspace in the empty input', async () => { stubServer({}) await renderPage() @@ -348,6 +364,39 @@ describe('CookFromPantryPage', () => { expect(within(card(CURRY.name)).getByRole('button', { name: 'Add to Quest log' })).toBeEnabled() }) + it('says an idea that came back as an existing Regular is already a Regular, not in the Quest log', async () => { + const { fetchMock, jobs } = stubServer({ + [FIND]: () => jsonResponse(202, { jobId: 'j1' }), + 'POST /api/v1/ideas/j1/items/0/quest-log': () => + jsonResponse(200, dinnerBody('dn-reg', 'Coconut Chicken Curry', { status: 'regular' })), + }) + jobs.j1 = state('succeeded', 'done', [CURRY]) + await renderPage() + await searched(fetchMock) + const curry = within(await screen.findByRole('article', { name: CURRY.name })) + + fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' })) + expect(await curry.findByRole('status')).toHaveTextContent('Already a Regular') + expect(curry.queryByText('In the Quest log')).toBeNull() + }) + + it('fails on a dinner whose status is neither quest_log nor regular', async () => { + const { fetchMock, jobs } = stubServer({ + [FIND]: () => jsonResponse(202, { jobId: 'j1' }), + 'POST /api/v1/ideas/j1/items/0/quest-log': () => jsonResponse(200, dinnerBody('dn-1', CURRY.name, { status: 'suggested' })), + }) + jobs.j1 = state('succeeded', 'done', [CURRY]) + await renderPage() + await searched(fetchMock) + const curry = within(await screen.findByRole('article', { name: CURRY.name })) + + fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' })) + expect(await curry.findByRole('alert')).toHaveTextContent( + 'POST /api/v1/ideas/j1/items/0/quest-log: status "suggested" is not one of quest_log, regular', + ) + expect(curry.queryByRole('status')).toBeNull() + }) + it('shows its failure on its card and lets it be pressed again', async () => { let fail = true const { fetchMock, jobs } = stubServer({ diff --git a/web/src/ideas/CookFromPantryPage.tsx b/web/src/ideas/CookFromPantryPage.tsx index e8d1dc1..7af6849 100644 --- a/web/src/ideas/CookFromPantryPage.tsx +++ b/web/src/ideas/CookFromPantryPage.tsx @@ -13,6 +13,7 @@ import { Segmented } from '../ui/Segmented.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { TextInput } from '../ui/TextInput.tsx' import { + ADDED_LABEL, FROM_INGREDIENTS_JOB_KIND, IDEAS_KEPT, ideasJobFailure, @@ -50,9 +51,10 @@ function fold(ingredient: string): string { // IdeaCard is one idea (mock 1i): its name and time, the server's line on // what it uses and what is missing, and its two actions, each showing its -// own progress and failure. ref is the idea in its job, null while the job -// runs: the server takes an idea only once the job has succeeded, as the -// ideas may still be reordered until then. +// own progress and failure; an idea added says whether its dinner is in +// the Quest log or is already a Regular. ref is the idea in its job, null +// while the job runs: the server takes an idea only once the job has +// succeeded, as the ideas may still be reordered until then. function IdeaCard({ idea, ideaRef }: { idea: Idea; ideaRef: IdeaRef | null }) { const questLog = useAddIdeaToQuestLog() const tonight = useCookIdeaTonight() @@ -80,7 +82,7 @@ function IdeaCard({ idea, ideaRef }: { idea: Idea; ideaRef: IdeaRef | null }) { )} {questLog.isSuccess ? ( - In the Quest log + {ADDED_LABEL[questLog.data.status]} ) : ( diff --git a/web/src/ideas/QuickDinnersPage.test.tsx b/web/src/ideas/QuickDinnersPage.test.tsx index 891e13e..fe41e8c 100644 --- a/web/src/ideas/QuickDinnersPage.test.tsx +++ b/web/src/ideas/QuickDinnersPage.test.tsx @@ -279,6 +279,28 @@ describe('QuickDinnersPage', () => { expect(within(row(QUESADILLAS.name)).getByRole('img', { name: 'In the Quest log' })).toBeInTheDocument() }) + it('labels the check Already a Regular when the idea came back as an existing Regular', async () => { + const { fetchMock, jobs } = quickServer(['j1', 'j2'], { + 'POST /api/v1/ideas/j1/items/0/quest-log': () => jsonResponse(200, dinnerBody('dn-reg', ORZO.name, { status: 'regular' })), + }) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS]) + jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI]) + await renderPage() + await screen.findByRole('listitem', { name: ORZO.name }) + + fireEvent.click(addButton(ORZO.name)) + const orzo = within(row(ORZO.name)) + expect(await orzo.findByRole('status')).toContainElement(orzo.getByRole('img', { name: 'Already a Regular' })) + expect(orzo.queryByRole('img', { name: 'In the Quest log' })).toBeNull() + expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1) + + // Kept across tabs as what it is. + fireEvent.click(tab('Crock pot')) + await screen.findByRole('listitem', { name: CHILI.name }) + fireEvent.click(tab('30 min or less')) + expect(within(row(ORZO.name)).getByRole('img', { name: 'Already a Regular' })).toBeInTheDocument() + }) + it('marks an add that settles while another tab is shown', async () => { const added = Promise.withResolvers() const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'POST /api/v1/ideas/j1/items/0/quest-log': () => added.promise }) diff --git a/web/src/ideas/QuickDinnersPage.tsx b/web/src/ideas/QuickDinnersPage.tsx index 5fe4c96..4432442 100644 --- a/web/src/ideas/QuickDinnersPage.tsx +++ b/web/src/ideas/QuickDinnersPage.tsx @@ -12,6 +12,7 @@ import { Photo } from '../ui/Photo.tsx' import { Segmented } from '../ui/Segmented.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { + ADDED_LABEL, ideasJobFailure, ideasJobLost, MAX_EXCLUDE, @@ -23,6 +24,8 @@ import { useAddIdeaToQuestLog, useFindQuickIdeas, useQuickTab, + type AddedDinner, + type AddedStatus, type IdeaRef, type QuickIdea, type QuickKind, @@ -57,7 +60,8 @@ function meta(idea: QuickIdea): string { } // QuickRow is one idea (mock 1j): its washed photo, name and meta line, and -// the teal "+" that adds it to the Quest log, a check once it is there. +// the teal "+" that adds it to the Quest log, then a check labelled with +// where it went: added is the status of the dinner it came back as. // ideaRef is null while its job runs: the server takes an idea only once // the job has succeeded. onAdded marks the idea added, also if the row has // unmounted before the add settles. @@ -69,8 +73,8 @@ function QuickRow({ }: { idea: QuickIdea ideaRef: IdeaRef | null - added: boolean - onAdded: (idea: IdeaRef) => void + added: AddedStatus | undefined + onAdded: (idea: IdeaRef, dinner: AddedDinner) => void }) { const questLog = useAddIdeaToQuestLog(onAdded) return ( @@ -85,9 +89,9 @@ function QuickRow({

)} - {added ? ( + {added !== undefined ? ( - + ) : ( (t.jobId === jobId ? { ...t, jobId: null, ended: true } : t)) }, [jobId, ideas, exhausted, update]) - const markAdded = ({ jobId, index }: IdeaRef) => update((t) => ({ ...t, added: [...t.added, `${jobId}:${index}`] })) + const markAdded = ({ jobId, index }: IdeaRef, { status }: AddedDinner) => + update((t) => ({ ...t, added: { ...t.added, [`${jobId}:${index}`]: status } })) // The rows of every page found, then of the page being found, in one // keyed list: a page kept with the tab's pages keeps its rows, and with @@ -223,7 +228,7 @@ function QuickTabPanel({ kind }: { kind: QuickKind }) {

{helpers[kind]}

diff --git a/web/src/ideas/api.ts b/web/src/ideas/api.ts index 0963f3d..b50eaaf 100644 --- a/web/src/ideas/api.ts +++ b/web/src/ideas/api.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ApiError, useApi } from '../api/index.ts' import { isRecord } from '../api/errors.ts' -import { DIFFICULTIES, METHODS, parseDinner, photoId, type Difficulty, type Method } from '../dinners/api.ts' +import { DIFFICULTIES, METHODS, parseDinner, photoId, type Difficulty, type Method, type Regular } from '../dinners/api.ts' import { QUEST_LOG_KEY } from '../discover/api.ts' import { parsePlan, planKey } from '../plan/api.ts' @@ -221,11 +221,17 @@ export type QuickPage = { jobId: string; ideas: QuickIdea[] } // QuickTab is what one tab of Quick dinners has found this session: the // pages found, the job finding the next one, whether the server has said // there are no more, why the last page asked for could not start, kept -// until Retry asks again, and the ideas added to the Quest log, as -// "jobId:index". -export type QuickTab = { pages: QuickPage[]; jobId: string | null; ended: boolean; startError: Error | null; added: string[] } +// until Retry asks again, and the status of the dinner each idea added +// came back as, by "jobId:index". +export type QuickTab = { + pages: QuickPage[] + jobId: string | null + ended: boolean + startError: Error | null + added: Record +} -const NEW_TAB: QuickTab = { pages: [], jobId: null, ended: false, startError: null, added: [] } +const NEW_TAB: QuickTab = { pages: [], jobId: null, ended: false, startError: null, added: {} } export function quickTabKey(kind: QuickKind) { return ['ideas', 'quick', kind] as const @@ -286,21 +292,46 @@ function ideaPath({ jobId, index }: IdeaRef, action: 'quest-log' | 'cook-tonight return `${IDEAS_PATH}/${encodeURIComponent(jobId)}/items/${index}/${action}` } +// ADDED_STATUSES are the statuses a dinner an idea is added as may have: +// quest_log for a dinner made or already in the Quest log, regular for a +// Regular the idea repeats, which the server answers as it is. +export const ADDED_STATUSES = ['quest_log', 'regular'] as const +export type AddedStatus = (typeof ADDED_STATUSES)[number] + +// ADDED_LABEL says where an idea added went, by its dinner's status. +export const ADDED_LABEL: Record = { + quest_log: 'In the Quest log', + regular: 'Already a Regular', +} + +// AddedDinner is the dinner an idea was added as, with its status. +export type AddedDinner = Regular & { status: AddedStatus } + +function parseAddedDinner(where: string, body: unknown): AddedDinner { + const dinner = parseDinner(where, body) + const status = isRecord(body) ? body.status : undefined + if (!(ADDED_STATUSES as readonly unknown[]).includes(status)) { + throw new Error(`${where}: status ${JSON.stringify(status)} is not one of ${ADDED_STATUSES.join(', ')}`) + } + return { ...dinner, status: status as AddedStatus } +} + // useAddIdeaToQuestLog adds an idea to the Quest log (POST -// /api/v1/ideas/{jobId}/items/{i}/quest-log) and resolves with the -// dinner's name. Asking again answers the same dinner. The Quest log is -// read again after it, and onAdded told, also if the caller has unmounted -// meanwhile. -export function useAddIdeaToQuestLog(onAdded?: (idea: IdeaRef) => void) { +// /api/v1/ideas/{jobId}/items/{i}/quest-log) and resolves with the dinner +// it came back as: a dinner the idea repeats is answered as it is, so a +// Regular stays a Regular, and its status says which. Asking again answers +// the same dinner. The Quest log is read again after it, and onAdded told +// with the dinner, also if the caller has unmounted meanwhile. +export function useAddIdeaToQuestLog(onAdded?: (idea: IdeaRef, dinner: AddedDinner) => void) { const { apiFetch } = useApi() const queryClient = useQueryClient() return useMutation({ - mutationFn: async (idea: IdeaRef): Promise => { + mutationFn: async (idea: IdeaRef): Promise => { const path = ideaPath(idea, 'quest-log') - return parseDinner(`POST ${path}`, await apiFetch(path, { method: 'POST' })).name + return parseAddedDinner(`POST ${path}`, await apiFetch(path, { method: 'POST' })) }, - onSuccess: (_name, idea) => { - onAdded?.(idea) + onSuccess: (dinner, idea) => { + onAdded?.(idea, dinner) return queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }) }, }) From 2bc412ae0689d759a4095f1d6ccdc04fad5fd1b5 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 10 Oct 2026 14:14:33 -0400 Subject: [PATCH 2/2] Add Quick dinner and Cook from what I have shortcuts to Home The placeholder Home now links to /quick and /cook-from-pantry, so Cook from what I have has an in-app entry point. App tests click each link and check the destination screen renders. Task: ab589dee-4cdc-4a17-aff9-7b3cba975548 Co-Authored-By: Claude Opus 5.5 --- web/src/App.test.tsx | 25 ++++++++++++ web/src/App.tsx | 2 + web/src/pages/HomePage.module.css | 68 +++++++++++++++++++++++++++++++ web/src/pages/HomePage.tsx | 40 ++++++++++++++++++ 4 files changed, 135 insertions(+) create mode 100644 web/src/pages/HomePage.module.css create mode 100644 web/src/pages/HomePage.tsx diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index e9be839..099b417 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -2,6 +2,7 @@ import { act, cleanup, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createApiClient, createVerifiedSession } from './api/index.ts' import { createNetworkStatus } from './pwa/networkStatus.ts' +import { stubAnimate } from './test/animate.ts' import { mockViewport } from './test/matchMedia.ts' import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts' import App, { routes } from './App.tsx' @@ -12,6 +13,7 @@ import { defaultWeek } from './plan/week.ts' afterEach(() => { Reflect.deleteProperty(window, 'matchMedia') + Reflect.deleteProperty(Element.prototype, 'animate') vi.restoreAllMocks() vi.unstubAllGlobals() }) @@ -30,6 +32,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) { // The routes that render a built screen rather than a placeholder page. const SCREENS = { + '/': true, '/onboarding': true, '/plan': true, '/plan/shopping': true, @@ -84,6 +87,28 @@ describe('App routes', () => { }, ) + it.each([ + ['Quick dinner', '/quick', 'Quick dinners'], + ['Cook from what I have', '/cook-from-pantry', 'Cook from what I have'], + ])('Home links %s to %s', async (link, path, heading) => { + mockViewport(390) + stubAnimate({ autoFinish: true }) + // The idea screen's own requests stay pending: this checks only that Home + // navigates to it. + const fetchMock = vi.fn((input) => { + const respond = Object.hasOwn(SIGNED_IN, String(input)) ? SIGNED_IN[String(input) as keyof typeof SIGNED_IN] : undefined + return respond === undefined ? new Promise(() => {}) : Promise.resolve(respond()) + }) + vi.stubGlobal('fetch', fetchMock) + renderAt('/') + await screen.findByRole('heading', { level: 1, name: 'Home' }) + const shortcut = screen.getByRole('link', { name: link }) + expect(shortcut).toHaveAttribute('href', path) + act(() => shortcut.click()) + expect(await screen.findByRole('heading', { level: 1, name: heading })).toBeInTheDocument() + expect(screen.queryByRole('heading', { level: 1, name: 'Home' })).not.toBeInTheDocument() + }) + it('/settings renders the settings home inside the shell', async () => { mockViewport(390) stubFetchByPath({ diff --git a/web/src/App.tsx b/web/src/App.tsx index 8fb3ea3..7c4897a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -8,6 +8,7 @@ import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx' import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx' import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx' import { OnboardingPage } from './onboarding/OnboardingPage.tsx' +import { HomePage } from './pages/HomePage.tsx' import { PlaceholderPage } from './pages/PlaceholderPage.tsx' import { PlanPage } from './plan/PlanPage.tsx' import { OfflinePill } from './pwa/OfflinePill.tsx' @@ -43,6 +44,7 @@ export const routes = [ // The screens built so far; every other route shows its placeholder page. const screens: Partial> = { + '/': , '/plan': , '/plan/shopping': , '/plan/shopping/archive': , diff --git a/web/src/pages/HomePage.module.css b/web/src/pages/HomePage.module.css new file mode 100644 index 0000000..33e2043 --- /dev/null +++ b/web/src/pages/HomePage.module.css @@ -0,0 +1,68 @@ +/* Placeholder Home with the idea-screen shortcuts. */ +.page { + max-width: 560px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.title { + margin: 0; + padding-top: 6px; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; +} + +.rows { + margin: 0; + padding: 0; + list-style: none; +} + +/* Rows in one card are split by a hairline inset to the row padding. */ +.item + .item { + border-top: 1px solid var(--color-neutral-300); + margin: 0 16px; +} + +.item + .item .row { + padding-left: 0; + padding-right: 0; +} + +.row { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 14px; + min-height: 44px; + padding: 14px 16px; + border-radius: 26px; + color: var(--color-text); + text-decoration: none; +} + +.icon { + flex: none; + display: flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: 50%; + background: var(--color-bg); +} + +.icon svg { + width: 18px; + height: 18px; +} + +.rowTitle { + flex: 1; + min-width: 0; + font-family: var(--font-body); + font-size: 15px; + font-weight: 700; +} diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx new file mode 100644 index 0000000..347ff22 --- /dev/null +++ b/web/src/pages/HomePage.tsx @@ -0,0 +1,40 @@ +import { ChevronRight, Refrigerator, Zap, type LucideIcon } from 'lucide-react' +import { Link } from 'react-router' +import { Card, Icon } from '../ui/index.ts' +import styles from './HomePage.module.css' + +// Home stands in for the Home screen until its feature is built, with the +// shortcuts to the idea screens that have no other entry point. +export function HomePage() { + return ( +
+

Home

+ +
    + + +
+
+
+ ) +} + +type ShortcutProps = { + to: string + icon: LucideIcon + title: string +} + +function Shortcut({ to, icon, title }: ShortcutProps) { + return ( +
  • + + + + + {title} + + +
  • + ) +}