Photo backfill UI: fix-recipe failures and Find missing photos (Maestro 7a468d2d, Feature 23 task 2)
CI / ci (push) Successful in 9m38s
CI / ci (pull_request) Successful in 9m30s

Settings → Regular dinners gains a "Find missing photos" trigger that
calls POST /api/v1/settings/photo-backfill (202 {id}) and follows the
backfill_dinner_photos job with useJob, showing progress, a summary of
the outcomes, or why it stopped. When the job ends, the dinners, plans
and home queries are read again so found photos show everywhere.

The fix-recipe sheet now shows the dinner's grounding_error as a typed
reason and offers "Search again" (the same backfill), reporting this
dinner's outcome; it is not offered for recipe_unusable, since the
backfill never replaces a pasted link. Regular now parses
grounding_error.

Task: 7a468d2d-4a9b-45f2-b626-a7bed5f671df

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 21:58:52 -04:00
co-authored by Claude Opus 5.5
parent 6127c5c2d1
commit 3882f670ca
8 changed files with 544 additions and 12 deletions
@@ -52,6 +52,7 @@ function listed(is_favorite: boolean) {
is_favorite,
photo_url: null,
grounding_status: 'grounded',
grounding_error: null,
}
}
+10 -2
View File
@@ -20,8 +20,10 @@ export type Difficulty = (typeof DIFFICULTIES)[number]
export type GroundingStatus = (typeof GROUNDING_STATUSES)[number]
// Regular is the part of a dinner the Regulars screens read. photo_id is
// the stored photo's id, from photo_url. pending is true while an
// optimistic add waits for the server; its id is not the server's.
// the stored photo's id, from photo_url. grounding_error is why its
// grounding failed, a reason code, set only while grounding_status is
// failed. pending is true while an optimistic add waits for the server;
// its id is not the server's.
export type Regular = {
id: string
name: string
@@ -31,6 +33,7 @@ export type Regular = {
is_favorite: boolean
photo_id: string | undefined
grounding_status: GroundingStatus
grounding_error: string | null
pending?: true
}
@@ -122,6 +125,10 @@ export function parseDinner(where: string, body: unknown): Regular {
is_favorite: body.is_favorite,
photo_id: photoId(where, body.photo_url),
grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status),
grounding_error: nullable(body.grounding_error, (v) => {
if (typeof v !== 'string') throw new Error(`${where}: grounding_error is not a string`)
return v
}),
}
}
@@ -225,6 +232,7 @@ export function useAddRegular() {
is_favorite: false,
photo_id: undefined,
grounding_status: 'pending',
grounding_error: null,
pending: true,
}
await changeOptimistically(queryClient, (list) => [...list, optimistic])
+1
View File
@@ -114,6 +114,7 @@ function dinner(id: string, name: string, difficulty: string | null, minutes: nu
is_favorite: false,
photo_url: null,
grounding_status: 'grounded',
grounding_error: null,
}
}
@@ -300,3 +300,27 @@
.lock {
display: contents;
}
/* Find missing photos: its hint, button and progress, padded like a sheet. */
.card.backfill {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
/* Why a Regular's recipe was not found. */
.reason {
margin: 0;
padding: 12px 14px;
border-radius: 18px;
background: var(--color-neutral-100);
color: var(--color-neutral-900);
font-family: var(--font-body);
font-size: 14px;
line-height: 1.4;
}
.reasonLabel {
font-weight: 700;
}
@@ -496,6 +496,195 @@ describe('RegularsSettingsPage', () => {
})
})
describe('Photo backfill', () => {
const BACKFILL = 'POST /api/v1/settings/photo-backfill'
const JOB = 'GET /api/v1/jobs/j9'
// backfillJob is a GET /api/v1/jobs/j9 answer for the photo backfill.
function backfillJob(status: string, result: unknown = null, error: string | null = null) {
return jsonResponse(200, { id: 'j9', kind: 'backfill_dinner_photos', status, result, error })
}
// polls answers each poll of the job with the next answer, then the last
// one again; an answer may change the server first.
function polls(...answers: (() => Response)[]) {
let next = 0
return () => answers[Math.min(next++, answers.length - 1)]!()
}
function fixSheet() {
return within(screen.getByRole('dialog', { name: 'Fix recipe' }))
}
const NO_CANDIDATES = { 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, { candidates: [] }) }
it('shows why the recipe was not found in the fix-recipe sheet', async () => {
stubServer([PIZZA], NO_CANDIDATES)
await renderPage()
openRow('Pizza night')
expect(fixSheet().getByText('No recipe page the search found had a recipe we could use.')).toBeInTheDocument()
expect(fixSheet().getByRole('button', { name: 'Search again' })).toBeEnabled()
})
it('offers no search again for a pasted link that had no usable recipe', async () => {
const linked = { ...PIZZA, grounding_error: 'recipe_unusable' }
stubServer([linked], NO_CANDIDATES)
await renderPage()
openRow('Pizza night')
expect(fixSheet().getByText('The pasted recipe link has no recipe we could use.')).toBeInTheDocument()
expect(fixSheet().queryByRole('button', { name: 'Search again' })).not.toBeInTheDocument()
})
it('searches again from the fix-recipe sheet and shows the new reason when it still fails', async () => {
const server = stubServer([PIZZA], {
...NO_CANDIDATES,
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: polls(
() => backfillJob('running'),
() => {
server.regulars = [{ ...PIZZA, grounding_error: 'search_failed' }]
return backfillJob('succeeded', { dinners: [{ dinner_id: 'd3', outcome: 'failed', reason: 'search_failed' }] })
},
),
})
await renderPage()
openRow('Pizza night')
fireEvent.click(fixSheet().getByRole('button', { name: 'Search again' }))
expect(await fixSheet().findByRole('button', { name: 'Searching again…' })).toBeDisabled()
expect(fixSheet().getByText('Searching again… this can take a few minutes.')).toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual([{ request: BACKFILL, body: undefined }])
expect(await fixSheet().findByText('Searched again: still no recipe found.', {}, { timeout: 3000 })).toBeInTheDocument()
expect(await fixSheet().findByText("The recipe search couldn't be reached.")).toBeInTheDocument()
expect(fixSheet().getByRole('button', { name: 'Search again' })).toBeEnabled()
})
it('closes the fix-recipe sheet once searching again finds the recipe and its photo', async () => {
const server = stubServer([PIZZA], {
...NO_CANDIDATES,
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: () => {
server.regulars = [{ ...PIZZA, grounding_status: 'grounded', grounding_error: null, photo_url: '/media/photos/p8-400.jpg' }]
return backfillJob('succeeded', { dinners: [{ dinner_id: 'd3', outcome: 'grounded', reason: null }] })
},
})
await renderPage()
openRow('Pizza night')
fireEvent.click(fixSheet().getByRole('button', { name: 'Search again' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
expect(within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })).toHaveAttribute('src', '/media/photos/p8-400.jpg')
})
it('says a dinner the search left out keeps its pasted recipe link', async () => {
const linked = { ...PIZZA, grounding_error: 'ai_failed' }
stubServer([linked], {
...NO_CANDIDATES,
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: () => backfillJob('succeeded', { dinners: [] }),
})
await renderPage()
openRow('Pizza night')
fireEvent.click(fixSheet().getByRole('button', { name: 'Search again' }))
expect(await fixSheet().findByText('Not searched again: it keeps its pasted recipe link.')).toBeInTheDocument()
})
it('finds missing photos: the placeholder shows until the backfill finds a photo', async () => {
const found = { ...PIZZA, grounding_status: 'grounded', grounding_error: null, photo_url: '/media/photos/p8-400.jpg' }
const server = stubServer([TACOS, PIZZA], {
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: polls(
() => backfillJob('queued'),
() => {
server.regulars = [TACOS, found]
return backfillJob('succeeded', { dinners: [{ dinner_id: 'd3', outcome: 'grounded', reason: null }] })
},
),
})
await renderPage()
// The photoless dinner shows the striped placeholder, not an image.
const placeholder = within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })
expect(placeholder).not.toHaveAttribute('src')
expect(placeholder).toHaveTextContent('dish photo')
const trigger = screen.getByRole('button', { name: 'Find missing photos' })
expectMinHitArea(trigger)
fireEvent.click(trigger)
expect(await screen.findByRole('button', { name: 'Finding photos…' })).toBeDisabled()
expect(await screen.findByText('Finding missing photos… this can take a few minutes.')).toBeInTheDocument()
expect(within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })).not.toHaveAttribute('src')
expect(sent(server.fetchMock)).toEqual([{ request: BACKFILL, body: undefined }])
expect(await screen.findByText('Photo search done: 1 photo found.', {}, { timeout: 3000 })).toBeInTheDocument()
await waitFor(() =>
expect(within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })).toHaveAttribute('src', '/media/photos/p8-400.jpg'),
)
expect(screen.getByRole('button', { name: 'Find missing photos' })).toBeEnabled()
expect(reads(server.fetchMock)).toBe(2)
})
it('sums up what the backfill did', async () => {
stubServer([PIZZA], {
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: () =>
backfillJob('succeeded', {
dinners: [
{ dinner_id: 'd3', outcome: 'grounded', reason: null },
{ dinner_id: 'd5', outcome: 'grounded', reason: null },
{ dinner_id: 'd6', outcome: 'failed', reason: 'no_recipe_found' },
{ dinner_id: 'd7', outcome: 'skipped', reason: null },
],
}),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
expect(
await screen.findByText(
'Photo search done: 2 photos found · 1 dinner still needs a recipe · 1 dinner changed meanwhile, left as it was.',
),
).toBeInTheDocument()
})
it('says when no dinner was missing a photo', async () => {
stubServer([TACOS], {
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: () => backfillJob('succeeded', { dinners: [] }),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
expect(await screen.findByText('No dinners were missing a photo.')).toBeInTheDocument()
})
it('says why the backfill stopped', async () => {
stubServer([PIZZA], {
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: () => backfillJob('failed', { dinners: [] }, 'internal_error'),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Photo search stopped: Something went wrong on the server.')
expect(screen.getByRole('button', { name: 'Find missing photos' })).toBeEnabled()
})
it('says why the backfill could not start', async () => {
stubServer([PIZZA], { [BACKFILL]: () => errorResponse(403, 'csrf_failed') })
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Photo search not started: csrf_failed message')
expect(screen.getByRole('button', { name: 'Find missing photos' })).toBeEnabled()
})
})
it('refreshes grounding status while any Regular is finding its recipe, then stops', async () => {
// Only intervals are faked: TanStack Query polls with setInterval, and
// Testing Library's waits keep their real timers.
+146 -10
View File
@@ -34,6 +34,7 @@ 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 { usePhotoBackfill, type BackfillResult, type PhotoBackfill } from './photoBackfill.ts'
import styles from './RegularsSettingsPage.module.css'
// The most minutes a dinner may take, as the dinners API allows: a day.
@@ -57,7 +58,8 @@ const LINK = /^https?:\/\//i
// 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.
// edits or removes it, or fixes its recipe when none was found. Find
// missing photos searches again for every dinner left without one.
export function RegularsSettingsPage() {
const navigate = useNavigate()
return (
@@ -86,6 +88,7 @@ function RegularsManager() {
const remove = useRemoveRegular()
const favorite = useToggleFavorite()
const { refusals, track } = useRefusals()
const backfill = usePhotoBackfill()
const [draft, setDraft] = useState('')
const [linkName, setLinkName] = useState('')
const [open, setOpen] = useState<Open>(null)
@@ -94,8 +97,10 @@ function RegularsManager() {
const isLink = LINK.test(draft.trim())
const ready = draft.trim() !== '' && (!isLink || linkName.trim() !== '')
const list = regulars.data ?? []
// A Regular removed meanwhile closes its sheet.
// A Regular removed meanwhile closes its sheet, and one whose recipe was
// found meanwhile closes its fix-recipe sheet.
const opened = open && list.find((regular) => regular.id === open.id && !regular.pending)
const fixing = opened && open.sheet === 'fix' && opened.grounding_status === 'failed' ? opened : undefined
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
@@ -188,6 +193,7 @@ function RegularsManager() {
)}
</section>
)}
{regulars.isSuccess && <FindMissingPhotos backfill={backfill} />}
{opened && open.sheet === 'edit' && (
<EditRegularSheet
@@ -197,12 +203,13 @@ function RegularsManager() {
onRemove={() => onRemove(opened)}
/>
)}
{opened && open.sheet === 'fix' && (
{fixing && (
<FixRecipeSheet
key={opened.id}
regular={opened}
key={fixing.id}
regular={fixing}
backfill={backfill}
onClose={() => setOpen(null)}
onEdit={() => setOpen({ sheet: 'edit', id: opened.id })}
onEdit={() => setOpen({ sheet: 'edit', id: fixing.id })}
/>
)}
</>
@@ -231,6 +238,88 @@ function GroundingBadge({ status }: { status: GroundingStatus }) {
return <Tag className={`${styles.badge} ${badge.className}`}>{badge.text}</Tag>
}
// groundingReasons says why a dinner's recipe was not found, by its
// grounding_error reason code.
const groundingReasons: Record<string, string> = {
no_recipe_found: 'No recipe page the search found had a recipe we could use.',
recipe_unusable: 'The pasted recipe link has no recipe we could use.',
search_not_configured: 'Recipe search is not set up. An admin can set it up in Settings.',
search_failed: "The recipe search couldn't be reached.",
ai_failed: "The AI assistant couldn't read the recipe.",
interrupted: 'The server stopped while finding the recipe.',
grounding_error: 'Something went wrong on the server.',
}
// groundingReason says why a dinner's recipe was not found; an unknown
// reason code is shown as it is.
export function groundingReason(code: string): string {
return groundingReasons[code] ?? code
}
// backfillJobFailures says why a photo backfill job failed, by its failure
// code; an unknown code is shown as it is.
const backfillJobFailures: Record<string, string> = {
internal_error: 'Something went wrong on the server.',
}
// backfillProblem says what went wrong with a photo backfill, or null.
function backfillProblem(backfill: PhotoBackfill): string | null {
if (backfill.startError) return `Photo search not started: ${backfill.startError.message}`
if (backfill.status === 'failed' && backfill.error) {
return `Photo search stopped: ${backfillJobFailures[backfill.error.message] ?? backfill.error.message}`
}
if (backfill.error) return `Couldn't check on the photo search: ${backfill.error.message}`
return null
}
function count(n: number, one: string, many: string): string {
return `${n} ${n === 1 ? one : many}`
}
// backfillSummary says what a finished photo backfill did.
function backfillSummary(result: BackfillResult): string {
const tally = (outcome: string) => result.dinners.filter((dinner) => dinner.outcome === outcome).length
if (result.dinners.length === 0) return 'No dinners were missing a photo.'
const parts = [count(tally('grounded'), 'photo found', 'photos found')]
const failed = tally('failed')
if (failed > 0) parts.push(`${count(failed, 'dinner still needs', 'dinners still need')} a recipe`)
const skipped = tally('skipped')
if (skipped > 0) parts.push(`${count(skipped, 'dinner', 'dinners')} changed meanwhile, left as it was`)
return `Photo search done: ${parts.join(' · ')}.`
}
// FindMissingPhotos searches again, by name, for a recipe and photo for
// every dinner left without a photo because its recipe was not found.
function FindMissingPhotos({ backfill }: { backfill: PhotoBackfill }) {
const headingId = useId()
const problem = backfillProblem(backfill)
return (
<section aria-labelledby={headingId} className={styles.section}>
<h2 id={headingId} className={styles.groupLabel}>
Missing photos
</h2>
<Card className={`${styles.card} ${styles.backfill}`}>
<p className={styles.hint}>
Search again for a recipe and photo for every dinner whose recipe wasn&apos;t found. Dinners with a pasted
recipe link keep it.
</p>
<Button disabled={backfill.active} onClick={backfill.start}>
{backfill.active ? 'Finding photos…' : 'Find missing photos'}
</Button>
<p role="status" className={styles.status}>
{backfill.active && 'Finding missing photos… this can take a few minutes.'}
{backfill.status === 'succeeded' && backfill.result && backfillSummary(backfill.result)}
</p>
{problem && (
<p role="alert" className={styles.problem}>
{problem}
</p>
)}
</Card>
</section>
)
}
type RegularRowProps = {
regular: Regular
onOpen: () => void
@@ -447,6 +536,7 @@ const fixPaths = [
type FixRecipeSheetProps = {
regular: Regular
backfill: PhotoBackfill
onClose: () => void
onEdit: () => void
}
@@ -459,10 +549,19 @@ function lines(text: string): string[] {
.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) {
// retryOutcomes says what searching again did for this dinner.
const retryOutcomes = {
grounded: 'Searched again: a recipe and photo were found.',
failed: 'Searched again: still no recipe found.',
skipped: 'It changed while it was searched again, so it was left as it is.',
} as const
// FixRecipeSheet shows why a Regular's recipe was not found and grounds it:
// by searching its name again (a photo backfill), in one of up to three
// recipe pages found for its name, in a pasted recipe link, or in
// ingredients and steps typed in. Searching again never replaces a pasted
// link, so a dinner whose link had no usable recipe is not offered it.
function FixRecipeSheet({ regular, backfill, onClose, onEdit }: FixRecipeSheetProps) {
const [path, setPath] = useState<FixPath>('pick')
const ground = useGroundRegular()
@@ -475,6 +574,15 @@ function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) {
<p className={styles.hint}>
We couldn&apos;t find a recipe for {regular.name}. Pick a match below, paste a recipe link or type it in.
</p>
{regular.grounding_error !== null && (
<p className={styles.reason}>
<span className={styles.reasonLabel}>Why: </span>
{groundingReason(regular.grounding_error)}
</p>
)}
{regular.grounding_error !== 'recipe_unusable' && (
<RetrySearch id={regular.id} backfill={backfill} disabled={ground.isPending} />
)}
<Segmented
label="How to fix the recipe"
options={fixPaths}
@@ -504,6 +612,34 @@ function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) {
)
}
// RetrySearch searches the dinner's name again with a photo backfill, and
// says what it did for the dinner once it ends. The backfill searches
// every dinner without a photo, this one with them.
function RetrySearch({ id, backfill, disabled }: { id: string; backfill: PhotoBackfill; disabled: boolean }) {
const problem = backfillProblem(backfill)
const tried = backfill.result?.dinners.find((dinner) => dinner.dinner_id === id)
let status: string | null = null
if (backfill.active) status = 'Searching again… this can take a few minutes.'
else if (backfill.status === 'succeeded') {
status = tried ? retryOutcomes[tried.outcome] : 'Not searched again: it keeps its pasted recipe link.'
}
return (
<>
<Button variant="outline" disabled={disabled || backfill.active} onClick={backfill.start}>
{backfill.active ? 'Searching again…' : 'Search again'}
</Button>
<p role="status" className={styles.status}>
{status}
</p>
{problem && (
<p role="alert" className={styles.problem}>
{problem}
</p>
)}
</>
)
}
function PickCandidate({ id, onPick }: { id: string; onPick: (candidateId: string) => void }) {
const candidates = useRecipeCandidates(id)
if (candidates.isPending) return <Skeleton className={styles.candidatesLoading} />
+63
View File
@@ -0,0 +1,63 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { HOME_KEY } from '../home/api.ts'
import { planKey } from '../plan/api.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { fakeLocation, jsonResponse, ME, stubFetch } from '../test/fetchStub.ts'
import { parseBackfillResult, usePhotoBackfill } from './photoBackfill.ts'
afterEach(() => {
vi.unstubAllGlobals()
})
const RESULT = { dinners: [{ dinner_id: 'd3', outcome: 'grounded', reason: null }] }
function backfillJob(status: string, result: unknown) {
return jsonResponse(200, { id: 'j9', kind: 'backfill_dinner_photos', status, result, error: null })
}
describe('usePhotoBackfill', () => {
it('reads the plans, home and dinners again once the backfill ends, so a found photo shows everywhere', async () => {
const fetchMock = stubFetch(
jsonResponse(200, ME),
jsonResponse(202, { id: 'j9' }),
backfillJob('running', null),
backfillJob('succeeded', RESULT),
)
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const queryClient = createQueryClient()
const plan = planKey('2026-10-05')
for (const key of [plan, HOME_KEY, ['dinners', 'quest_log']]) queryClient.setQueryData(key, {})
const wrapper = ({ children }: { children: ReactNode }) => (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ApiProvider>
)
const { result } = renderHook(() => usePhotoBackfill(), { wrapper })
act(() => result.current.start())
await waitFor(() => expect(result.current.status).toBe('running'))
expect(result.current.active).toBe(true)
expect(queryClient.getQueryState(plan)?.isInvalidated).toBe(false)
await waitFor(() => expect(result.current.status).toBe('succeeded'), { timeout: 3000 })
expect(result.current).toMatchObject({ active: false, error: null, result: RESULT })
expect(fetchMock).toHaveBeenCalledTimes(4)
for (const key of [plan, HOME_KEY, ['dinners', 'quest_log']]) {
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true)
}
})
})
describe('parseBackfillResult', () => {
it('refuses a result that is not a photo backfill', () => {
expect(() => parseBackfillResult('job', { dinners: [{ dinner_id: 'd3', outcome: 'lost', reason: null }] })).toThrow(
'job: outcome "lost" is not one of grounded, failed, skipped',
)
expect(() => parseBackfillResult('job', { stage: 'x' })).toThrow('job: result is not a photo backfill')
})
})
+110
View File
@@ -0,0 +1,110 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { useApi, useJob, type JobStatus } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { oneOf } from '../dinners/api.ts'
import { HOME_KEY } from '../home/api.ts'
export const PHOTO_BACKFILL_PATH = '/api/v1/settings/photo-backfill'
// BACKFILL_JOB_KIND is the kind of the job a photo backfill runs as.
export const BACKFILL_JOB_KIND = 'backfill_dinner_photos'
export const BACKFILL_OUTCOMES = ['grounded', 'failed', 'skipped'] as const
export type BackfillOutcome = (typeof BACKFILL_OUTCOMES)[number]
// BackfillDinner is what a photo backfill did for one dinner. reason is
// set only when it failed: the reason code now its grounding_error.
export type BackfillDinner = { dinner_id: string; outcome: BackfillOutcome; reason: string | null }
// BackfillResult is the photo backfill job's result: each dinner it tried,
// in the order it tried them.
export type BackfillResult = { dinners: BackfillDinner[] }
// Every query showing a dinner's photo, read again once a backfill ends:
// the dinners lists and details, the plans and the home dashboard.
const PHOTO_KEYS = [['dinners'], ['plans'], HOME_KEY] as const
// parseBackfillResult checks a photo backfill job's result, which useJob
// passes on unread.
export function parseBackfillResult(where: string, body: unknown): BackfillResult {
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: result is not a photo backfill`)
return {
dinners: body.dinners.map((dinner): BackfillDinner => {
if (!isRecord(dinner) || typeof dinner.dinner_id !== 'string' || (dinner.reason !== null && typeof dinner.reason !== 'string')) {
throw new Error(`${where}: result has a dinner that is not a backfill outcome`)
}
return {
dinner_id: dinner.dinner_id,
outcome: oneOf(where, 'outcome', BACKFILL_OUTCOMES, dinner.outcome),
reason: dinner.reason,
}
}),
}
}
// PhotoBackfill is a photo backfill started from this screen, as it goes.
export type PhotoBackfill = {
// start asks the server for a backfill over every dinner without a
// photo. While one is already queued or running the server answers with
// that job, so starting never runs two at once.
start: () => void
// active is true from start until the job ends, except while a poll's
// failure leaves its progress unknown: starting again then asks the
// server for the job it is running and polls it anew.
active: boolean
// Why the backfill could not be started.
startError: Error | null
// The job's status, undefined until it is started and first polled.
status: JobStatus | undefined
// The job's result, once it has one.
result: BackfillResult | null
// The job's failure, a poll's failure, or a result that is not a backfill.
error: Error | null
}
// usePhotoBackfill starts a photo backfill (POST
// /api/v1/settings/photo-backfill, 202 {id}) and follows its job with
// useJob. When the job ends, every query showing a dinner's photo is read
// again, so dinners it grounded show their photo everywhere.
export function usePhotoBackfill(): PhotoBackfill {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
const [jobId, setJobId] = useState<string | null>(null)
const job = useJob<unknown>(BACKFILL_JOB_KIND, jobId)
const begin = useMutation({
mutationFn: async (): Promise<string> => {
const body = await apiFetch<unknown>(PHOTO_BACKFILL_PATH, { method: 'POST' })
if (!isRecord(body) || typeof body.id !== 'string') throw new Error(`POST ${PHOTO_BACKFILL_PATH}: response has no job id`)
return body.id
},
onSuccess: (id) => {
setJobId(id)
job.poll()
},
})
const ended = job.status === 'succeeded' || job.status === 'failed'
useEffect(() => {
if (!ended) return
for (const queryKey of PHOTO_KEYS) void queryClient.invalidateQueries({ queryKey })
}, [ended, jobId, queryClient])
const parsed = useMemo(() => {
if (job.result === null) return { result: null, error: null }
try {
return { result: parseBackfillResult(`GET /api/v1/jobs/${jobId}`, job.result), error: null }
} catch (error) {
return { result: null, error: error as Error }
}
}, [job.result, jobId])
return {
start: () => begin.mutate(),
active: begin.isPending || (jobId !== null && !ended && job.error === null),
startError: begin.error,
status: job.status,
result: parsed.result,
error: job.error ?? parsed.error,
}
}