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]}
) : (
)}
- {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 }) {
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 (
+