Settle a push's failed Supply run once the shopping list is made
CI / ci (push) Successful in 4m53s
CI / ci (pull_request) Successful in 4m57s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 13:54:32 -04:00
co-authored by Claude Opus 5.5
parent 9616941b73
commit c589c02cdc
3 changed files with 69 additions and 9 deletions
+38 -3
View File
@@ -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 <p>Opened {pathname + search}</p>
return (
<>
<p>Opened {pathname + search}</p>
<Link to="/plan">Back to the Plan</Link>
</>
)
}
// 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<Response> = () => jsonResponse(200, planBody('draft'))
let push = () => errorResponse(409, 'plan_changed')
+14 -3
View File
@@ -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 ? (
<PushFailure error={push.error} cached={cached} onRetry={retryPush} />
) : supplyRun?.status === 'failed' ? (
@@ -382,7 +392,7 @@ function WeekBoard({
it again.
</ErrorBanner>
) : (
<ErrorBanner onRetry={generate.isPending ? undefined : () => generate.mutate()} retryLabel="Make the shopping list">
<ErrorBanner onRetry={generate.isPending ? undefined : makeShoppingList} 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,
+17 -3
View File
@@ -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,
}
}