import { Check, ChevronLeft } from 'lucide-react' import { useMemo, useRef, useState, type KeyboardEvent } from 'react' import { Link, useNavigate } from 'react-router' import { ApiError, useJob } from '../api/index.ts' import { duration } from '../plan/PlanPage.tsx' import { Button } from '../ui/Button.tsx' import { Card } from '../ui/Card.tsx' import { Chip } from '../ui/Chip.tsx' import { ErrorBanner } from '../ui/ErrorBanner.tsx' import { Icon } from '../ui/Icon.tsx' import { IconButton } from '../ui/IconButton.tsx' 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, ideasJobLost, INGREDIENT_LIMIT, MAX_INGREDIENTS, parseIdeasResult, useAddIdeaToQuestLog, useCookIdeaTonight, useFindIdeas, type Idea, type IdeaRef, type IdeaStage, type Search, } from './api.ts' import styles from './CookFromPantryPage.module.css' type When = 'any' | 'quick' const WHEN = [ { value: 'any', label: 'Any time' }, { value: 'quick', label: 'Quick only' }, ] as const satisfies readonly { value: When; label: string }[] // findingLines says what a running search is doing, by its stage. const findingLines: Record, string> = { suggesting: 'Thinking up ideas…', grounding: 'Finding recipes for them…', } // fold is an ingredient as the chips compare it, so "Rice" repeats " rice". function fold(ingredient: string): string { return ingredient.trim().replace(/\s+/g, ' ').toLowerCase() } // 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; 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() const noPlan = tonight.error instanceof ApiError && tonight.error.code === 'no_plan' return (

{idea.name}

{idea.minutes_total !== null && {duration(idea.minutes_total)}}

{idea.summary}

{tonight.isSuccess ? ( On tonight's plan ) : ( )} {questLog.isSuccess ? ( {ADDED_LABEL[questLog.data.status]} ) : ( )}
{noPlan ? (
This week has no plan yet, so there's no tonight to put it on. Plan this week
) : ( tonight.isError && (

Couldn't put it on tonight's plan. {tonight.error.message}

) )} {questLog.isError && (

Couldn't add it to the Quest log. {questLog.error.message}

)}
) } // Skeletons stand in for the count ideas still to come. function Skeletons({ count }: { count: number }) { return Array.from({ length: count }, (_, i) => ) } // 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 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(null) const [ingredients, setIngredients] = useState([]) const [draft, setDraft] = useState('') const [notice, setNotice] = useState(null) const [when, setWhen] = useState('any') // The search last asked for, which Retry asks again, and its job. const [asked, setAsked] = useState(null) const [jobId, setJobId] = useState(null) const findIdeas = useFindIdeas() const job = useJob(FROM_INGREDIENTS_JOB_KIND, jobId) // add lists entries after the chips there are, saying why it leaves out // any it cannot list: the server would refuse a blank, overlong or // repeated ingredient, or one past the most it takes. function add(list: string[], entries: string[]): string[] { const next = [...list] for (const entry of entries.map((e) => e.trim().replace(/\s+/g, ' '))) { if (entry === '') continue if (next.some((ingredient) => fold(ingredient) === fold(entry))) { setNotice(`${entry} is listed already.`) } else if (entry.length > INGREDIENT_LIMIT) { setNotice(`An ingredient can be at most ${INGREDIENT_LIMIT} characters.`) } else if (next.length === MAX_INGREDIENTS) { setNotice(`List at most ${MAX_INGREDIENTS} ingredients.`) } else { next.push(entry) } } return next } function onDraftChange(value: string) { setNotice(null) // A comma ends an ingredient, also within pasted text. const parts = value.split(',') const rest = parts.pop()! if (parts.length > 0) setIngredients(add(ingredients, parts)) setDraft(rest) } function onDraftKeyDown(event: KeyboardEvent) { if (event.key === 'Enter') { event.preventDefault() setNotice(null) setIngredients(add(ingredients, [draft])) setDraft('') } else if (event.key === 'Backspace' && draft === '' && ingredients.length > 0) { setNotice(null) setIngredients(ingredients.slice(0, -1)) } } // search asks for ideas. Only the latest search's job is followed: mutate // calls back for the latest call only. function search(next: Search) { setAsked(next) setJobId(null) findIdeas.mutate(next, { onSuccess: setJobId }) } // find lists what is still typed, then searches the chips. function find() { setNotice(null) const list = add(ingredients, [draft]) setIngredients(list) setDraft('') if (list.length > 0) search({ ingredients: list, quickOnly: when === 'quick' }) } function onWhenChange(value: When) { setWhen(value) if (asked !== null && ingredients.length > 0) search({ ingredients, quickOnly: value === 'quick' }) } const retry = () => search(asked!) // The job's result as far as it has gone, or why it cannot be read. const found = useMemo(() => { if (job.result === null || asked === null) return { result: null, error: null } try { return { result: parseIdeasResult(`GET /api/v1/jobs/${jobId}`, asked.quickOnly, job.result), error: null } } catch (error) { return { result: null, error: error as Error } } }, [job.result, jobId, asked]) let results = null if (findIdeas.isPending) { results = (

{findingLines.suggesting}

) } else if (findIdeas.isError) { results = Couldn't start looking for ideas. {findIdeas.error.message} } else if (jobId !== null && job.status === 'failed') { results = Couldn't find ideas. {ideasJobFailure(job.error!.message)} } else if (found.error !== null) { results = Couldn't read the ideas. {found.error.message} } else if (jobId !== null) { const ideas = found.result?.ideas ?? [] const done = job.status === 'succeeded' // A job not found will not be found by asking again: Retry searches // again. Any other failed poll is asked again. const lost = ideasJobLost(job.error) results = ( <> {job.error !== null && ( Couldn't check on the ideas. {job.error.message} )}

{done ? `${ideas.length} ${ideas.length === 1 ? 'idea' : 'ideas'}` : findingLines[found.result?.stage === 'grounding' ? 'grounding' : 'suggesting']}

{ideas.map((idea, index) => ( ))} {!done && }
) } return (
void navigate('/')}>

Cook from what I have

{/* A tap anywhere on the field types in it. */}
inputRef.current?.focus()}>
    {ingredients.map((ingredient, i) => (
  • { setNotice(null) setIngredients(ingredients.filter((_, j) => j !== i)) inputRef.current?.focus() }} />
  • ))}
onDraftChange(event.target.value)} onKeyDown={onDraftKeyDown} />
{notice !== null && (

{notice}

)}
{results}
) }