From cd3771f9ec13a5c747afb7cc453861fa268196bb Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 12:48:39 -0400 Subject: [PATCH 1/2] Add the mobile swap sheet to the Draft board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a day tile puts it in the swapping state and opens a bottom sheet segmented "Quest log · N | Regulars · N". Choosing a dinner swaps the day optimistically with PUT /api/v1/plans/{weekStart}/days/{date}, landing the server's plan on success and rolling the day back with an ErrorBanner and Retry on failure. Dinners already this week are marked and not selectable. Maestro task f231813c-992e-4a3b-a1a4-868cbad47415. Co-Authored-By: Claude Opus 5.5 --- web/src/discover/QuestLogSheet.tsx | 2 +- web/src/plan/PlanPage.module.css | 42 ++++++- web/src/plan/PlanPage.test.tsx | 180 ++++++++++++++++++++++++++++- web/src/plan/PlanPage.tsx | 84 +++++++++++--- web/src/plan/SwapSheet.module.css | 57 +++++++++ web/src/plan/SwapSheet.tsx | 122 +++++++++++++++++++ web/src/plan/api.ts | 49 +++++++- web/src/plan/week.test.ts | 7 +- web/src/plan/week.ts | 7 ++ 9 files changed, 526 insertions(+), 24 deletions(-) create mode 100644 web/src/plan/SwapSheet.module.css create mode 100644 web/src/plan/SwapSheet.tsx diff --git a/web/src/discover/QuestLogSheet.tsx b/web/src/discover/QuestLogSheet.tsx index cafdfa7..9ccd8b6 100644 --- a/web/src/discover/QuestLogSheet.tsx +++ b/web/src/discover/QuestLogSheet.tsx @@ -20,7 +20,7 @@ export type QuestLogSheetProps = { // details is a Quest-log dinner's "difficulty · minutes" line, or null when // neither is known. -function details(dinner: QuestLogDinner): string | null { +export function details(dinner: QuestLogDinner): string | null { const parts = [ dinner.difficulty !== null && DIFFICULTY_LABEL[dinner.difficulty], dinner.minutes_total !== null && `${dinner.minutes_total} min`, diff --git a/web/src/plan/PlanPage.module.css b/web/src/plan/PlanPage.module.css index 3803b71..ea13869 100644 --- a/web/src/plan/PlanPage.module.css +++ b/web/src/plan/PlanPage.module.css @@ -95,22 +95,38 @@ .tile { box-sizing: border-box; display: flex; - flex-direction: column; - justify-content: space-between; - gap: 6px; min-height: 86px; - padding: 12px; border-radius: 22px; background: var(--color-surface); color: var(--color-text); } +/* The whole tile is the button that opens the swap sheet. */ +.tileButton { + box-sizing: border-box; + flex: 1; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: stretch; + gap: 6px; + padding: 12px; + border: 0; + border-radius: inherit; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + /* A Quest-log pick. */ .newTile { background: var(--color-accent-2-300); } .dayKicker { + display: block; margin: 0; font-family: var(--font-body); font-size: 10px; @@ -125,6 +141,7 @@ } .dinner { + display: block; margin: 0; font-family: var(--font-heading); font-size: 15px; @@ -134,6 +151,7 @@ } .startBy { + display: block; margin: 4px 0 0; font-family: var(--font-body); font-size: 11px; @@ -145,6 +163,22 @@ color: var(--color-accent-2-900); } +/* The day being swapped, while the swap sheet is open: the page background + ringed in grape, its name muted. */ +.swapping { + background: var(--color-bg); + box-shadow: inset 0 0 0 2px var(--color-accent); +} + +.swapping .dayKicker { + color: var(--color-accent-700); +} + +.swapping .dinner, +.swapping .startBy { + color: var(--color-neutral-600); +} + .rebuildCell { display: flex; } diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 1ecd898..6a6f84d 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -54,7 +54,7 @@ function draftJob(status: string, error: string | null = null, planId = 'plan-1' return { id: 'j1', kind: 'draft_week', status, result, error } } -type Responder = (init: RequestInit | undefined) => Response +type Responder = (init: RequestInit | undefined) => Response | Promise // stubServer answers "METHOD path" requests from responders, with /me and // the household settings; any other request fails the test. @@ -142,7 +142,7 @@ describe('PlanPage', () => { expect(declaredStyle(grid).gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))') const items = within(grid).getAllByRole('listitem') expect(items).toHaveLength(8) - expect(items.slice(0, 7).map((item) => item.firstChild!.textContent)).toEqual([ + expect(items.slice(0, 7).map((item) => item.querySelector('button')!.firstChild!.textContent)).toEqual([ 'MON', 'TUE', 'WED · NEW', @@ -421,4 +421,180 @@ describe('PlanPage', () => { expect(await screen.findByRole('alert', {}, { timeout: 3000 })).toHaveTextContent("Couldn't load this week's plan. internal message") }) + + describe('swap sheet', () => { + const QUEST_LOG = '/api/v1/dinners?status=quest_log' + const REGULARS = '/api/v1/dinners?status=regular' + const FRIDAY = `${THIS_WEEK}/days/2026-10-09` + + // dinner is a dinner as the dinners API lists it. + function dinner(id: string, name: string, difficulty: string | null, minutes: number | null) { + return { + id, + name, + method: 'stovetop', + minutes_total: minutes, + difficulty, + is_favorite: false, + photo_url: null, + grounding_status: 'grounded', + } + } + + // The Quest log has lemon orzo; the Regulars are Monday's tacos, already + // this week, and fish tacos. + const lists = { + [`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: [dinner('q1', 'Lemon orzo', 'easy', 25)] }), + [`GET ${REGULARS}`]: () => + jsonResponse(200, { dinners: [dinner('d0', 'Beef tacos', 'easy', 30), dinner('r1', 'Fish tacos', 'medium', 35)] }), + } + + // swappedBody is the plan as the server answers a swap of Friday to + // lemon orzo, a Quest-log pick. + function swappedBody() { + const body = planBody('draft') + body.days[4] = { ...body.days[4]!, dinner: { ...body.days[4]!.dinner, id: 'q1', name: 'Lemon orzo' }, is_new: true } + return body + } + + // puts lists the PUTs sent, as "path body". + function puts(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([, init]) => init?.method === 'PUT') + .map(([input, init]) => `${String(input)} ${String(init?.body)}`) + } + + async function openFriday() { + fireEvent.click(within(await waitFor(() => tile('FRI'))).getByRole('button')) + return screen.findByRole('dialog', { name: 'Swap Friday' }) + } + + it('opens on a tapped day, puts it in the swapping state, and closes again', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) + await renderPage() + + const sheet = await openFriday() + expect(within(sheet).getByText('Replaces Pizza night')).toBeInTheDocument() + + // The tile: page background, a 2 px grape inset ring, the SWAPPING… + // kicker and its name muted. + const friday = tile('FRI · SWAPPING…') + expect(declaredStyle(friday).background).toBe('var(--color-bg)') + expect(declaredStyle(friday).boxShadow).toBe('inset 0 0 0 2px var(--color-accent)') + expect(declaredStyle(within(friday).getByText('FRI · SWAPPING…')).color).toBe('var(--color-accent-700)') + expect(declaredStyle(within(friday).getByText('Pizza night')).color).toBe('var(--color-neutral-600)') + + // "Quest log · N | Regulars · N", on the Quest log first. + expect(await within(sheet).findByRole('radio', { name: 'Quest log · 1', checked: true })).toBeInTheDocument() + expect(await within(sheet).findByRole('radio', { name: 'Regulars · 2' })).toBeInTheDocument() + const row = within(within(sheet).getByRole('list', { name: 'Quest log' })).getByRole('button', { name: /Lemon orzo/ }) + expect(row).toHaveTextContent('Easy · 25 min') + const photo = within(sheet).getByRole('img', { hidden: true, name: 'Lemon orzo' }) + expect(declaredStyle(photo).width).toBe('48px') + + fireEvent.keyDown(sheet, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(tile('FRI')).toBeInTheDocument() + expect(screen.queryByText('FRI · SWAPPING…')).not.toBeInTheDocument() + }) + + it('swaps the day at once, closing the sheet, and lands the plan the server answers', async () => { + let answer!: (response: Response) => void + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...lists, + [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (answer = resolve)), + }) + await renderPage() + + const sheet = await openFriday() + fireEvent.click(await within(sheet).findByRole('button', { name: /Lemon orzo/ })) + + // Optimistic: the sheet is closed and Friday is the Quest-log pick, NEW. + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await screen.findByText('FRI · NEW') + expect(within(tile('FRI · NEW')).getByText('Lemon orzo')).toBeInTheDocument() + expect(declaredStyle(tile('FRI · NEW')).background).toBe('var(--color-accent-2-300)') + await waitFor(() => expect(puts(fetchMock)).toEqual([`${FRIDAY} {"dinner_id":"q1"}`])) + + await act(async () => answer(jsonResponse(200, swappedBody()))) + expect(within(tile('FRI · NEW')).getByText('Lemon orzo')).toBeInTheDocument() + expect(screen.queryByText('Pizza night')).not.toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + // Once the swap has settled the sheet opens again, and reads the Quest + // log again: the server leaves a dinner on the plan out of it. + await waitFor(() => { + fireEvent.click(within(tile('FRI · NEW')).getByRole('button')) + expect(screen.getByRole('dialog', { name: 'Swap Friday' })).toHaveTextContent('Replaces Lemon orzo') + }) + await waitFor(() => expect(fetchMock.mock.calls.filter(([input]) => String(input) === QUEST_LOG)).toHaveLength(2)) + }) + + it('takes the NEW highlight off a day swapped to a Regular', async () => { + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...lists, + [`PUT ${THIS_WEEK}/days/2026-10-07`]: () => { + const body = planBody('draft') + body.days[2] = { ...body.days[2]!, dinner: { ...body.days[2]!.dinner, id: 'r1', name: 'Fish tacos' }, is_new: false, start_by: null } + return jsonResponse(200, body) + }, + }) + await renderPage() + + fireEvent.click(within(await waitFor(() => tile('WED · NEW'))).getByRole('button')) + const sheet = await screen.findByRole('dialog', { name: 'Swap Wednesday' }) + fireEvent.click(await within(sheet).findByRole('radio', { name: 'Regulars · 2' })) + fireEvent.click(within(sheet).getByRole('button', { name: /Fish tacos/ })) + + await screen.findByText('Fish tacos') + expect(within(tile('WED')).getByText('Fish tacos')).toBeInTheDocument() + expect(declaredStyle(tile('WED')).background).toBe('var(--color-surface)') + await waitFor(() => expect(puts(fetchMock)).toEqual([`${THIS_WEEK}/days/2026-10-07 {"dinner_id":"r1"}`])) + expect(screen.queryByText('Start by 1:30 pm')).not.toBeInTheDocument() + }) + + it('puts the day back and shows the failure, with Retry, when the swap is refused', async () => { + let refuse = true + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...lists, + [`PUT ${FRIDAY}`]: () => (refuse ? errorResponse(409, 'dinner_not_plannable') : jsonResponse(200, swappedBody())), + }) + await renderPage() + + const sheet = await openFriday() + fireEvent.click(await within(sheet).findByRole('button', { name: /Lemon orzo/ })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message") + expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument() + expect(screen.queryByText('Lemon orzo')).not.toBeInTheDocument() + + refuse = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Lemon orzo')).toBeInTheDocument() + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + expect(puts(fetchMock)).toHaveLength(2) + }) + + it('marks dinners already this week, which cannot be chosen', async () => { + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) + await renderPage() + + const sheet = await openFriday() + fireEvent.click(await within(sheet).findByRole('radio', { name: 'Regulars · 2' })) + const regulars = within(sheet).getByRole('list', { name: 'Regulars' }) + const [tacos, fish] = within(regulars).getAllByRole('listitem') + expect(within(tacos!).getByText('Already this week')).toBeInTheDocument() + expect(within(tacos!).queryByRole('button')).not.toBeInTheDocument() + expect(within(fish!).queryByText('Already this week')).not.toBeInTheDocument() + expect(within(fish!).getByRole('button', { name: /Fish tacos/ })).toBeInTheDocument() + + fireEvent.click(within(tacos!).getByText('Beef tacos')) + expect(screen.getByRole('dialog', { name: 'Swap Friday' })).toBeInTheDocument() + expect(puts(fetchMock)).toEqual([]) + }) + }) }) diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 10d8473..1d4a444 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -11,8 +11,20 @@ import { Icon } from '../ui/Icon.tsx' import { IconButton } from '../ui/IconButton.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { Tag } from '../ui/Tag.tsx' -import { DRAFT_JOB_KIND, parseDraftResult, planKey, usePlan, useStartDraft, type DraftResult, type PlanDay, type PlanStatus } from './api.ts' -import { addDays, defaultWeek, localNow, weekdayKicker, weekLabel } from './week.ts' +import { + DRAFT_JOB_KIND, + parseDraftResult, + planKey, + usePlan, + useStartDraft, + useSwapDay, + type DraftResult, + type PlanDay, + type PlanStatus, + type Swap, +} from './api.ts' +import { SwapSheet } from './SwapSheet.tsx' +import { addDays, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel } from './week.ts' import styles from './PlanPage.module.css' // statusTags is the tag beside the week's title for each plan status. @@ -44,18 +56,23 @@ function pushedToCalendar(status: PlanStatus): boolean { // DayTile is one day of the Draft board: a Regular on the surface, a // Quest-log pick in teal with its "NEW" kicker, and a crock-pot dinner's -// start-by time in the household's zone. -function DayTile({ day, timezone }: { day: PlanDay; timezone: string }) { +// start-by time in the household's zone. Tapping it opens the swap sheet; +// while that is open the tile is swapping: on the page background, ringed in +// grape, its name muted. +function DayTile({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) { const kicker = weekdayKicker(day.date) + const classes = [styles.tile, day.is_new && styles.newTile, swapping && styles.swapping].filter(Boolean).join(' ') return ( -
  • -

    {day.is_new ? `${kicker} · NEW` : kicker}

    -
    -

    {day.dinner.name}

    - {day.start_by !== null && ( -

    Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}

    - )} -
    +
  • +
  • ) } @@ -87,6 +104,9 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon const [jobId, setJobId] = useState(null) const job = useJob(DRAFT_JOB_KIND, jobId) const [confirming, setConfirming] = useState(false) + const swap = useSwapDay(weekStart) + // The date of the day being swapped, while the swap sheet is open. + const [swappingDate, setSwappingDate] = useState(null) const label = weekLabel(weekStart) // The draft's result, once the job has succeeded, or why it cannot be read. @@ -143,8 +163,10 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon const { mutate } = startDraft function start(confirm: boolean) { setConfirming(false) - // A draft asked for again leaves the last one's failure behind. + // A draft asked for again leaves the last one's failure, and a failed + // swap's, behind. setJobId(null) + swap.reset() mutate( { weekStart, confirm }, { @@ -160,7 +182,11 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon else start(false) } - const failure = startDraft.isError ? ( + const failure = swap.isError ? ( + swapIn(swap.variables)}> + Couldn't swap {weekdayName(swap.variables.date)}'s dinner. {swap.error.message} + + ) : startDraft.isError ? ( Couldn't start drafting this week. {startDraft.error.message} ) : result.error !== null ? ( Couldn't read the draft. {result.error.message} @@ -174,6 +200,19 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon Couldn't check on the draft. {job.error.message} ) + // swapIn sends the swap chosen in the sheet and closes it. + function swapIn(chosen: Swap) { + setSwappingDate(null) + swap.mutate(chosen) + } + // openSwap opens the swap sheet on a day. One swap is sent at a time: the + // plan the server answers it with must not overwrite another still pending. + function openSwap(date: string) { + if (swap.isPending) return + swap.reset() + setSwappingDate(date) + } + let body if (plan.isError) { body = void plan.refetch()}>Couldn't load this week's plan. {plan.error.message} @@ -192,6 +231,7 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon ) } else { const { ai_note, days } = plan.data + const swappingDay = days.find((day) => day.date === swappingDate) body = ( <> {ai_note !== null && ( @@ -204,7 +244,13 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon )}
      {days.map((day) => ( - + openSwap(day.date)} + /> ))}
    + {swappingDay !== undefined && ( + day.dinner.id))} + onSwap={swapIn} + onClose={() => setSwappingDate(null)} + /> + )} ) } diff --git a/web/src/plan/SwapSheet.module.css b/web/src/plan/SwapSheet.module.css new file mode 100644 index 0000000..5df5b8c --- /dev/null +++ b/web/src/plan/SwapSheet.module.css @@ -0,0 +1,57 @@ +/* The mobile swap sheet: the Quest log and Regulars of desktop's plan panel + (mock 2d) as a bottom sheet. */ +.sheet { + display: flex; + flex-direction: column; + gap: 14px; + max-width: 560px; + margin: 0 auto; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.replaces { + margin: 2px 0 0; + font-family: var(--font-body); + font-size: 13px; + font-weight: 600; + color: var(--color-neutral-700); +} + +.rows { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +/* The 48 px washed photo; Photo applies the washed filter. */ +.photo { + width: 48px; + height: 48px; +} + +/* A dinner already this week: shown, muted, and not a button. */ +.already { + color: var(--color-neutral-600); +} + +.skeletonRow { + height: 72px; + border-radius: var(--radius-md); +} + +.empty { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; + color: var(--color-neutral-700); +} diff --git a/web/src/plan/SwapSheet.tsx b/web/src/plan/SwapSheet.tsx new file mode 100644 index 0000000..b2e0e49 --- /dev/null +++ b/web/src/plan/SwapSheet.tsx @@ -0,0 +1,122 @@ +import { useId, useState } from 'react' +import { useRegulars } from '../dinners/api.ts' +import { useQuestLog, type QuestLogDinner } from '../discover/api.ts' +import { details } from '../discover/QuestLogSheet.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { ListRow } from '../ui/ListRow.tsx' +import { Photo } from '../ui/Photo.tsx' +import { Segmented } from '../ui/Segmented.tsx' +import { Sheet } from '../ui/Sheet.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { Tag } from '../ui/Tag.tsx' +import type { PlanDay, Swap } from './api.ts' +import { weekdayName } from './week.ts' +import styles from './SwapSheet.module.css' + +type Source = 'quest_log' | 'regulars' + +const sourceNames: Record = { quest_log: 'Quest log', regulars: 'Regulars' } + +const emptyLists: Record = { + quest_log: 'Your Quest log is empty — swipe right on a Side quest to add one.', + regulars: 'You have no Regulars yet.', +} + +export type SwapSheetProps = { + // The day being swapped. + day: PlanDay + // The ids of the dinners already on the week's plan. + planned: ReadonlySet + onSwap: (swap: Swap) => void + onClose: () => void +} + +// SwapSheet is the mobile swap sheet: the Quest log and the Regulars, as +// desktop's plan panel lists them, segmented "Quest log · N | Regulars · N", +// each dinner with its 48 px photo, name, difficulty and minutes. Choosing a +// dinner swaps it into the day; a dinner already this week can't be chosen. +export function SwapSheet({ day, planned, onSwap, onClose }: SwapSheetProps) { + const titleId = useId() + const questLog = useQuestLog() + const regulars = useRegulars() + const [source, setSource] = useState('quest_log') + const lists = { quest_log: questLog, regulars } + const list = lists[source] + + const label = (s: Source) => { + const dinners = lists[s].data + return dinners === undefined ? sourceNames[s] : `${sourceNames[s]} · ${dinners.length}` + } + + let body + if (list.isError) { + body = ( + void list.refetch()}> + Couldn't load your {sourceNames[source]}. {list.error.message} + + ) + } else if (list.isPending) { + body = ( +
    + {Array.from({ length: 3 }, (_, i) => ( + + ))} +
    + ) + } else if (list.data.length === 0) { + body =

    {emptyLists[source]}

    + } else { + body = ( +
      + {list.data.map((dinner: QuestLogDinner) => { + const already = planned.has(dinner.id) + return ( +
    • +
    • + ) + })} +
    + ) + } + + return ( + +
    +
    +

    + Swap {weekdayName(day.date)} +

    +

    Replaces {day.dinner.name}

    +
    + + {body} +
    +
    + ) +} diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index 17b4539..bded587 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -1,7 +1,8 @@ -import { useMutation, useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ApiError, useApi, type ApiRead } from '../api/index.ts' import { isRecord } from '../api/errors.ts' import { oneOf } from '../dinners/api.ts' +import { QUEST_LOG_KEY } from '../discover/api.ts' export const PLANS_PATH = '/api/v1/plans' @@ -130,3 +131,49 @@ export function useStartDraft() { }, }) } + +// Swap is one day's swap: the day, and the dinner chosen for it with whether +// it was chosen from the Quest log. +export type Swap = { date: string; dinner: { id: string; name: string }; fromQuestLog: boolean } + +// useSwapDay swaps the dinner of one day of the week starting on weekStart +// (PUT /api/v1/plans/{weekStart}/days/{date}). The day shows the dinner at +// once, NEW when it came from the Quest log, with no start-by time until the +// server answers with the plan as swapped, which then replaces it. A refused +// swap puts the day back as it was, unless it has changed again meanwhile. +// However the swap ends, the Quest log is read again: the server leaves a +// dinner on the plan out of it. +export function useSwapDay(weekStart: string) { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + const key = planKey(weekStart) + return useMutation({ + mutationFn: async ({ date, dinner }: Swap): Promise => { + const path = `${planPath(weekStart)}/days/${encodeURIComponent(date)}` + const body = await apiFetch(path, { method: 'PUT', body: { dinner_id: dinner.id } }) + return parsePlan(`PUT ${path}`, weekStart, body, false) + }, + onMutate: async ({ date, dinner, fromQuestLog }) => { + await queryClient.cancelQueries({ queryKey: key }) + const plan = queryClient.getQueryData(key) + const previous = plan?.days.find((day) => day.date === date) + if (!plan || previous === undefined) throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`) + const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null } + queryClient.setQueryData(key, { ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) }) + return { previous, swapped } + }, + onSuccess: (plan) => { + queryClient.setQueryData(key, plan) + }, + onError: (_error, { date }, context) => { + if (context === undefined) return + queryClient.setQueryData(key, (plan) => + plan && { + ...plan, + days: plan.days.map((day) => (day.date === date && day.dinner.id === context.swapped.dinner.id ? context.previous : day)), + }, + ) + }, + onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }), + }) +} diff --git a/web/src/plan/week.test.ts b/web/src/plan/week.test.ts index 211d211..0854764 100644 --- a/web/src/plan/week.test.ts +++ b/web/src/plan/week.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { addDays, defaultWeek, localNow, weekdayKicker, weekLabel, weekStart } from './week.ts' +import { addDays, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts' // SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York. const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' } @@ -31,6 +31,11 @@ describe('week helpers', () => { expect(weekdayKicker('2026-10-11')).toBe('SUN') }) + it('names the day of a date in full', () => { + expect(weekdayName('2026-10-09')).toBe('Friday') + expect(weekdayName('2026-10-11')).toBe('Sunday') + }) + it('refuses a week start that is not a Monday, and a date that is not real', () => { expect(() => weekLabel('2026-10-06')).toThrow('week start 2026-10-06 is not a Monday') expect(() => addDays('2026-02-30', 1)).toThrow('not a real date') diff --git a/web/src/plan/week.ts b/web/src/plan/week.ts index d0ecb54..7241135 100644 --- a/web/src/plan/week.ts +++ b/web/src/plan/week.ts @@ -40,6 +40,13 @@ export function weekdayKicker(date: string): string { return WEEKDAYS[midnightUTC(date).getUTCDay()]! } +const weekday = new Intl.DateTimeFormat('en-US', { weekday: 'long', timeZone: 'UTC' }) + +// weekdayName is date's day in full: "Monday" to "Sunday". +export function weekdayName(date: string): string { + return weekday.format(midnightUTC(date)) +} + // monday reads start as midnight UTC, or throws when it is not a Monday. function monday(start: string): Date { const t = midnightUTC(start) -- 2.55.0 From 2164e24d9dcb87c8b694634e4577b92717e61583 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 13:13:12 -0400 Subject: [PATCH 2/2] Keep a week's swaps in the mutation cache, across remounts The Draft board's swap guard was its mounted useMutation observer, so leaving the week and coming back let a second swap start while the first PUT was still pending, and Rebuild week's reset() detached a pending swap, hiding its failure. The week's swaps now live in the mutation cache, keyed by week: the guard, the failure shown and the success handler all read them from there. Only the latest swap of the week writes the plan it is answered with, and only its failure is the week's. Rebuild week waits for a pending swap (its tile is disabled meanwhile), and dismissing settled swaps is refused while one is pending. A failed swap puts its day back only while the day is still, field for field, the day it wrote, so a newer plan read survives. Maestro task f231813c-992e-4a3b-a1a4-868cbad47415. Co-Authored-By: Claude Opus 5.5 --- web/src/plan/PlanPage.module.css | 7 +- web/src/plan/PlanPage.test.tsx | 83 ++++++++++++++++ web/src/plan/PlanPage.tsx | 22 +++-- web/src/plan/api.test.tsx | 165 +++++++++++++++++++++++++++++++ web/src/plan/api.ts | 124 +++++++++++++++++------ 5 files changed, 361 insertions(+), 40 deletions(-) create mode 100644 web/src/plan/api.test.tsx diff --git a/web/src/plan/PlanPage.module.css b/web/src/plan/PlanPage.module.css index ea13869..8080ba8 100644 --- a/web/src/plan/PlanPage.module.css +++ b/web/src/plan/PlanPage.module.css @@ -202,10 +202,15 @@ cursor: pointer; } -.rebuild:hover { +.rebuild:hover:not(:disabled) { background: var(--color-neutral-100); } +.rebuild:disabled { + opacity: 0.45; + cursor: not-allowed; +} + .loading { display: flex; flex-direction: column; diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 6a6f84d..d583bc8 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -596,5 +596,88 @@ describe('PlanPage', () => { expect(screen.getByRole('dialog', { name: 'Swap Friday' })).toBeInTheDocument() expect(puts(fetchMock)).toEqual([]) }) + + // leaveAndReturn goes to next week and back, remounting this week's board. + async function leaveAndReturn() { + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + await screen.findByText('FRI · NEW') + } + + it('sends one swap at a time even after leaving the week and coming back', async () => { + let answer!: (response: Response) => void + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + ...lists, + [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (answer = resolve)), + }) + await renderPage() + + fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ })) + await waitFor(() => expect(puts(fetchMock)).toHaveLength(1)) + await leaveAndReturn() + + // Friday's swap is still pending: Monday does not open. + fireEvent.click(within(tile('MON')).getByRole('button')) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled() + + await act(async () => answer(jsonResponse(200, swappedBody()))) + await waitFor(() => expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeEnabled()) + fireEvent.click(within(tile('MON')).getByRole('button')) + expect(screen.getByRole('dialog', { name: 'Swap Monday' })).toBeInTheDocument() + expect(puts(fetchMock)).toHaveLength(1) + }) + + it('shows the failure of a swap that ended while the week was left', async () => { + let refuse!: () => void + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + ...lists, + [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (refuse = () => resolve(errorResponse(409, 'dinner_not_plannable')))), + }) + await renderPage() + + fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ })) + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + await act(async () => refuse()) + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message") + expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument() + }) + + it('waits for a pending swap before Rebuild week, keeping its failure', async () => { + let refuse!: () => void + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...lists, + [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (refuse = () => resolve(errorResponse(409, 'dinner_not_plannable')))), + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, draftJob('running')), + }) + await renderPage() + + fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ })) + await screen.findByText('FRI · NEW') + const rebuild = screen.getByRole('button', { name: 'Rebuild week' }) + expect(rebuild).toBeDisabled() + fireEvent.click(rebuild) + expect(posts(fetchMock)).toEqual([]) + + await act(async () => refuse()) + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message") + expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument() + + // Once it has failed, Rebuild week drafts, leaving the failure behind. + fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' })) + await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])) + expect(await screen.findByText('Drafting your week…')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) }) }) diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 1d4a444..19519fc 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -166,7 +166,7 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon // A draft asked for again leaves the last one's failure, and a failed // swap's, behind. setJobId(null) - swap.reset() + swap.dismiss() mutate( { weekStart, confirm }, { @@ -177,14 +177,17 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon ) } // rebuild drafts the week, asking first over a plan pushed to Calendar. + // It waits for a swap of the week still pending, which may yet fail: the + // Rebuild week tile is disabled meanwhile. function rebuild() { + if (swap.pending) return if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true) else start(false) } - const failure = swap.isError ? ( - swapIn(swap.variables)}> - Couldn't swap {weekdayName(swap.variables.date)}'s dinner. {swap.error.message} + const failure = swap.failed !== null ? ( + swapIn(swap.failed!.swap)}> + Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message} ) : startDraft.isError ? ( Couldn't start drafting this week. {startDraft.error.message} @@ -205,11 +208,12 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon setSwappingDate(null) swap.mutate(chosen) } - // openSwap opens the swap sheet on a day. One swap is sent at a time: the - // plan the server answers it with must not overwrite another still pending. + // openSwap opens the swap sheet on a day. One swap of the week is sent at + // a time, even across leaving the week and coming back: the plan the + // server answers it with must not overwrite another still pending. function openSwap(date: string) { - if (swap.isPending) return - swap.reset() + if (swap.pending) return + swap.dismiss() setSwappingDate(date) } @@ -253,7 +257,7 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon /> ))}
  • - diff --git a/web/src/plan/api.test.tsx b/web/src/plan/api.test.tsx new file mode 100644 index 0000000..87b3e57 --- /dev/null +++ b/web/src/plan/api.test.tsx @@ -0,0 +1,165 @@ +import { QueryClientProvider, type QueryClient } 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 { createNetworkStatus } from '../pwa/networkStatus.ts' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { planKey, usePlan, useSwapDay, type Plan } from './api.ts' + +const WEEK = '2026-10-05' +const PLAN = `/api/v1/plans/${WEEK}` +const MONDAY = `${PLAN}/days/2026-10-05` +const FRIDAY = `${PLAN}/days/2026-10-09` +const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11'] + +// planBody is the plan of the week of Oct 5, dinners d0 to d6, with days +// changed as given by index. +function planBody(changes: Record = {}) { + return { + id: 'plan-1', + week_start: WEEK, + status: 'draft', + ai_note: null, + days: DAYS.map((date, i) => { + const change = changes[i] + return { + date, + dinner: { id: change?.id ?? `d${i}`, name: change?.name ?? `Dinner ${i}` }, + is_new: change?.is_new ?? false, + start_by: change?.start_by ?? null, + } + }), + } +} + +// deferred is a reply the test sends when it chooses. +function deferred() { + let send!: (response: Response) => void + const reply = new Promise((resolve) => (send = resolve)) + return { reply, send } +} + +// stubServer answers "METHOD path" requests from responders, with /me and +// the Quest log; any other request fails the test. +function stubServer(responders: Record Response | Promise>) { + const all: Record Response | Promise> = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + 'GET /api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }), + ...responders, + } + vi.stubGlobal( + 'fetch', + vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + return Promise.resolve(respond()) + }), + ) +} + +// renderSwaps renders the week's plan and swaps over a query client. +async function renderSwaps() { + const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const queryClient: QueryClient = createQueryClient() + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + const hook = renderHook(() => ({ plan: usePlan(WEEK), swap: useSwapDay(WEEK) }), { wrapper }) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + return { ...hook, queryClient, plan: () => queryClient.getQueryData(planKey(WEEK))! } +} + +const ORZO = { date: '2026-10-09', dinner: { id: 'q1', name: 'Lemon orzo' }, fromQuestLog: true } +const FISH = { date: '2026-10-05', dinner: { id: 'r1', name: 'Fish tacos' }, fromQuestLog: false } + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('useSwapDay', () => { + // Friday's swap is sent first, then Monday's; the server answers Monday's + // with both swapped, Friday's with only Friday. + const fridayPlan = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true } }) + const bothPlan = planBody({ 0: { id: 'r1', name: 'Fish tacos' }, 4: { id: 'q1', name: 'Lemon orzo', is_new: true } }) + + it.each([ + ['the later swap answered first', ['monday', 'friday'] as const], + ['the earlier swap answered first', ['friday', 'monday'] as const], + ])('keeps the plan of the later of two swaps, %s', async (_, order) => { + const replies = { friday: deferred(), monday: deferred() } + stubServer({ + [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), + [`PUT ${FRIDAY}`]: () => replies.friday.reply, + [`PUT ${MONDAY}`]: () => replies.monday.reply, + }) + const { result, plan, queryClient } = await renderSwaps() + + act(() => result.current.swap.mutate(ORZO)) + act(() => result.current.swap.mutate(FISH)) + await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos')) + + // Whichever reply settles, Monday stays swapped: Friday's plan, the + // earlier one, never lands over it. + for (const [settled, day] of order.entries()) { + await act(async () => replies[day].send(jsonResponse(200, day === 'friday' ? fridayPlan : bothPlan))) + await waitFor(() => expect(queryClient.isMutating()).toBe(1 - settled)) + expect(plan().days[0]!.dinner.name).toBe('Fish tacos') + } + expect(plan().days.map((day) => day.dinner.name)).toEqual(bothPlan.days.map((day) => day.dinner.name)) + }) + + it("does not take an earlier swap's failure for the week's", async () => { + const friday = deferred() + stubServer({ + [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), + [`PUT ${FRIDAY}`]: () => friday.reply, + [`PUT ${MONDAY}`]: () => jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })), + }) + const { result, plan } = await renderSwaps() + + act(() => result.current.swap.mutate(ORZO)) + act(() => result.current.swap.mutate(FISH)) + await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos')) + await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable'))) + + await waitFor(() => expect(result.current.swap.pending).toBe(false)) + expect(result.current.swap.failed).toBeNull() + }) + + it('leaves a day a later plan read landed when the swap of it fails', async () => { + const friday = deferred() + // The read finds Friday already lemon orzo, starting by 2 pm. + let read = planBody() + stubServer({ + [`GET ${PLAN}`]: () => jsonResponse(200, read), + [`PUT ${FRIDAY}`]: () => friday.reply, + }) + const { result, plan, queryClient } = await renderSwaps() + + act(() => result.current.swap.mutate(ORZO)) + await waitFor(() => expect(plan().days[4]).toEqual({ date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: null })) + read = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true, start_by: '2026-10-09T14:00:00-04:00' } }) + await act(() => result.current.plan.refetch()) + await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable'))) + + await waitFor(() => expect(queryClient.isMutating()).toBe(0)) + expect(plan().days[4]).toEqual({ date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: '2026-10-09T14:00:00-04:00' }) + expect(result.current.swap.failed?.error.message).toBe('dinner_not_plannable message') + }) + + it('refuses to dismiss a pending swap', async () => { + const friday = deferred() + stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), [`PUT ${FRIDAY}`]: () => friday.reply }) + const { result } = await renderSwaps() + + act(() => result.current.swap.mutate(ORZO)) + await waitFor(() => expect(result.current.swap.pending).toBe(true)) + expect(() => result.current.swap.dismiss()).toThrow('The week of 2026-10-05 has a swap pending, which cannot be dismissed.') + await act(async () => friday.send(jsonResponse(200, planBody()))) + }) +}) diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index bded587..4d14cb4 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query' import { ApiError, useApi, type ApiRead } from '../api/index.ts' import { isRecord } from '../api/errors.ts' import { oneOf } from '../dinners/api.ts' @@ -136,6 +136,28 @@ export function useStartDraft() { // it was chosen from the Quest log. export type Swap = { date: string; dinner: { id: string; name: string }; fromQuestLog: boolean } +// sameDay says whether two plan days are the same in every field. +function sameDay(a: PlanDay, b: PlanDay): boolean { + return ( + a.date === b.date && a.dinner.id === b.dinner.id && a.dinner.name === b.dinner.name && a.is_new === b.is_new && a.start_by === b.start_by + ) +} + +// swapKey keys the swaps of the week starting on weekStart in the mutation +// cache, where they outlive the Draft board that sent them. +function swapKey(weekStart: string) { + return ['plans', weekStart, 'swap'] as const +} + +// SwapDay is the swaps of one week, as the mutation cache holds them: +// whether one is pending, and the failure of the latest, if it failed. +export type SwapDay = { + pending: boolean + failed: { swap: Swap; error: Error } | null + mutate: (chosen: Swap) => void + dismiss: () => void +} + // useSwapDay swaps the dinner of one day of the week starting on weekStart // (PUT /api/v1/plans/{weekStart}/days/{date}). The day shows the dinner at // once, NEW when it came from the Quest log, with no start-by time until the @@ -143,37 +165,79 @@ export type Swap = { date: string; dinner: { id: string; name: string }; fromQue // swap puts the day back as it was, unless it has changed again meanwhile. // However the swap ends, the Quest log is read again: the server leaves a // dinner on the plan out of it. -export function useSwapDay(weekStart: string) { +// +// The week's swaps live in the mutation cache, not in the component, so +// leaving the week and coming back keeps them: pending and failed are read +// from there. Only the latest swap of the week speaks for it: the plan an +// earlier one is answered with is not written over a later one, and its +// failure is not the week's. dismiss leaves settled swaps behind; it is +// refused while one is pending, so a later swap is always in the cache for +// an earlier, pending one to see. +export function useSwapDay(weekStart: string): SwapDay { const { apiFetch } = useApi() const queryClient = useQueryClient() const key = planKey(weekStart) - return useMutation({ - mutationFn: async ({ date, dinner }: Swap): Promise => { - const path = `${planPath(weekStart)}/days/${encodeURIComponent(date)}` - const body = await apiFetch(path, { method: 'PUT', body: { dinner_id: dinner.id } }) - return parsePlan(`PUT ${path}`, weekStart, body, false) - }, - onMutate: async ({ date, dinner, fromQuestLog }) => { - await queryClient.cancelQueries({ queryKey: key }) - const plan = queryClient.getQueryData(key) - const previous = plan?.days.find((day) => day.date === date) - if (!plan || previous === undefined) throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`) - const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null } - queryClient.setQueryData(key, { ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) }) - return { previous, swapped } - }, - onSuccess: (plan) => { - queryClient.setQueryData(key, plan) - }, - onError: (_error, { date }, context) => { - if (context === undefined) return - queryClient.setQueryData(key, (plan) => - plan && { - ...plan, - days: plan.days.map((day) => (day.date === date && day.dinner.id === context.swapped.dinner.id ? context.previous : day)), - }, - ) - }, - onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }), + const mutationKey = swapKey(weekStart) + const mutations = queryClient.getMutationCache() + // The week's swaps, in the order sent. + const swaps = useMutationState({ + filters: { mutationKey }, + select: (mutation) => ({ status: mutation.state.status, swap: mutation.state.variables as Swap, error: mutation.state.error }), }) + const latest = swaps.at(-1) + + function mutate(chosen: Swap) { + // isLatest says whether no swap of the week was sent after this one. + const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId) + const mutation = mutations.build(queryClient, { + mutationKey, + // Kept until dismissed: a later swap must stay in the cache while an + // earlier one is pending. + gcTime: Infinity, + mutationFn: async ({ date, dinner }) => { + const path = `${planPath(weekStart)}/days/${encodeURIComponent(date)}` + const body = await apiFetch(path, { method: 'PUT', body: { dinner_id: dinner.id } }) + return parsePlan(`PUT ${path}`, weekStart, body, false) + }, + onMutate: async ({ date, dinner, fromQuestLog }) => { + await queryClient.cancelQueries({ queryKey: key }) + const plan = queryClient.getQueryData(key) + const previous = plan?.days.find((day) => day.date === date) + if (!plan || previous === undefined) throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`) + const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null } + queryClient.setQueryData(key, { ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) }) + return { previous, swapped } + }, + onSuccess: (plan) => { + if (isLatest()) queryClient.setQueryData(key, plan) + }, + // The day goes back only while it is still, field for field, the day + // this swap wrote: a later swap or plan read that replaced it is left + // alone. + onError: (_error, _swap, context) => { + if (context === undefined) return + queryClient.setQueryData(key, (plan) => + plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) }, + ) + }, + onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }), + }) + // A failure is the mutation's state, which failed reports. + mutation.execute(chosen).catch(() => undefined) + } + + function dismiss() { + const week = mutations.findAll({ mutationKey }) + if (week.some((mutation) => mutation.state.status === 'pending')) { + throw new Error(`The week of ${weekStart} has a swap pending, which cannot be dismissed.`) + } + for (const mutation of week) mutations.remove(mutation) + } + + return { + pending: swaps.some((swap) => swap.status === 'pending'), + failed: latest?.status === 'error' ? { swap: latest.swap, error: latest.error! } : null, + mutate, + dismiss, + } } -- 2.55.0