From 9616941b7310a0b7601de1530a8241c071169085 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 13:36:13 -0400 Subject: [PATCH 1/4] Report the Supply run outcome after Push to Calendar The push client parsed only the plan from POST /plans/{weekStart}/push-calendar and dropped supply_run, so a Supply run that was not made was shown as a successful push (F17-01). Parse supply_run as the server sends it and tell the household every outcome on the Plan: made, left as it was in the Supply archive, or not made, with its recovery: push again for plan_changed (reading the edited plan again first), or make the shopping list again through the existing generate endpoint for supply_run_failed. A push whose Supply run failed stays in the mutation cache like a failed push, so leaving the week before it settles still shows it on coming back. Read the week's Supply run again when the push answers, using the Supply run screen's supplyRunKey, so a cached 404 or the earlier list is not shown for the freshness window (F17-02). Maestro task e75db0f0-817f-4eef-84b2-266e793c018a. Co-Authored-By: Claude Opus 5.5 --- web/src/plan/PlanPage.module.css | 10 +++ web/src/plan/PlanPage.test.tsx | 128 ++++++++++++++++++++++++++++++- web/src/plan/PlanPage.tsx | 42 +++++++++- web/src/plan/api.test.tsx | 56 +++++++++++++- web/src/plan/api.ts | 87 +++++++++++++++++++-- 5 files changed, 309 insertions(+), 14 deletions(-) diff --git a/web/src/plan/PlanPage.module.css b/web/src/plan/PlanPage.module.css index 9cac0e3..86298fd 100644 --- a/web/src/plan/PlanPage.module.css +++ b/web/src/plan/PlanPage.module.css @@ -414,6 +414,16 @@ text-align: center; } +/* What became of the week's Supply run after a push. */ +.pushed { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + text-align: center; + color: var(--color-neutral-700); +} + /* The way to the week's Supply run under the Draft board on mobile. */ .shoppingLink { display: inline-flex; diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 6f4ec8c..c26a70d 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -52,9 +52,33 @@ function planBody(status: string, names = NAMES) { } // syncedNow is the plan of the week of Oct 5 as a push answers it: synced -// at the tests' noon. -function syncedNow() { - return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z' } +// at the tests' noon, with what became of the week's Supply run, made +// unless told otherwise. +function syncedNow(supplyRun: { status: string; error: { code: string; message: string; retryable: boolean } | null } = GENERATED) { + return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z', supply_run: supplyRun } +} + +// The Supply run outcomes a push answers with, as the server sends them. +const GENERATED = { status: 'generated', error: null } +const ARCHIVED = { + status: 'archived', + error: { code: 'archived', message: 'the Supply run is in the Supply archive', retryable: false }, +} +const PLAN_CHANGED = { + status: 'failed', + error: { + code: 'plan_changed', + message: 'the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again', + retryable: true, + }, +} +const SUPPLY_RUN_FAILED = { + status: 'failed', + error: { + code: 'supply_run_failed', + message: 'the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run', + retryable: true, + }, } // deferred is a response the test sends when it chooses. @@ -420,6 +444,104 @@ describe('PlanPage', () => { expect(planReads(fetchMock)).toBe(2) }) + it("reads the week's Supply run again once a push has made it, and says it was made", async () => { + let run = () => errorResponse(404, 'not_found') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => { + run = () => jsonResponse(200, supplyRun(28)) + return jsonResponse(200, syncedNow(GENERATED)) + }, + 'GET /api/v1/shopping/2026-10-05': () => run(), + }) + await renderPage() + + expect(await screen.findByRole('link', { name: 'Shopping list' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") + // The list made is read at once, not after the cached 404 goes stale. + expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument() + expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/shopping/2026-10-05')).toHaveLength(2) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('says a Supply run in the Supply archive was left as it was', async () => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(ARCHIVED)), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent("This week's Supply run is in the Supply archive, so it was left as it was.") + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('says no Supply run was made when the plan changed after it was pushed, reads the plan again, and pushes again on Retry', async () => { + let plan = planBody('draft') + let push = () => { + plan = planBody('changed', ['Lasagna', ...NAMES.slice(1)]) + return jsonResponse(200, syncedNow(PLAN_CHANGED)) + } + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), + [`POST ${PUSH}`]: () => push(), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent( + 'Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push it again.', + ) + expect(screen.queryByRole('status')).not.toBeInTheDocument() + // The plan edited meanwhile is the one shown, not the one pushed. + expect(await screen.findByText('Lasagna')).toBeInTheDocument() + expect(screen.getByText('Changes not pushed to Google Calendar')).toBeInTheDocument() + expect(planReads(fetchMock)).toBe(2) + + push = () => jsonResponse(200, syncedNow(GENERATED)) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) + }) + + it('says the Supply run could not be made after the push, and makes the shopping list again', async () => { + let generate = () => errorResponse(500, 'internal') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), + 'POST /api/v1/shopping/2026-10-05/generate': () => generate(), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + // Calendar has the plan, but the push is not reported a quiet success. + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") + expect(screen.queryByRole('status')).not.toBeInTheDocument() + + fireEvent.click(within(alert).getByRole('button', { name: 'Make the shopping list' })) + expect( + await screen.findByText("Pushed to Calendar, but this week's Supply run couldn't be made. Couldn't make the shopping list. internal message"), + ).toBeInTheDocument() + + generate = () => jsonResponse(200, { ...supplyRun(28), skipped: [] }) + fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' })) + expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument() + expect(posts(fetchMock)).toEqual([ + `${PUSH} undefined`, + '/api/v1/shopping/2026-10-05/generate undefined', + '/api/v1/shopping/2026-10-05/generate undefined', + ]) + }) + it('pushes nothing again until the changed plan is read and shown', async () => { let read: () => Response | Promise = () => jsonResponse(200, planBody('draft')) let push = () => errorResponse(409, 'plan_changed') diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 8f97f99..0bbc291 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -7,7 +7,7 @@ import { QUEST_LOG_KEY } from '../discover/api.ts' import { Confirm } from '../settings/LeaveGuard.tsx' import { methodLabels } from '../settings/RegularsSettingsPage.tsx' import { formatClockTime, useHouseholdSettings } from '../settings/api.ts' -import { supplyRunScreen, useSupplyRun } from '../shopping/api.ts' +import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts' import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts' import { Button, type ButtonVariant } from '../ui/Button.tsx' import buttonStyles from '../ui/Button.module.css' @@ -31,6 +31,7 @@ import { type PlanDay, type PlanDinner, type PlanStatus, + type PushedSupplyRun, type Swap, } from './api.ts' import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx' @@ -88,6 +89,13 @@ const syncStatuses: Record = { changed: 'Changes not pushed to Google Calendar', } +// supplyRunOutcomes says what became of the week's Supply run after a push +// that made it or left it as it was. +const supplyRunOutcomes: Record, string> = { + generated: "Made this week's Supply run from the plan.", + archived: "This week's Supply run is in the Supply archive, so it was left as it was.", +} + // duration is minutes as the plan manager's cards say it: "45 min", "6 hr", // "1 hr 30 min", with no-break spaces so a narrow card wraps the line // between the method and the duration, never inside the duration. @@ -238,7 +246,9 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre // // Push to Calendar, on either layout, sends the plan shown and shows it as // synced once the server answers; a failure says why, linking to the Google -// Calendar settings when only they can fix it. +// Calendar settings when only they can fix it. What became of the week's +// Supply run is said too: made, left as it was in the Supply archive, or +// not made, with the way to make it. function WeekBoard({ weekStart, timezone, @@ -259,6 +269,8 @@ function WeekBoard({ const swap = useSwapDay(weekStart) const push = usePushToCalendar(weekStart) const run = useSupplyRun(weekStart) + // Makes the week's Supply run again after a push could not make it. + const generate = useGenerateSupplyRun(weekStart) const pushing = push.pending // The date of the day being swapped, while the swap sheet is open. const [swappingDate, setSwappingDate] = useState(null) @@ -328,6 +340,7 @@ function WeekBoard({ if (!canPush) return swap.dismiss() push.reset() + generate.reset() push.mutate() } @@ -357,8 +370,25 @@ function WeekBoard({ else start(false) } + const retryPush = cached ? () => void plan.refetch() : pushToCalendar + // A push's Supply run made again on the Plan stands for the push's. + const supplyRun: PushedSupplyRun | null = push.supplyRun && (generate.isSuccess ? { status: 'generated', error: null } : push.supplyRun) const failure = push.error !== null ? ( - void plan.refetch() : pushToCalendar} /> + + ) : supplyRun?.status === 'failed' ? ( + supplyRun.error.code === 'plan_changed' ? ( + + Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push + it again. + + ) : ( + generate.mutate()} retryLabel="Make the shopping list"> + Pushed to Calendar, but this week's Supply run couldn't be made. + {generate.isPending + ? ' Making the shopping list…' + : generate.isError && ` Couldn't make the shopping list. ${generate.error.message}`} + + ) ) : swap.failed !== null ? ( swapIn(swap.failed!.swap)}> Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message} @@ -376,6 +406,11 @@ function WeekBoard({ ) : ( Couldn't check on the draft. {job.error.message} ) + const pushed = supplyRun !== null && supplyRun.status !== 'failed' && ( +

+ {supplyRunOutcomes[supplyRun.status]} +

+ ) // A failed read of the week's Supply run is shown, with Retry, after the // Draft board's Shopping list link or under the plan manager's board; the // Shopping list action still opens the Supply run. The count is that of a @@ -562,6 +597,7 @@ function WeekBoard({ {header} {body} {failure} + {pushed} {layout !== 'mobile' && supplyRunFailure} { // The push succeeds while the third week is shown: it is left behind, // and the week's pending swap is not. - await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT }))) + await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT, ...GENERATED }))) await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual([])) expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success']) @@ -338,6 +353,43 @@ describe('week mutations', () => { expect(weekMutations(queryClient, LATER)).toEqual([]) }) + it('keeps a push whose Supply run was not made, settled in a week no longer shown, to be shown on coming back', async () => { + const nextPush = deferred() + stubServer({ + [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), + [`GET /api/v1/plans/${NEXT}`]: () => jsonResponse(200, { ...planBody(), week_start: NEXT }), + [`POST /api/v1/plans/${NEXT}/push-calendar`]: () => nextPush.reply, + }) + const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const queryClient = createQueryClient() + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + const hook = renderHook(({ week }) => ({ plan: usePlan(week), push: usePushToCalendar(week) }), { + wrapper, + initialProps: { week: NEXT }, + }) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + + // The push is pushed to Calendar while another week is shown, but its + // Supply run is not made: it stays, though it succeeded. + act(() => hook.result.current.push.mutate()) + hook.rerender({ week: WEEK }) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + const synced = { ...planBody(), week_start: NEXT, status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' } + await act(async () => nextPush.send(jsonResponse(200, { ...synced, ...SUPPLY_RUN_FAILED }))) + await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual(['push success'])) + + // Coming back mounts the week's board again, as the Plan's does. + hook.unmount() + const back = renderHook(() => usePushToCalendar(NEXT), { wrapper }) + expect(back.result.current.error).toBeNull() + expect(back.result.current.supplyRun).toEqual(SUPPLY_RUN_FAILED.supply_run) + }) + it('leaves behind what settles once Plan is left for another route, and keeps what is pending', async () => { const friday = deferred() const monday = deferred() @@ -394,7 +446,7 @@ describe('week mutations', () => { // The push succeeds: the plan as synced is recorded, and the push is // left behind. const synced = { ...planBody(), status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' } - await act(async () => push.send(jsonResponse(200, synced))) + await act(async () => push.send(jsonResponse(200, { ...synced, ...GENERATED }))) await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual([])) expect(queryClient.getQueryData(planKey(WEEK))).toMatchObject({ status: 'synced', calendar_synced_at: new Date('2026-10-08T06:00:00Z') }) }) diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index 0a8ee4a..5a5302f 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -5,6 +5,7 @@ import { isRecord } from '../api/errors.ts' import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts' import { QUEST_LOG_KEY } from '../discover/api.ts' import { noteGoogleFailure } from '../settings/calendarApi.ts' +import { supplyRunKey } from '../shopping/api.ts' export const PLANS_PATH = '/api/v1/plans' @@ -114,6 +115,56 @@ function parsePlan(where: string, weekStart: string, body: unknown, cached: bool } } +// SupplyRunError is the API error a Push to Calendar's Supply run carries +// when it was not generated. +export type SupplyRunError = { code: C; message: string; retryable: boolean } + +// PushedSupplyRun says whether a Push to Calendar made the week's Supply +// run: generated when it did; archived when the week's Supply run is in the +// Supply archive and was left as it was; or failed when it was not made. +// A failed one's error is plan_changed when the plan was edited after it +// was pushed, and pushing again makes it, or supply_run_failed when it +// could not be made, and making the shopping list again does. +export type PushedSupplyRun = + | { status: 'generated'; error: null } + | { status: 'archived'; error: SupplyRunError<'archived'> } + | { status: 'failed'; error: SupplyRunError<'plan_changed' | 'supply_run_failed'> } + +// PushedPlan is the POST /api/v1/plans/{weekStart}/push-calendar answer: +// the plan as synced, and what became of the week's Supply run. +export type PushedPlan = { plan: Plan; supply_run: PushedSupplyRun } + +const PUSHED_SUPPLY_RUN_STATUSES = ['generated', 'archived', 'failed'] as const +const FAILED_SUPPLY_RUN_CODES = ['plan_changed', 'supply_run_failed'] as const + +function parseSupplyRunError(where: string, codes: readonly C[], body: unknown): SupplyRunError { + if (!isRecord(body) || typeof body.message !== 'string' || typeof body.retryable !== 'boolean') { + throw new Error(`${where}: supply_run has an error that is not an API error`) + } + return { code: oneOf(where, 'supply_run.error.code', codes, body.code), message: body.message, retryable: body.retryable } +} + +function parsePushedSupplyRun(where: string, body: unknown): PushedSupplyRun { + if (!isRecord(body)) throw new Error(`${where}: response has no supply_run`) + const status = oneOf(where, 'supply_run.status', PUSHED_SUPPLY_RUN_STATUSES, body.status) + switch (status) { + case 'generated': + if (body.error !== null) throw new Error(`${where}: a generated supply_run has an error`) + return { status, error: null } + case 'archived': + return { status, error: parseSupplyRunError(where, ['archived'] as const, body.error) } + case 'failed': + return { status, error: parseSupplyRunError(where, FAILED_SUPPLY_RUN_CODES, body.error) } + } +} + +function parsePushedPlan(where: string, weekStart: string, body: unknown): PushedPlan { + return { + plan: parsePlan(where, weekStart, body, false), + supply_run: parsePushedSupplyRun(where, isRecord(body) ? body.supply_run : undefined), + } +} + // parseDraftResult checks a draft_week job's result, which useJob passes on // unread, is the draft of the week starting on weekStart. export function parseDraftResult(where: string, weekStart: string, body: unknown): DraftResult { @@ -178,6 +229,14 @@ function sameDay(a: PlanDay, b: PlanDay): boolean { ) } +// failed says whether a swap or push failed: a push whose Supply run was +// not made failed too, though Calendar has the plan. +function failed(mutation: Mutation): boolean { + if (mutation.state.status === 'error') return true + const data = mutation.state.data as PushedPlan | undefined + return mutation.state.status === 'success' && weekMutation(mutation)?.kind === 'push' && data?.supply_run.status === 'failed' +} + // weekMutation is the kind and week of a swap or push of a week the mutation // cache keeps for it, or null for any other mutation. function weekMutation(mutation: Mutation): { kind: 'swap' | 'push'; week: string } | null { @@ -225,7 +284,7 @@ function keepWeekMutations(queryClient: QueryClient): WeeksShown { if (week.some((mutation) => mutation.state.status === 'pending')) continue const latest = week.at(-1) for (const mutation of week) { - if (settled && mutation === latest && mutation.state.status === 'error') continue + if (settled && mutation === latest && failed(mutation)) continue mutations.remove(mutation) } } @@ -363,10 +422,12 @@ function pushKey(weekStart: string) { } // PushToCalendar is the push of one week, as the mutation cache holds it: -// whether it is pending, and why the latest failed, if it failed. +// whether it is pending, why the latest failed, if it failed, and what +// became of the week's Supply run, if it succeeded. export type PushToCalendar = { pending: boolean error: Error | null + supplyRun: PushedSupplyRun | null mutate: () => void reset: () => void } @@ -380,6 +441,13 @@ export type PushToCalendar = { // the one shown. One Google refused the grant for reads the Google Calendar // connection again, which is now reconnect required. // +// Once pushed, the server makes the week's Supply run from the plan pushed +// and answers with its outcome, which supplyRun reports: a failed one is +// never a quiet success. The week's Supply run is read again, whatever the +// outcome, so the list shown is the server's. A Supply run not made because +// the plan was edited after it was pushed reads the plan again too, as a +// push refused for that does. +// // The week's pushes live in the mutation cache, not in the component, as its // swaps do, so leaving the week and coming back keeps one pending. reset // leaves settled pushes behind; it is refused while one is pending. Settled @@ -395,12 +463,12 @@ export function usePushToCalendar(weekStart: string): PushToCalendar { // The week's pushes, in the order sent. const pushes = useMutationState({ filters: { mutationKey }, - select: (mutation) => ({ status: mutation.state.status, error: mutation.state.error }), + select: (mutation) => ({ status: mutation.state.status, error: mutation.state.error, data: mutation.state.data as PushedPlan | undefined }), }) const latest = pushes.at(-1) function mutate() { - const mutation = mutations.build(queryClient, { + const mutation = mutations.build(queryClient, { mutationKey, // Kept until reset or settled in a week not shown (keepWeekMutations): // a push pending in a week left must still be found when it is shown @@ -408,9 +476,15 @@ export function usePushToCalendar(weekStart: string): PushToCalendar { gcTime: Infinity, mutationFn: async () => { const path = `${planPath(weekStart)}/push-calendar` - return parsePlan(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST' }), false) + return parsePushedPlan(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST' })) + }, + onSuccess: async ({ plan, supply_run }) => { + queryClient.setQueryData(key, plan) + void queryClient.invalidateQueries({ queryKey: supplyRunKey(weekStart) }) + if (supply_run.error?.code === 'plan_changed') { + await queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' }) + } }, - onSuccess: (plan) => queryClient.setQueryData(key, plan), onError: async (error) => { noteGoogleFailure(queryClient, error) if (error instanceof ApiError && error.code === 'plan_changed') { @@ -433,6 +507,7 @@ export function usePushToCalendar(weekStart: string): PushToCalendar { return { pending: pushes.some((push) => push.status === 'pending'), error: latest?.status === 'error' ? latest.error! : null, + supplyRun: latest?.status === 'success' ? latest.data!.supply_run : null, mutate, reset, } -- 2.55.0 From c589c02cdc72218df6b88b972424c976a5fb21ef Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 13:54:32 -0400 Subject: [PATCH 2/4] Settle a push's failed Supply run once the shopping list is made A push whose Supply run could not be made stays in the mutation cache, so leaving the Plan still shows the failure on coming back. Making the shopping list from that failure was only remembered while the Plan stayed mounted: after visiting the Supply run and coming back, the Plan said again that the list could not be made and offered to make it (F17-R1). usePushToCalendar now has settle, which removes the push shown from the mutation cache. The Plan calls it once the generate request succeeds, through mutateAsync, so it is settled even if the Plan was left while the list was being made. A push never followed by a made list is kept as before. Co-Authored-By: Claude Opus 5.5 --- web/src/plan/PlanPage.test.tsx | 41 +++++++++++++++++++++++++++++++--- web/src/plan/PlanPage.tsx | 17 +++++++++++--- web/src/plan/api.ts | 20 ++++++++++++++--- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index c26a70d..44be664 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -1,6 +1,6 @@ import { QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import { MemoryRouter, Route, Routes, useLocation } from 'react-router' +import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' @@ -177,10 +177,16 @@ async function renderPage() { ) } -// Opened stands in for the screen a link opened, saying where it is. +// Opened stands in for the screen a link opened, saying where it is, with +// a link back to the Plan. function Opened() { const { pathname, search } = useLocation() - return

