diff --git a/web/src/shopping/SupplyRunPage.tsx b/web/src/shopping/SupplyRunPage.tsx index 6bc4bf5..30072ef 100644 --- a/web/src/shopping/SupplyRunPage.tsx +++ b/web/src/shopping/SupplyRunPage.tsx @@ -1,4 +1,5 @@ import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react' +import { useState } from 'react' import { Link, useSearchParams } from 'react-router' import { Unavailable } from '../plan/PlanPage.tsx' import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts' @@ -249,12 +250,15 @@ function ChosenSupplyRun() { const settings = useHouseholdSettings() const [params] = useSearchParams() const chosen = params.get(WEEK_PARAM) + // Read once, not on every render: a failed read reports a problem, which + // renders this again. + const [saved] = useState(() => keep(savedSchedule)) if (chosen !== null && !isMonday(chosen)) { return {`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`} } // fetch rejects with a TypeError only when the network fails. - const schedule = settings.data ?? (settings.error instanceof TypeError ? keep(savedSchedule) : null) + const schedule = settings.data ?? (settings.error instanceof TypeError ? saved : null) if (settings.isError && !schedule) { return void settings.refetch()}>Couldn't load the household settings. {settings.error.message} } diff --git a/web/src/shopping/api.ts b/web/src/shopping/api.ts index 343b4f8..e9b5430 100644 --- a/web/src/shopping/api.ts +++ b/web/src/shopping/api.ts @@ -7,7 +7,7 @@ import { type MutationObserverOptions, type QueryClient, } from '@tanstack/react-query' -import { useEffect, useReducer, useSyncExternalStore } from 'react' +import { useEffect, useReducer, useRef, useSyncExternalStore } from 'react' import { ApiError, useApi, type ApiClient } from '../api/index.ts' import { isRecord } from '../api/errors.ts' import { meQueryKey } from '../AuthBootstrap.tsx' @@ -200,6 +200,10 @@ function toggleKey(weekStart: string) { return ['shopping', weekStart, 'toggle'] as const } +// TOGGLE_SCOPE is the one mutation scope of every toggle of every week, made +// here or replayed from the queue saved on this device. +const TOGGLE_SCOPE = 'shopping toggle' + // Toggles is the toggles of one week's Supply run: shown, the Supply run // with those still pending laid over it, the items whose toggles will sync // once the server can be reached, the latest failed toggle no later toggle @@ -228,12 +232,13 @@ function reachable(network: NetworkStatus): Promise { // made on the Supply run screen (useToggles) or replayed from the queue saved // on this device (useSupplyRunSync). // -// The week's toggles are sent one at a time, in the order made (one -// mutation scope), so the counts each is answered with include every toggle -// before it and none after. While the server cannot be reached — the device -// is offline, or the toggle's PATCH failed with a network error (fetch -// rejected with a TypeError) — a toggle waits, pending, and is sent once it -// can be: those after it wait behind it. Any answer from the server settles +// Toggles are sent one at a time, in the order made, whatever their week +// (one mutation scope, TOGGLE_SCOPE), so the counts each is answered with +// include every toggle before it and none after, and a replayed toggle never +// overlaps another replayed or made here. While the server cannot be +// reached — the device is offline, or the toggle's PATCH failed with a +// network error (fetch rejected with a TypeError) — a toggle waits, pending, +// and is sent once it can be: those after it wait behind it. Any answer from the server settles // it: a refusal (a 400, 404, 409 or any other error response) or a response // that is not the item as toggled fails it, and it is not sent again. Before // a toggle is sent, GET /api/v1/me has answered, so it carries the session's @@ -250,7 +255,7 @@ function toggleOptions( const mutationKey = toggleKey(weekStart) return { mutationKey, - scope: { id: mutationKey.join(' ') }, + scope: { id: TOGGLE_SCOPE }, networkMode: 'always', retry: false, gcTime: Infinity, @@ -312,8 +317,9 @@ function toggleOptions( // the counts it answers with there. Until then the toggle is pending and is // laid over the Supply run shown, so the item and the needed count change at // once; a refused toggle just stops being pending, which puts both back. -// While the server cannot be reached, the items with pending toggles will -// sync. +// An item whose pending toggle was made, or waited, while the server could +// not be reached will sync: it says so until that toggle settles, even once +// the server can be reached again. // // The Supply run and the toggles are read straight from the query and // mutation caches as each render begins, never from the caches' batched @@ -339,6 +345,9 @@ export function useToggles(weekStart: string): Toggles { useMutationState({ filters: { mutationKey } }) const offline = useSyncExternalStore(api.network.subscribe, api.network.offline) const [, renderAgain] = useReducer((n: number) => n + 1, 0) + // The keys of the pending toggles seen while the server could not be + // reached: every change of the network status renders again. + const waited = useRef>(new Set()) const run = queryClient.getQueryData(key) if (!run) throw new Error(`The week of ${weekStart} has no Supply run read to toggle items of.`) const toggles = queryClient @@ -346,13 +355,14 @@ export function useToggles(weekStart: string): Toggles { .findAll({ mutationKey }) .map((mutation) => ({ status: mutation.state.status, toggle: mutation.state.variables as KeyedToggle, error: mutation.state.error })) const pending = toggles.filter((t) => t.status === 'pending').map((t) => t.toggle) + waited.current = new Set(pending.filter((t) => offline || waited.current.has(t.key)).map((t) => t.key)) const failed = toggles .filter((t, i) => t.status === 'error' && !toggles.slice(i + 1).some((later) => later.toggle.id === t.toggle.id)) .at(-1) const { mutate } = useMutation(toggleOptions(weekStart, api, queryClient)) return { shown: shown(run, pending), - willSync: new Set(offline ? pending.map((toggle) => toggle.id) : []), + willSync: new Set(pending.filter((t) => waited.current.has(t.key)).map((t) => t.id)), failed: failed === undefined ? null : { toggle: { id: failed.toggle.id, needed: failed.toggle.needed }, error: failed.error! }, toggle: ({ id, needed }) => { const queuedToggle = { key: crypto.randomUUID(), weekStart, id, needed } @@ -367,9 +377,18 @@ export function useToggles(weekStart: string): Toggles { // to open offline — the household's schedule and each Supply run the server // sends (saveRun) — and replays, in the order made, the toggles saved in the // queue that are not already pending here: those of a page reloaded before -// the server answered them. A replayed toggle's answer is written over the -// Supply run saved, so the week's Supply run is cached from it first; it -// is still read again (updatedAt 0) when the Supply run screen opens. +// the server answered them. +// +// Replay is at-least-once, not exactly-once: a toggle leaves the saved queue +// only once its answer settles here, so one the server applied just before a +// reload is sent again. That duplicate is safe because a toggle sets needed +// to a fixed value — the same PATCH applied twice leaves the item and the +// counts as applying it once does — so no key is sent for the server to +// deduplicate by. +// +// A replayed toggle's answer is written over the Supply run saved, so the +// week's Supply run is cached from it first; it is still read again +// (updatedAt 0) when the Supply run screen opens. export function useSupplyRunSync() { const api = useApi() const queryClient = useQueryClient() diff --git a/web/src/shopping/offlineQueue.test.tsx b/web/src/shopping/offlineQueue.test.tsx index 8ec514c..e711612 100644 --- a/web/src/shopping/offlineQueue.test.tsx +++ b/web/src/shopping/offlineQueue.test.tsx @@ -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 = { '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) { + 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((input, init) => { - if (!onLine || !reachable) return Promise.reject(new TypeError('Failed to fetch')) + const fetchMock = vi.fn(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((resolve) => waiting.push(resolve)), + waiting: () => waiting.length, + release: () => waiting.shift()!(), + } +} + // patches lists the PATCHes sent, as "path body". function patches(fetchMock: Mock) { 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) + }) +})