import { Check, Plus, RefreshCw, Zap } from 'lucide-react' import { useEffect, useMemo, useRef, useState } from 'react' import { useIsMutating } from '@tanstack/react-query' import { useJob } from '../api/index.ts' import { DIFFICULTY_LABEL } from '../discover/DeckCard.tsx' import { duration } from '../plan/PlanPage.tsx' import { methodLabels } from '../settings/RegularsSettingsPage.tsx' import { ErrorBanner } from '../ui/ErrorBanner.tsx' import { Icon } from '../ui/Icon.tsx' import { IconButton } from '../ui/IconButton.tsx' 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, parseQuickResult, QUICK_IDEAS_KEPT, QUICK_JOB_KIND, quickTabKey, shownNames, useAddIdeaToQuestLog, useFindQuickIdeas, useQuickTab, type AddedDinner, type AddedStatus, type IdeaRef, type QuickIdea, type QuickKind, } from './api.ts' import styles from './QuickDinnersPage.module.css' const TABS = [ { value: 'crockpot', label: 'Crock pot' }, { value: 'under30', label: '30 min or less' }, ] as const satisfies readonly { value: QuickKind; label: string }[] // helpers says what each tab finds. const helpers: Record = { crockpot: 'Set it going in the crock pot and come back to dinner, picked from your tastes.', under30: 'Prep to table in 30 minutes or less, picked from your tastes.', } // The server's code for a search that found nothing: on a later page, it // means the tab has shown every idea there is. const NO_IDEAS_FOUND = 'no_ideas_found' // meta is a row's "25 min · Medium" line: "Crock pot" first for a crock-pot // idea, and the time only when the recipe says it. function meta(idea: QuickIdea): string { return [ idea.method === 'crockpot' && methodLabels.crockpot, idea.minutes_total !== null && duration(idea.minutes_total), DIFFICULTY_LABEL[idea.difficulty], ] .filter((part): part is string => typeof part === 'string') .join(' · ') } // QuickRow is one idea (mock 1j): its washed photo, name and meta line, and // 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. function QuickRow({ idea, ideaRef, added, onAdded, }: { idea: QuickIdea ideaRef: IdeaRef | null added: AddedStatus | undefined onAdded: (idea: IdeaRef, dinner: AddedDinner) => void }) { const questLog = useAddIdeaToQuestLog(onAdded) return (
  • {idea.name}

    {meta(idea)} {questLog.isError && (

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

    )}
    {added !== undefined ? ( ) : ( questLog.mutate(ideaRef!)} > )}
  • ) } // Skeletons stand in for the count ideas still to come. function Skeletons({ count }: { count: number }) { return Array.from({ length: count }, (_, i) => ( )) } // QuickTabPanel is one tab's ideas: its pages so far, the page being // found, filling as the server grounds it, and Show me more, which asks for // a page excluding every name shown. The tab's first page is asked for when // the tab is first shown; what it finds is kept for the session. function QuickTabPanel({ kind }: { kind: QuickKind }) { const [tab, update] = useQuickTab(kind) const { mutate } = useFindQuickIdeas(kind) // A page being asked for, also by this tab before it was last shown. const starting = useIsMutating({ mutationKey: quickTabKey(kind) }) > 0 const job = useJob(QUICK_JOB_KIND, tab.jobId) const names = shownNames(tab) // ask asks for the next page, excluding every name shown; Retry asks // again for the page that failed. A page that could not start stays the // tab's startError until Retry: nothing asks again by itself. const ask = () => mutate(names) // The first page, asked for once. const asked = useRef(false) const fresh = tab.pages.length === 0 && tab.jobId === null && !tab.ended && tab.startError === null && !starting useEffect(() => { if (!fresh || asked.current) return asked.current = true mutate([]) }, [fresh, mutate]) // The job's result as far as it has gone, or why it cannot be read. const found = useMemo(() => { if (job.result === null || tab.jobId === null) return { result: null, error: null } try { return { result: parseQuickResult(`GET /api/v1/jobs/${tab.jobId}`, kind, job.result), error: null } } catch (error) { return { result: null, error: error as Error } } }, [job.result, tab.jobId, kind]) const failure = job.status === 'failed' ? job.error!.message : null // A later page that finds nothing means there is nothing more to show. const exhausted = failure === NO_IDEAS_FOUND && tab.pages.length > 0 // A page found is kept with the tab's pages; a page that ends the tab // ends it. const jobId = tab.jobId const ideas = job.status === 'succeeded' ? (found.result?.ideas ?? null) : null useEffect(() => { if (jobId === null) return if (ideas !== null) update((t) => (t.jobId === jobId ? { ...t, pages: [...t.pages, { jobId, ideas }], jobId: null } : t)) else if (exhausted) update((t) => (t.jobId === jobId ? { ...t, jobId: null, ended: true } : t)) }, [jobId, ideas, exhausted, update]) 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 // them any add still under way. const rows: { idea: QuickIdea; ref: IdeaRef | null; key: string }[] = tab.pages.flatMap((page) => page.ideas.map((idea, index) => ({ idea, ref: { jobId: page.jobId, index }, key: `${page.jobId}:${index}` })), ) let next = null if (starting) { next = } else if (tab.startError !== null) { next = (
  • Couldn't start looking for Quick dinners. {tab.startError.message}
  • ) } else if (jobId !== null && failure !== null && !exhausted) { next = (
  • Couldn't find Quick dinners. {ideasJobFailure(failure)}
  • ) } else if (found.error !== null) { next = (
  • Couldn't read the Quick dinners. {found.error.message}
  • ) } else if (jobId !== null && !exhausted) { const shown = found.result?.ideas ?? [] const done = job.status === 'succeeded' shown.forEach((idea, index) => rows.push({ idea, ref: done ? { jobId, index } : null, key: `${jobId}:${index}` })) // A job not found will not be found by asking again: Retry asks for the // page again. Any other failed poll is asked again. const lost = ideasJobLost(job.error) next = ( <> {!done && } {job.error !== null && (
  • Couldn't check on the Quick dinners. {job.error.message}
  • )} ) } // Show me more once the tab is between pages; the server takes at most // MAX_EXCLUDE names to leave out, so past that there is no more to ask. const between = !starting && tab.startError === null && jobId === null && tab.pages.length > 0 const allShown = tab.ended || names.length > MAX_EXCLUDE const busy = starting || (jobId !== null && failure === null && found.error === null && job.status !== 'succeeded') return (
    t.value === kind)!.label} className={styles.panel}>

    {helpers[kind]}

      {rows.map(({ idea, ref, key }) => ( ))} {next}
    {between && (allShown ? (

    That's every Quick dinner for now.

    ) : ( ))}
    ) } // QuickDinnersPage is mock 1j, Quick dinners: a Crock pot | 30 min or less // tab for each kind of Quick dinner, each a quick_ideas search of its own // whose ideas fill as the server grounds them, kept for the session. export function QuickDinnersPage() { const [kind, setKind] = useState('under30') return (

    Quick dinners

    ) }