import { ChevronLeft, Heart, Plus } from 'lucide-react' import { useId, useState, type FormEvent, type ReactNode } from 'react' import { useNavigate } from 'react-router' import { DIFFICULTIES, METHODS, useAddRegular, useEditRegular, useGroundRegular, useRecipeCandidates, useRegulars, useRemoveRegular, useToggleFavorite, type Difficulty, type GroundingStatus, type ManualRecipe, type Method, type NewRegular, type RecipeFix, type Regular, type RegularPatch, } from '../dinners/api.ts' import { useRefusals } from '../dinners/useRefusals.ts' import { Button } from '../ui/Button.tsx' import { Card } from '../ui/Card.tsx' import { ErrorBanner } from '../ui/ErrorBanner.tsx' import { Icon } from '../ui/Icon.tsx' import { IconButton } from '../ui/IconButton.tsx' import { ListRow } from '../ui/ListRow.tsx' import { Photo } from '../ui/Photo.tsx' import { Segmented } from '../ui/Segmented.tsx' import { Select } from '../ui/Select.tsx' import { Sheet } from '../ui/Sheet.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { Tag } from '../ui/Tag.tsx' import { TextInput } from '../ui/TextInput.tsx' import styles from './RegularsSettingsPage.module.css' // The most minutes a dinner may take, as the dinners API allows: a day. const MAX_MINUTES = 1440 export const methodLabels: Record = { stovetop: 'Stovetop', oven: 'Oven', crockpot: 'Crock pot', grill: 'Grill', other: 'Other', } const difficultyLabels: Record = { easy: 'Easy', medium: 'Medium', hard: 'Hard' } // A pasted link is added as a recipe link under a name the household gives; // anything else is a dinner name. const LINK = /^https?:\/\//i // Settings · Regular dinners: the household's Regulars, each with its photo, // method and minutes, favorite heart and whether its recipe was found. The // field at the top adds one by name, or by pasted recipe link with a name // the household gives it; tapping one // edits or removes it, or fixes its recipe when none was found. export function RegularsSettingsPage() { const navigate = useNavigate() return (
void navigate('/settings')}>

Settings

Regular dinners