Opened {pathname + search}

+ return ( + <> +

Opened {pathname + search}

+ Back to the Plan + + ) } // supplyRun is the Supply run of the week of Oct 5 with needed of its 32 @@ -542,6 +548,35 @@ describe('PlanPage', () => { ]) }) + it('no longer says the Supply run could not be made once the shopping list is made, after leaving the Plan and coming back', async () => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), + 'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") + + // A Supply run never made is still said after leaving the Plan and + // coming back. + fireEvent.click(screen.getByRole('link', { name: 'Shopping list' })) + fireEvent.click(await screen.findByRole('link', { name: 'Back to the Plan' })) + expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") + + fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' })) + expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") + + // Once made, it is not said again to be unmade. + fireEvent.click(screen.getByRole('link', { name: 'Shopping list · 28' })) + fireEvent.click(await screen.findByRole('link', { name: 'Back to the Plan' })) + expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument() + }) + it('pushes nothing again until the changed plan is read and shown', async () => { let read: () => Response | Promise = () => jsonResponse(200, planBody('draft')) let push = () => errorResponse(409, 'plan_changed') diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 0bbc291..baf6257 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -352,6 +352,7 @@ function WeekBoard({ setJobId(null) swap.dismiss() push.reset() + generate.reset() mutate( { weekStart, confirm }, { @@ -371,8 +372,17 @@ function WeekBoard({ } const retryPush = cached ? () => void plan.refetch() : pushToCalendar - // A push's Supply run made again on the Plan stands for the push's. - const supplyRun: PushedSupplyRun | null = push.supplyRun && (generate.isSuccess ? { status: 'generated', error: null } : push.supplyRun) + // makeShoppingList makes the week's Supply run again after a push could + // not make it. Once made, the push is settled, even if the Plan was left + // meanwhile: coming back does not say again that it couldn't be made. A + // failure is the mutation's state, which generate.error reports. + function makeShoppingList() { + const { settle } = push + generate.mutateAsync().then(settle, () => undefined) + } + // A push's Supply run made again on the Plan stands for the push's, which + // is settled. + const supplyRun: PushedSupplyRun | null = generate.isSuccess ? { status: 'generated', error: null } : push.supplyRun const failure = push.error !== null ? ( ) : supplyRun?.status === 'failed' ? ( @@ -382,7 +392,7 @@ function WeekBoard({ it again.
) : ( - generate.mutate()} retryLabel="Make the shopping list"> + Pushed to Calendar, but this week's Supply run couldn't be made. {generate.isPending ? ' Making the shopping list…' @@ -426,6 +436,7 @@ function WeekBoard({ function swapIn(chosen: Swap) { setSwappingDate(null) push.reset() + generate.reset() swap.mutate(chosen) } // dropIn swaps in a dinner dragged onto a day. A drag is not picked up, diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index 5a5302f..6359449 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -430,6 +430,7 @@ export type PushToCalendar = { supplyRun: PushedSupplyRun | null mutate: () => void reset: () => void + settle: () => void } // usePushToCalendar pushes the plan of the week starting on weekStart to the @@ -450,8 +451,10 @@ export type PushToCalendar = { // // The week's pushes live in the mutation cache, not in the component, as its // swaps do, so leaving the week and coming back keeps one pending. reset -// leaves settled pushes behind; it is refused while one is pending. Settled -// pushes of a week no longer shown are left behind too +// leaves settled pushes behind; it is refused while one is pending. settle +// leaves behind the push shown, once its failed Supply run has been made +// another way, so it is not said again to be unmade; a push sent since is +// kept. Settled pushes of a week no longer shown are left behind too // (keepWeekMutations). export function usePushToCalendar(weekStart: string): PushToCalendar { const { apiFetch } = useApi() @@ -463,7 +466,12 @@ export function usePushToCalendar(weekStart: string): PushToCalendar { // The week's pushes, in the order sent. const pushes = useMutationState({ filters: { mutationKey }, - select: (mutation) => ({ status: mutation.state.status, error: mutation.state.error, data: mutation.state.data as PushedPlan | undefined }), + select: (mutation) => ({ + mutationId: mutation.mutationId, + status: mutation.state.status, + error: mutation.state.error, + data: mutation.state.data as PushedPlan | undefined, + }), }) const latest = pushes.at(-1) @@ -504,11 +512,17 @@ export function usePushToCalendar(weekStart: string): PushToCalendar { for (const mutation of week) mutations.remove(mutation) } + function settle() { + const shown = mutations.findAll({ mutationKey }).find((mutation) => mutation.mutationId === latest?.mutationId) + if (shown !== undefined) mutations.remove(shown) + } + return { pending: pushes.some((push) => push.status === 'pending'), error: latest?.status === 'error' ? latest.error! : null, supplyRun: latest?.status === 'success' ? latest.data!.supply_run : null, mutate, reset, + settle, } } -- 2.55.0 From 445750cecb203587a93d3b1f4a09ba5940d2bbc9 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 14:14:52 -0400 Subject: [PATCH 3/4] Keep a push's unmade Supply run across week navigation A push whose Supply run was not made was swept from the mutation cache as soon as another week was shown. If the household pressed "Make the shopping list" and moved to another week while it was being made, and making it then failed, coming back showed neither the failure nor a way to make the list: the week was left on Calendar with no shopping list (F17-R2). keepWeekMutations now keeps a week's latest push whose Supply run was not made whatever week is shown. It goes only when the list is made (settle), or when a push, draft or swap of that week sends a new push or resets it; an earlier push replaced by a later one is still swept as before. Co-Authored-By: Claude Opus 5.5 --- web/src/plan/PlanPage.test.tsx | 51 ++++++++++++++++++++++++++++++++++ web/src/plan/api.ts | 23 ++++++++++----- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 44be664..cde7881 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -577,6 +577,57 @@ describe('PlanPage', () => { expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument() }) + it('still says the Supply run could not be made when making the shopping list fails after leaving the week', async () => { + const made = deferred() + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), + 'POST /api/v1/shopping/2026-10-05/generate': () => made.response, + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…') + + // The week is left while the shopping list is being made, which fails. + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + await act(async () => made.send(errorResponse(500, 'internal'))) + + // Coming back, the Supply run is still said to be unmade, and can be + // made again. + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") + expect(within(alert).getByRole('button', { name: 'Make the shopping list' })).toBeEnabled() + }) + + it('no longer says the Supply run could not be made once the shopping list is made after leaving the week', async () => { + const made = deferred() + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), + 'POST /api/v1/shopping/2026-10-05/generate': () => made.response, + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…') + + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + await act(async () => made.send(jsonResponse(200, { ...supplyRun(28), skipped: [] }))) + + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument() + }) + it('pushes nothing again until the changed plan is read and shown', async () => { let read: () => Response | Promise = () => jsonResponse(200, planBody('draft')) let push = () => errorResponse(409, 'plan_changed') diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index 6359449..7e9341d 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -232,7 +232,12 @@ function sameDay(a: PlanDay, b: PlanDay): boolean { // failed says whether a swap or push failed: a push whose Supply run was // not made failed too, though Calendar has the plan. function failed(mutation: Mutation): boolean { - if (mutation.state.status === 'error') return true + return mutation.state.status === 'error' || unmade(mutation) +} + +// unmade says whether a push succeeded but its Supply run was not made: the +// week is on Calendar with no shopping list until one is made. +function unmade(mutation: Mutation): boolean { const data = mutation.state.data as PushedPlan | undefined return mutation.state.status === 'success' && weekMutation(mutation)?.kind === 'push' && data?.supply_run.status === 'failed' } @@ -262,9 +267,13 @@ const weeksShown = new WeakMap() // earlier pending one must see, stay across leaving its week and coming // back. When one settles, the same is done, except that a week's latest, // when it failed, stays until a week is next shown, so that coming straight -// back to it shows the failure. The cache holds at most the weeks shown's, -// those of weeks left pending, and one failure of each week whose last -// pending one settled since a week was last shown. +// back to it shows the failure. A week's latest push whose Supply run was +// not made stays whatever is shown: the week has no shopping list until one +// is made, which settles the push, or a push sent since replaces it, and +// making it may still be under way when the week is left. The cache holds +// at most the weeks shown's, those of weeks left pending, one push of each +// week whose Supply run was not made, and one failure of each week whose +// last pending one settled since a week was last shown. function keepWeekMutations(queryClient: QueryClient): WeeksShown { const kept = weeksShown.get(queryClient) if (kept !== undefined) return kept @@ -284,7 +293,7 @@ function keepWeekMutations(queryClient: QueryClient): WeeksShown { if (week.some((mutation) => mutation.state.status === 'pending')) continue const latest = week.at(-1) for (const mutation of week) { - if (settled && mutation === latest && failed(mutation)) continue + if (mutation === latest && (unmade(mutation) || (settled && failed(mutation)))) continue mutations.remove(mutation) } } @@ -454,8 +463,8 @@ export type PushToCalendar = { // leaves settled pushes behind; it is refused while one is pending. settle // leaves behind the push shown, once its failed Supply run has been made // another way, so it is not said again to be unmade; a push sent since is -// kept. Settled pushes of a week no longer shown are left behind too -// (keepWeekMutations). +// kept. Settled pushes of a week no longer shown are left behind too, but +// for the latest when its Supply run was not made (keepWeekMutations). export function usePushToCalendar(weekStart: string): PushToCalendar { const { apiFetch } = useApi() const queryClient = useQueryClient() -- 2.55.0 From d917a40fa2f561fd83f6b167d51c832aff740a07 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 14:36:25 -0400 Subject: [PATCH 4/4] Settle a push's unmade Supply run however the shopping list is made Only the Plan's own "Make the shopping list" settled a push whose Supply run was not made. Making the list on the Supply run screen, through the same generate endpoint, left the push in the mutation cache, so coming back to the Plan still said the Supply run couldn't be made and offered to make it again, although the list existed (F17-R3). The settlement now lives in useGenerateSupplyRun, which both screens use: onMutate notes the week's pushes sent so far (settleUnmadePushes) and onSuccess removes those whose Supply run was not made. A failed attempt leaves them shown. usePushToCalendar's settle, the Plan-only path, is gone. Tests: the list made on the Supply run screen is no longer said to be unmade on the Plan, a failure there still is, and the success-after-navigation test now checks the push is kept while the week is left and removed once made, so settlement is observed apart from the week sweep. Maestro task e75db0f0-817f-4eef-84b2-266e793c018a. Co-Authored-By: Claude Opus 5.5 --- web/src/plan/PlanPage.test.tsx | 81 +++++++++++++++++++++++++++++++--- web/src/plan/PlanPage.tsx | 9 ++-- web/src/plan/api.ts | 35 ++++++++++----- web/src/shopping/api.ts | 10 ++++- 4 files changed, 110 insertions(+), 25 deletions(-) diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index cde7881..1f027a9 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -1,9 +1,10 @@ -import { QueryClientProvider } from '@tanstack/react-query' +import { QueryClientProvider, type QueryClient } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { SupplyRunPage } from '../shopping/SupplyRunPage.tsx' import { cacheFallbackHeader } from '../sw/routes.ts' import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' import { declaredStyle } from '../test/cssRules.ts' @@ -159,17 +160,17 @@ function planReads(fetchMock: Mock) { } // renderPage loads /me (for the CSRF token every write needs) and renders -// the Plan page. -async function renderPage() { +// the Plan page, over queryClient, with shopping as the Supply run screen. +async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = ) { const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) await api.loadMe() return render( - + } /> - } /> + @@ -189,6 +190,22 @@ function Opened() { ) } +// pushesKept counts the pushes of the week of Oct 5 the mutation cache of +// queryClient keeps. +function pushesKept(queryClient: QueryClient) { + return queryClient.getMutationCache().findAll({ mutationKey: ['plans', '2026-10-05', 'push'] }).length +} + +// SupplyRunScreen is the Supply run screen, with a link back to the Plan. +function SupplyRunScreen() { + return ( + <> + + Back to the Plan + + ) +} + // supplyRun is the Supply run of the week of Oct 5 with needed of its 32 // items still to buy. function supplyRun(needed: number) { @@ -612,15 +629,20 @@ describe('PlanPage', () => { [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), 'POST /api/v1/shopping/2026-10-05/generate': () => made.response, }) - await renderPage() + const queryClient = createQueryClient() + await renderPage(queryClient) fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' })) expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…') + // The push is kept while the week is left, until the list is made: it + // is then settled, not merely swept with the week. fireEvent.click(screen.getByRole('button', { name: 'Next week' })) expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + expect(pushesKept(queryClient)).toBe(1) await act(async () => made.send(jsonResponse(200, { ...supplyRun(28), skipped: [] }))) + expect(pushesKept(queryClient)).toBe(0) fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) expect(await screen.findByText('Beef tacos')).toBeInTheDocument() @@ -628,6 +650,53 @@ describe('PlanPage', () => { expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument() }) + it('no longer says the Supply run could not be made once the shopping list is made on the Supply run screen', async () => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), + 'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }), + }) + const queryClient = createQueryClient() + await renderPage(queryClient, ) + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") + + // The list is made on the Supply run screen, not from the Plan's alert. + fireEvent.click(screen.getByRole('link', { name: 'Shopping list' })) + fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' })) + await waitFor(() => expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()) + expect(pushesKept(queryClient)).toBe(0) + + fireEvent.click(screen.getByRole('link', { name: 'Back to the Plan' })) + expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument() + }) + + it('still says the Supply run could not be made when making it on the Supply run screen fails', async () => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), + 'POST /api/v1/shopping/2026-10-05/generate': () => errorResponse(500, 'internal'), + }) + const queryClient = createQueryClient() + await renderPage(queryClient, ) + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") + + fireEvent.click(screen.getByRole('link', { name: 'Shopping list' })) + fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' })) + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't make the shopping list.") + expect(pushesKept(queryClient)).toBe(1) + + fireEvent.click(screen.getByRole('link', { name: 'Back to the Plan' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") + expect(within(alert).getByRole('button', { name: 'Make the shopping list' })).toBeEnabled() + }) + it('pushes nothing again until the changed plan is read and shown', async () => { let read: () => Response | Promise = () => jsonResponse(200, planBody('draft')) let push = () => errorResponse(409, 'plan_changed') diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index baf6257..87124ba 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -373,12 +373,11 @@ function WeekBoard({ const retryPush = cached ? () => void plan.refetch() : pushToCalendar // makeShoppingList makes the week's Supply run again after a push could - // not make it. Once made, the push is settled, even if the Plan was left - // meanwhile: coming back does not say again that it couldn't be made. A - // failure is the mutation's state, which generate.error reports. + // not make it. Once made, here or on the Supply run screen, the push is + // settled (useGenerateSupplyRun). A failure is the mutation's state, which + // generate.error reports. function makeShoppingList() { - const { settle } = push - generate.mutateAsync().then(settle, () => undefined) + generate.mutate() } // A push's Supply run made again on the Plan stands for the push's, which // is settled. diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index 7e9341d..e6094fc 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -439,7 +439,6 @@ export type PushToCalendar = { supplyRun: PushedSupplyRun | null mutate: () => void reset: () => void - settle: () => void } // usePushToCalendar pushes the plan of the week starting on weekStart to the @@ -460,11 +459,11 @@ export type PushToCalendar = { // // The week's pushes live in the mutation cache, not in the component, as its // swaps do, so leaving the week and coming back keeps one pending. reset -// leaves settled pushes behind; it is refused while one is pending. settle -// leaves behind the push shown, once its failed Supply run has been made -// another way, so it is not said again to be unmade; a push sent since is -// kept. Settled pushes of a week no longer shown are left behind too, but -// for the latest when its Supply run was not made (keepWeekMutations). +// leaves settled pushes behind; it is refused while one is pending. A push +// whose Supply run was not made is left behind once the list is made +// (settleUnmadePushes). Settled pushes of a week no longer shown are left +// behind too, but for the latest when its Supply run was not made +// (keepWeekMutations). export function usePushToCalendar(weekStart: string): PushToCalendar { const { apiFetch } = useApi() const queryClient = useQueryClient() @@ -521,17 +520,29 @@ export function usePushToCalendar(weekStart: string): PushToCalendar { for (const mutation of week) mutations.remove(mutation) } - function settle() { - const shown = mutations.findAll({ mutationKey }).find((mutation) => mutation.mutationId === latest?.mutationId) - if (shown !== undefined) mutations.remove(shown) - } - return { pending: pushes.some((push) => push.status === 'pending'), error: latest?.status === 'error' ? latest.error! : null, supplyRun: latest?.status === 'success' ? latest.data!.supply_run : null, mutate, reset, - settle, + } +} + +// settleUnmadePushes is called as the Supply run of the week starting on +// weekStart starts being made, from the Plan or the Supply run screen +// (useGenerateSupplyRun). The function it answers with is called once the +// list is made: it leaves behind the week's pushes sent before whose Supply +// run was not made, so the Plan does not say again that it is unmade, even +// if the Plan was left meanwhile. A push sent since is kept, and a list not +// made leaves them as they are. +export function settleUnmadePushes(queryClient: QueryClient, weekStart: string): () => void { + const mutations = queryClient.getMutationCache() + const mutationKey = pushKey(weekStart) + const sent = new Set(mutations.findAll({ mutationKey })) + return () => { + for (const mutation of mutations.findAll({ mutationKey })) { + if (sent.has(mutation) && unmade(mutation)) mutations.remove(mutation) + } } } diff --git a/web/src/shopping/api.ts b/web/src/shopping/api.ts index 6119f03..09fc0a3 100644 --- a/web/src/shopping/api.ts +++ b/web/src/shopping/api.ts @@ -12,6 +12,7 @@ import { ApiError, useApi, type ApiClient } from '../api/index.ts' import { isRecord } from '../api/errors.ts' import { meQueryKey } from '../AuthBootstrap.tsx' import { oneOf } from '../dinners/api.ts' +import { settleUnmadePushes } from '../plan/api.ts' import type { NetworkStatus } from '../pwa/networkStatus.ts' import { HOUSEHOLD_SETTINGS_KEY, type HouseholdSettings } from '../settings/api.ts' import { @@ -187,16 +188,21 @@ export function useSupplyRun(weekStart: string) { // useGenerateSupplyRun makes the week's Supply run from its plan (POST // /api/v1/shopping/{weekStart}/generate); the Supply run it answers with is -// the one shown. +// the one shown. Once made, from whichever screen, a push of the week whose +// Supply run was not made is settled (settleUnmadePushes). export function useGenerateSupplyRun(weekStart: string) { const { apiFetch } = useApi() const queryClient = useQueryClient() return useMutation({ + onMutate: () => settleUnmadePushes(queryClient, weekStart), mutationFn: async (): Promise => { const path = `${supplyRunPath(weekStart)}/generate` return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST' })) }, - onSuccess: (run) => queryClient.setQueryData(supplyRunKey(weekStart), run), + onSuccess: (run, _, settle) => { + queryClient.setQueryData(supplyRunKey(weekStart), run) + settle() + }, }) } -- 2.55.0