Serialize Supply run toggle replay and keep "will sync" until it settles (Feature 17)
CI / ci (push) Successful in 5m15s
CI / ci (pull_request) Successful in 5m17s

Every Supply run toggle, made here or replayed from the saved queue, now
runs in one mutation scope whatever its week, so a replay spanning weeks
is sent strictly in the stored order and never overlaps a live toggle.

The "will sync" marker latches on a toggle that was pending while the
server could not be reached and stays until that toggle settles, rather
than vanishing as soon as the network returns while its replay is still
in flight.

The Supply run screen reads the saved schedule once per mount instead of
on every render, so a corrupt saved store offline reports its problem
once instead of looping.

Replay is documented as at-least-once: a toggle sets needed to a fixed
value, so a duplicate sent after a reload leaves the item and the counts
unchanged. Tests cover a two-week queue replayed in order one PATCH at a
time, a duplicate replay, corrupt storage offline and a delayed replay
answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 10:20:23 -04:00
co-authored by Claude Opus 5.5
parent b7608d0a9a
commit 4ba6471070
3 changed files with 194 additions and 40 deletions
+156 -25
View File
@@ -21,6 +21,18 @@ const ITEMS = [
{ id: 'i3', name: 'Taco seasoning', qty: '1 packet', needed: false },
]
// Next week's curry: one item to buy.
const NEXT_ITEMS = [{ id: 'n1', name: 'Chicken thighs', qty: '2 lb', needed: true }]
// The items of each week, by its Monday.
const WEEKS: Record<string, typeof ITEMS> = { '2026-10-05': ITEMS, '2026-10-12': NEXT_ITEMS }
function itemsOf(week: string) {
const items = WEEKS[week]
if (items === undefined) throw new Error(`no week ${week}`)
return items
}
// The browser's online flag; goOffline and goOnline change it and send the
// page's window the event a browser sends.
let onLine: boolean
@@ -53,51 +65,78 @@ function goOnline() {
act(() => void win.dispatchEvent(new Event('online')))
}
// stubServer is the server's Supply run of the week: a PATCH marks its item
// and answers with its counts, unless refuse answers it instead. It counts
// as archived once archive is called.
function stubServer(refuse: (id: string) => Response | undefined = () => undefined) {
const needed = new Map(ITEMS.map((item) => [item.id, item.needed]))
// stubServer is the server's Supply runs of this week and next: a PATCH
// marks its item and answers with its week's counts, unless refuse answers
// it instead; when hold is given, the item is marked at once but the answer
// waits for the promise hold returns. This week's counts as archived once
// archive is called.
function stubServer(refuse: (id: string) => Response | undefined = () => undefined, hold?: () => Promise<void>) {
const needed = new Map(Object.values(WEEKS).flatMap((items) => items.map((item) => [item.id, item.needed] as const)))
let status = 'current'
const counts = () => ({ needed_count: [...needed.values()].filter(Boolean).length, total_count: needed.size })
const run = () => ({
weekStart: '2026-10-05',
label: 'Week 41 · Oct 5 – 11',
// How many PATCHes are unanswered now, and the most there have been at once.
let inFlight = 0
let mostInFlight = 0
const counts = (week: string) => {
const marks = itemsOf(week).map((item) => needed.get(item.id)!)
return { needed_count: marks.filter(Boolean).length, total_count: marks.length }
}
const run = (week: string) => ({
weekStart: week,
label: week === '2026-10-05' ? 'Week 41 · Oct 5 – 11' : 'Week 42 · Oct 12 – 18',
title: null,
status,
status: week === '2026-10-05' ? status : 'current',
sent_to_chat_at: null,
...counts(),
...counts(week),
groups: [
{
dinner: { id: 'd0', name: 'Beef tacos' },
date: '2026-10-05',
items: ITEMS.map((item) => ({ ...item, needed: needed.get(item.id)! })),
dinner: { id: week === '2026-10-05' ? 'd0' : 'd1', name: week === '2026-10-05' ? 'Beef tacos' : 'Chicken curry' },
date: week,
items: itemsOf(week).map((item) => ({ ...item, needed: needed.get(item.id)! })),
},
],
})
const fetchMock = vi.fn<typeof fetch>((input, init) => {
if (!onLine || !reachable) return Promise.reject(new TypeError('Failed to fetch'))
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
if (!onLine || !reachable) throw new TypeError('Failed to fetch')
const key = `${init?.method ?? 'GET'} ${String(input)}`
if (key === 'GET /api/v1/me') return Promise.resolve(jsonResponse(200, ME))
if (key === 'GET /api/v1/settings/household') return Promise.resolve(jsonResponse(200, HOUSEHOLD_SETTINGS))
if (key === `GET ${THIS_WEEK}`) return Promise.resolve(jsonResponse(200, run()))
const item = ITEMS.find((i) => key === `PATCH ${ITEM(i.id)}`)
if (item === undefined) throw new Error(`unexpected fetch: ${key}`)
const refusal = refuse(item.id)
if (refusal !== undefined) return Promise.resolve(refusal)
if (key === 'GET /api/v1/me') return jsonResponse(200, ME)
if (key === 'GET /api/v1/settings/household') return jsonResponse(200, HOUSEHOLD_SETTINGS)
if (key === `GET ${THIS_WEEK}`) return jsonResponse(200, run('2026-10-05'))
const week = Object.keys(WEEKS).find((w) => itemsOf(w).some((i) => key === `PATCH ${ITEM(i.id)}`))
if (week === undefined) throw new Error(`unexpected fetch: ${key}`)
const id = itemsOf(week).find((i) => key === `PATCH ${ITEM(i.id)}`)!.id
const refusal = refuse(id)
if (refusal !== undefined) return refusal
const body = JSON.parse(String(init?.body)) as { needed: boolean }
needed.set(item.id, body.needed)
return Promise.resolve(jsonResponse(200, { id: item.id, needed: body.needed, ...counts() }))
needed.set(id, body.needed)
const answer = jsonResponse(200, { id, needed: body.needed, ...counts(week) })
inFlight++
mostInFlight = Math.max(mostInFlight, inFlight)
await hold?.()
inFlight--
return answer
})
vi.stubGlobal('fetch', fetchMock)
return {
fetchMock,
run,
mostInFlight: () => mostInFlight,
archive: () => {
status = 'archived'
},
}
}
// held is a hold for stubServer whose answers wait until release is called,
// each one released in turn.
function held() {
const waiting: (() => void)[] = []
return {
hold: () => new Promise<void>((resolve) => waiting.push(resolve)),
waiting: () => waiting.length,
release: () => waiting.shift()!(),
}
}
// patches lists the PATCHes sent, as "path body".
function patches(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls.filter(([, init]) => init?.method === 'PATCH').map(([input, init]) => `${String(input)} ${String(init?.body)}`)
@@ -145,6 +184,7 @@ beforeEach(() => {
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
@@ -277,3 +317,94 @@ describe('offline toggle queue', () => {
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`]))
})
})
describe('offline toggle queue, replay', () => {
it('replays a queue spanning two weeks strictly in order, one PATCH at a time, a toggle made meanwhile after it', async () => {
const answers = held()
const server = stubServer(undefined, answers.hold)
const queued = [
{ key: 'k1', weekStart: '2026-10-05', id: 'i3', needed: true },
{ key: 'k2', weekStart: '2026-10-12', id: 'n1', needed: false },
{ key: 'k3', weekStart: '2026-10-05', id: 'i1', needed: false },
]
const runs = { '2026-10-05': server.run('2026-10-05'), '2026-10-12': server.run('2026-10-12') }
localStorage.setItem(SAVED_KEY, JSON.stringify({ schedule: null, runs, queue: queued }))
await openPage()
await waitFor(() => expect(answers.waiting()).toBe(1))
fireEvent.click(await screen.findByRole('checkbox', { name: 'Corn tortillas' }))
for (let sent = 1; sent <= 4; sent++) {
await waitFor(() => expect(patches(server.fetchMock)).toHaveLength(sent))
// The next one is not sent before this one is answered.
await new Promise((resolve) => setTimeout(resolve, 20))
expect(patches(server.fetchMock)).toHaveLength(sent)
act(() => answers.release())
}
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
`${ITEM('n1')} {"needed":false}`,
`${ITEM('i1')} {"needed":false}`,
`${ITEM('i2')} {"needed":false}`,
])
expect(server.mostInFlight()).toBe(1)
await waitFor(() => expect(screen.getByText('1 / 3')).toBeInTheDocument())
})
it('replays at least once: a toggle the server applied before a reload is sent again and leaves the item and count the same', async () => {
const answers = held()
const server = stubServer(undefined, answers.hold)
const first = await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
// The server applied it; the page reloads before its answer arrives.
await waitFor(() => expect(answers.waiting()).toBe(1))
expect(queue()).toEqual(['i3 true'])
first.unmount()
act(() => answers.release())
await openPage()
await waitFor(() => expect(answers.waiting()).toBe(1))
act(() => answers.release())
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`])
await waitFor(() => expect(screen.getByText('3 / 3')).toBeInTheDocument())
expect(checkbox('Taco seasoning')).toBeChecked()
expect(server.run('2026-10-05')).toMatchObject({ needed_count: 3, total_count: 3 })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('keeps "will sync" on a queued toggle until its delayed replay is answered', async () => {
const answers = held()
stubServer(undefined, answers.hold)
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Ground beef'))
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
goOnline()
await waitFor(() => expect(answers.waiting()).toBe(1))
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(queue()).toEqual(['i1 false'])
act(() => answers.release())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(queue()).toEqual([])
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
it('says the saved list is unreadable when it is corrupt offline, without rendering again and again', async () => {
stubServer()
onLine = false
localStorage.setItem(SAVED_KEY, '{corrupt')
const getItem = vi.spyOn(Storage.prototype, 'getItem')
await openPage()
expect(await screen.findByText(/Couldn't load the household settings/)).toBeInTheDocument()
expect(screen.getByText(/can't be kept on this device while offline/)).toBeInTheDocument()
const reads = getItem.mock.calls.filter(([key]) => key === SAVED_KEY).length
await new Promise((resolve) => setTimeout(resolve, 50))
expect(getItem.mock.calls.filter(([key]) => key === SAVED_KEY)).toHaveLength(reads)
})
})