Changes apply to everyone in the household.

) } // Which sheet is open, for which Regular. type Open = { sheet: 'edit' | 'fix'; id: string } | null // RegularsManager lists, adds, edits and fixes the recipes of the // household's Regulars: this page's body, and the Settings "Regular // dinners" sub-tab at tablet and desktop widths. export function RegularsManager() { const regulars = useRegulars() const add = useAddRegular() const remove = useRemoveRegular() const favorite = useToggleFavorite() const { refusals, track } = useRefusals() const [draft, setDraft] = useState('') const [linkName, setLinkName] = useState('') const [open, setOpen] = useState(null) const headingId = useId() const linkHintId = useId() const isLink = LINK.test(draft.trim()) const ready = draft.trim() !== '' && (!isLink || linkName.trim() !== '') const list = regulars.data ?? [] // A Regular removed meanwhile closes its sheet. const opened = open && list.find((regular) => regular.id === open.id && !regular.pending) function onSubmit(event: FormEvent) { event.preventDefault() if (!ready) return const text = draft.trim() const body: NewRegular = isLink ? { name: linkName.trim(), recipe_url: text } : { name: text } track(`add:${body.name.toLowerCase()}`, `add ${body.name}`, add.mutateAsync(body)) setDraft('') setLinkName('') } function onRemove(regular: Regular) { setOpen(null) track(`remove:${regular.id}`, `remove ${regular.name}`, remove.mutateAsync(regular)) } function onFavorite(regular: Regular) { track(`favorite:${regular.id}`, `change the favorite heart on ${regular.name}`, favorite.mutateAsync(regular)) } return ( <>
setDraft(event.target.value)} />
{isLink && ( <> setLinkName(event.target.value)} />

Name it yourself: a link can carry private details, so it never names a dinner.

)} {refusals.map((refusal) => (

{refusal.text}

))} {regulars.isPending && } {regulars.isError && ( void regulars.refetch()}> Your Regulars could not load: {regulars.error.message} )} {regulars.isSuccess && (

On rotation · {list.length}

{list.length === 0 ? (

No Regulars yet. Add the dinners your household makes on repeat.

) : (
    {list.map((regular) => ( setOpen({ sheet: regular.grounding_status === 'failed' ? 'fix' : 'edit', id: regular.id }) } onFavorite={() => onFavorite(regular)} /> ))}
)}
)} {opened && open.sheet === 'edit' && ( setOpen(null)} onRemove={() => onRemove(opened)} /> )} {opened && open.sheet === 'fix' && ( setOpen(null)} onEdit={() => setOpen({ sheet: 'edit', id: opened.id })} /> )} ) } // details is a Regular's "method · minutes" line, or null when neither is // known yet. export function details(regular: Regular): string | null { const parts = [ regular.method && methodLabels[regular.method], regular.minutes_total !== null && `${regular.minutes_total} min`, ].filter((part): part is string => typeof part === 'string') return parts.length === 0 ? null : parts.join(' · ') } const badges: Record = { pending: { text: 'Finding recipe…', className: styles.finding }, grounded: { text: 'Recipe found', className: styles.found }, manual: { text: 'Recipe found', className: styles.found }, failed: { text: 'Needs a recipe', className: styles.needs }, } function GroundingBadge({ status }: { status: GroundingStatus }) { const badge = badges[status] return {badge.text} } type RegularRowProps = { regular: Regular onOpen: () => void onFavorite: () => void } function RegularRow({ regular, onOpen, onFavorite }: RegularRowProps) { const line = details(regular) return (
  • } title={regular.name} meta={ {line && {line}} } onClick={regular.pending ? undefined : onOpen} trailing={ } />
  • ) } type SheetFrameProps = { title: string onClose: () => void children: ReactNode } function SheetFrame({ title, onClose, children }: SheetFrameProps) { const titleId = useId() return (

    {title}

    {children}
    ) } // minutesProblem is why a typed minutes value cannot be saved, or null. function minutesProblem(text: string): string | null { if (text === '') return null const minutes = Number(text) if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_MINUTES) { return `Minutes must be a whole number from 1 to ${MAX_MINUTES}.` } return null } type Draft = { name: string; method: Method | ''; minutes: string; difficulty: Difficulty | '' } function draftOf(regular: Regular): Draft { return { name: regular.name, method: regular.method ?? '', minutes: regular.minutes_total === null ? '' : String(regular.minutes_total), difficulty: regular.difficulty ?? '', } } // patchOf is the PATCH body for the fields draft changes. A field the // Regular has no value for stays unset until one is chosen. function patchOf(regular: Regular, draft: Draft): RegularPatch { const saved = draftOf(regular) const patch: RegularPatch = {} if (draft.name.trim() !== saved.name) patch.name = draft.name.trim() if (draft.method !== saved.method && draft.method !== '') patch.method = draft.method if (draft.minutes !== saved.minutes && draft.minutes !== '') patch.minutes_total = Number(draft.minutes) if (draft.difficulty !== saved.difficulty && draft.difficulty !== '') patch.difficulty = draft.difficulty return patch } type EditRegularSheetProps = { regular: Regular onClose: () => void onRemove: () => void } // EditRegularSheet renames a Regular and sets its method, minutes and // difficulty, or removes it from the rotation once confirmed. function EditRegularSheet({ regular, onClose, onRemove }: EditRegularSheetProps) { const [draft, setDraft] = useState(() => draftOf(regular)) const [confirming, setConfirming] = useState(false) const edit = useEditRegular() const patch = patchOf(regular, draft) const minutesError = minutesProblem(draft.minutes) const nameError = draft.name.trim() === '' ? 'A Regular needs a name.' : null const canSave = Object.keys(patch).length > 0 && minutesError === null && nameError === null function change(next: Partial) { edit.reset() setDraft((current) => ({ ...current, ...next })) } function onSave(event: FormEvent) { event.preventDefault() if (!canSave) return edit.mutate({ id: regular.id, patch }, { onSuccess: onClose }) } if (confirming) { return (

    It leaves your Regulars, so suggestions and plans stop using it. You can add it again later.

    ) } return (
    change({ name: event.target.value })} /> {nameError &&

    {nameError}

    }
    change({ minutes: event.target.value })} />
    {minutesError &&

    {minutesError}

    } {edit.isError && (

    Not saved: {edit.error.message}

    )}
    ) } type FixPath = 'pick' | 'link' | 'manual' const fixPaths = [ { value: 'pick', label: 'Pick one' }, { value: 'link', label: 'Paste a link' }, { value: 'manual', label: 'Type it in' }, ] as const type FixRecipeSheetProps = { regular: Regular onClose: () => void onEdit: () => void } // lines is text split into its non-blank lines, trimmed. function lines(text: string): string[] { return text .split('\n') .map((line) => line.trim()) .filter((line) => line !== '') } // FixRecipeSheet grounds a Regular whose recipe was not found: in one of // up to three recipe pages found for its name, in a pasted recipe link, or // in ingredients and steps typed in. function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) { const [path, setPath] = useState('pick') const ground = useGroundRegular() function fix(body: RecipeFix) { ground.mutate({ id: regular.id, fix: body }, { onSuccess: onClose }) } return (

    We couldn't find a recipe for {regular.name}. Pick a match below, paste a recipe link or type it in.

    { ground.reset() setPath(next) }} />
    {path === 'pick' && fix({ candidate_id })} />} {path === 'link' && fix({ recipe_url })} />} {path === 'manual' && fix({ manual })} />}

    {ground.isPending && 'Finding recipe… this can take a minute.'}

    {ground.isError && (

    Not fixed: {ground.error.message}

    )}
    ) } function PickCandidate({ id, onPick }: { id: string; onPick: (candidateId: string) => void }) { const candidates = useRecipeCandidates(id) if (candidates.isPending) return if (candidates.isError) { return ( void candidates.refetch()}> Recipe matches could not load: {candidates.error.message} ) } if (candidates.data.length === 0) { return

    No matches were found. Paste a recipe link or type it in.

    } return (
      {candidates.data.map((candidate) => (
    • ))}
    ) } function PasteLink({ onSubmit }: { onSubmit: (recipeUrl: string) => void }) { const [link, setLink] = useState('') const url = link.trim() return (
    { event.preventDefault() if (url !== '') onSubmit(url) }} > setLink(event.target.value)} /> ) } function ManualEntry({ onSubmit }: { onSubmit: (manual: ManualRecipe) => void }) { const [ingredients, setIngredients] = useState('') const [steps, setSteps] = useState('') const [minutes, setMinutes] = useState('') const [method, setMethod] = useState('') const ingredientsId = useId() const stepsId = useId() const minutesError = minutesProblem(minutes) const ingredientLines = lines(ingredients) const missing = method === '' || minutes === '' const canSave = ingredientLines.length > 0 && !missing && minutesError === null function onSave(event: FormEvent) { event.preventDefault() if (!canSave) return onSubmit({ ingredients: ingredientLines, steps: lines(steps), minutes: Number(minutes), method }) } return (