Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 2bc412ae06 Add Quick dinner and Cook from what I have shortcuts to Home
CI / ci (push) Successful in 5m39s
CI / ci (pull_request) Successful in 5m43s
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 <noreply@anthropic.com>
2026-10-10 14:14:33 -04:00
orchestrator-agent 01038b376a 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 14f7141e44)
2026-10-10 14:13:05 -04:00
dev_agent d1be28467c Merge remote-tracking branch 'origin/main' into HEAD
CI / ci (push) Successful in 5m44s
CI / ci (pull_request) Successful in 5m40s
2026-10-09 10:45:52 -04:00
overseer d7c7a3314d Merge pull request 'Release: Feature 17 (Shopping List and Supply Archive)' (#142) from release/2026-10-08b into main
CI / ci (push) Successful in 4m50s
Reviewed-on: #142
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-08 18:15:48 -04:00
dev_agent e0574b1511 Merge remote-tracking branch 'origin/main' into release/2026-10-08b
CI / ci (push) Successful in 5m15s
CI / ci (pull_request) Successful in 5m8s
2026-10-08 17:15:21 -04:00
overseer e9b7593d3c Merge pull request 'Promote dev to main: Feature 16 (Google Calendar Sync) + release corrections' (#131) from release/2026-10-08 into main
CI / ci (push) Failing after 3m39s
Reviewed-on: #131
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-08 12:01:31 -04:00
dev_agent 8e33b7b824 Merge remote-tracking branch 'origin/main' into release/2026-10-08
CI / ci (push) Successful in 4m52s
CI / ci (pull_request) Successful in 4m22s
2026-10-08 03:14:36 -04:00
overseer 75b8d64f74 Merge pull request 'Promote dev to main: Feature 14 (Weekly Plan Builder)' (#123) from release/2026-10-07c into main
CI / ci (push) Successful in 3m19s
Reviewed-on: #123
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-07 19:29:54 -04:00
dev_agent fd225b9f90 Merge remote-tracking branch 'origin/main' into release/2026-10-07c
CI / ci (pull_request) Successful in 3m16s
CI / ci (push) Successful in 3m39s
2026-10-07 16:54:33 -04:00
overseer 66fc292166 Merge pull request 'Promote dev to main: Feature 13 (Discover - Side Quest Suggestions)' (#115) from release/2026-10-07b into main
CI / ci (push) Successful in 2m45s
Reviewed-on: #115
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-07 08:07:10 -04:00
dev_agent fa62813bb7 Merge remote-tracking branch 'origin/main' into release/2026-10-07b
CI / ci (push) Successful in 3m5s
CI / ci (pull_request) Successful in 2m45s
2026-10-07 07:05:10 -04:00
overseer 1bbacf2df5 Merge pull request 'Promote dev to main: Feature 12 (regular dinners and onboarding)' (#98) from release/2026-10-07 into main
CI / ci (push) Successful in 2m49s
Reviewed-on: #98
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-07 06:54:05 -04:00
dev_agent 372424e2f5 Merge remote-tracking branch 'origin/dev' into release/2026-10-07
CI / ci (push) Successful in 2m28s
CI / ci (pull_request) Successful in 2m26s
2026-10-07 00:41:03 -04:00
dev_agent a27ef6a4c4 Merge remote-tracking branch 'origin/dev' into release/2026-10-07
CI / ci (push) Successful in 2m28s
CI / ci (pull_request) Successful in 2m25s
2026-10-07 00:14:42 -04:00
dev_agent 7138721a28 Merge remote-tracking branch 'origin/dev' into release/2026-10-07
CI / ci (push) Successful in 2m26s
CI / ci (pull_request) Successful in 2m35s
2026-10-06 23:13:06 -04:00
dev_agent c54af1656c Merge remote-tracking branch 'origin/main' into release/2026-10-07
CI / ci (push) Successful in 2m24s
CI / ci (pull_request) Successful in 2m37s
2026-10-06 22:00:58 -04:00
overseer b0a6d896ef Merge pull request 'Promote dev to main: Feature 11 (settings home and Admin AI assistant)' (#87) from release/2026-10-06c into main
CI / ci (push) Successful in 2m2s
Reviewed-on: #87
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-06 13:46:37 -04:00
dev_agent 2fe439cdcd Merge dev into release/2026-10-06c: incorporate the deflake correction
CI / ci (push) Successful in 2m9s
CI / ci (pull_request) Successful in 2m4s
2026-10-06 13:19:22 -04:00
dev_agent cc6c1f9cf9 Merge main into release/2026-10-06c before promotion review
CI / ci (push) Successful in 2m1s
CI / ci (pull_request) Failing after 1m2s
2026-10-06 12:46:00 -04:00
overseer 768bb48b21 Merge pull request 'Promote dev to main: Feature 10 (recipe grounding and photos)' (#79) from release/2026-10-06b into main
CI / ci (push) Successful in 1m49s
Reviewed-on: #79
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-06 07:39:40 -04:00
dev_agent a6a32b8426 Merge main into release/2026-10-06b before promotion review
CI / ci (push) Successful in 1m53s
CI / ci (pull_request) Successful in 1m52s
2026-10-06 07:28:53 -04:00
overseer d4be6212bc Merge pull request 'Promote dev to main: Feature 07 (deployment, identity and backups)' (#76) from release/2026-10-06 into main
CI / ci (push) Successful in 1m40s
Reviewed-on: #76
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-05 21:19:34 -04:00
dev_agent 2dbfc74f3e Merge main into release/2026-10-06 before promotion review
CI / ci (push) Successful in 1m42s
CI / ci (pull_request) Successful in 1m43s
2026-10-05 21:01:02 -04:00
overseer 9cddbbb8a9 Merge pull request 'Promote dev to main: Feature 06 (PWA shell and offline)' (#71) from release/2026-10-05b into main
CI / ci (push) Successful in 1m40s
Reviewed-on: #71
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-05 16:02:03 -04:00
dev_agent a2f3143ac2 Merge main into release/2026-10-05b before promotion review
CI / ci (push) Successful in 1m42s
CI / ci (pull_request) Successful in 1m40s
2026-10-05 15:27:27 -04:00
overseer 6e134968cc Merge pull request 'Promote dev to main: Features 08-09' (#63) from release/2026-10-05 into main
CI / ci (push) Successful in 1m35s
Reviewed-on: #63
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-05 12:19:09 -04:00
dev_agent 86e7cc63c2 Merge dev into release/2026-10-05: incorporate cancelled-backoff correction
CI / ci (push) Successful in 1m35s
CI / ci (pull_request) Successful in 1m35s
2026-10-05 11:42:11 -04:00
dev_agent 37315c7821 Merge main into release/2026-10-05 before promotion review
CI / ci (push) Successful in 1m37s
CI / ci (pull_request) Successful in 1m36s
2026-10-05 11:21:47 -04:00
overseer 084350cb03 Merge pull request 'Promote dev to main: Features 01–05' (#47) from release/2026-10-04 into main
CI / ci (push) Successful in 1m30s
Reviewed-on: #47
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-04 10:35:01 -04:00
9 changed files with 281 additions and 30 deletions
+25
View File
@@ -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<typeof fetch>((input) => {
const respond = Object.hasOwn(SIGNED_IN, String(input)) ? SIGNED_IN[String(input) as keyof typeof SIGNED_IN] : undefined
return respond === undefined ? new Promise<Response>(() => {}) : 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({
+2
View File
@@ -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<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/': <HomePage />,
'/plan': <PlanPage />,
'/plan/shopping': <SupplyRunPage />,
'/plan/shopping/archive': <SupplyArchivePage />,
+49
View File
@@ -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({
+18 -9
View File
@@ -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 ? (
<span className={styles.done} role="status">
<Icon icon={Check} /> In the Quest log
<Icon icon={Check} /> {ADDED_LABEL[questLog.data.status]}
</span>
) : (
<Button
@@ -123,10 +125,12 @@ function Skeletons({ count }: { count: number }) {
// CookFromPantryPage is mock 1i, Cook from what I have: the ingredients on
// hand as chips (Enter or a comma adds one), Any time | Quick only, and
// Find ideas, which starts an ideas_from_ingredients job and shows its
// ideas as the server grounds them, skeleton cards holding the places of
// those still to come. Changing Any time | Quick only after a search asks
// again: the server, not the screen, says what is quick.
// Find ideas, which lists what is still typed, so it is enabled by a chip
// or by text typed and not yet added, and starts an ideas_from_ingredients
// job and shows its ideas as the server grounds them, skeleton cards
// holding the places of those still to come. Changing Any time | Quick
// only after a search asks again: the server, not the screen, says what is
// quick.
export function CookFromPantryPage() {
const navigate = useNavigate()
const inputRef = useRef<HTMLInputElement>(null)
@@ -302,7 +306,12 @@ export function CookFromPantryPage() {
)}
<div className={styles.controls}>
<Segmented label="Which dinners" options={WHEN} value={when} onValueChange={onWhenChange} className={styles.when} />
<Button variant="dark" className={styles.find} disabled={ingredients.length < 1 || findIdeas.isPending} onClick={find}>
<Button
variant="dark"
className={styles.find}
disabled={(ingredients.length < 1 && draft.trim() === '') || findIdeas.isPending}
onClick={find}
>
Find ideas
</Button>
</div>
+22
View File
@@ -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<Response>()
const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'POST /api/v1/ideas/j1/items/0/quest-log': () => added.promise })
+12 -7
View File
@@ -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({
</p>
)}
</div>
{added ? (
{added !== undefined ? (
<span className={styles.added} role="status">
<Icon icon={Check} size="nav" label="In the Quest log" />
<Icon icon={Check} size="nav" label={ADDED_LABEL[added]} />
</span>
) : (
<IconButton
@@ -163,7 +167,8 @@ function QuickTabPanel({ kind }: { kind: QuickKind }) {
else if (exhausted) update((t) => (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 }) {
<p className={styles.helper}>{helpers[kind]}</p>
<ul aria-busy={busy} aria-label="Quick dinners" className={styles.rows}>
{rows.map(({ idea, ref, key }) => (
<QuickRow key={key} idea={idea} ideaRef={ref} added={tab.added.includes(key)} onAdded={markAdded} />
<QuickRow key={key} idea={idea} ideaRef={ref} added={tab.added[key]} onAdded={markAdded} />
))}
{next}
</ul>
+45 -14
View File
@@ -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<string, AddedStatus>
}
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<AddedStatus, string> = {
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<string> => {
mutationFn: async (idea: IdeaRef): Promise<AddedDinner> => {
const path = ideaPath(idea, 'quest-log')
return parseDinner(`POST ${path}`, await apiFetch<unknown>(path, { method: 'POST' })).name
return parseAddedDinner(`POST ${path}`, await apiFetch<unknown>(path, { method: 'POST' }))
},
onSuccess: (_name, idea) => {
onAdded?.(idea)
onSuccess: (dinner, idea) => {
onAdded?.(idea, dinner)
return queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
},
})
+68
View File
@@ -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;
}
+40
View File
@@ -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 (
<div className={styles.page}>
<h1 className={styles.title}>Home</h1>
<Card>
<ul className={styles.rows}>
<Shortcut to="/quick" icon={Zap} title="Quick dinner" />
<Shortcut to="/cook-from-pantry" icon={Refrigerator} title="Cook from what I have" />
</ul>
</Card>
</div>
)
}
type ShortcutProps = {
to: string
icon: LucideIcon
title: string
}
function Shortcut({ to, icon, title }: ShortcutProps) {
return (
<li className={styles.item}>
<Link to={to} className={styles.row}>
<span className={styles.icon}>
<Icon icon={icon} />
</span>
<span className={styles.rowTitle}>{title}</span>
<Icon icon={ChevronRight} />
</Link>
</li>
)
}