Offline toggle queue for the Supply run (Feature 17) #138

3 changed files with 194 additions and 40 deletions
Showing only changes of commit 4ba6471070 - Show all commits
+5 -1
View File
@@ -1,4 +1,5 @@
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react' import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router' import { Link, useSearchParams } from 'react-router'
import { Unavailable } from '../plan/PlanPage.tsx' import { Unavailable } from '../plan/PlanPage.tsx'
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts' import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
@@ -249,12 +250,15 @@ function ChosenSupplyRun() {
const settings = useHouseholdSettings() const settings = useHouseholdSettings()
const [params] = useSearchParams() const [params] = useSearchParams()
const chosen = params.get(WEEK_PARAM) 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)) { if (chosen !== null && !isMonday(chosen)) {
return <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner> return <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner>
} }
// fetch rejects with a TypeError only when the network fails. // 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) { if (settings.isError && !schedule) {
return <ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner> return <ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
} }
+33 -14
View File
@@ -7,7 +7,7 @@ import {
type MutationObserverOptions, type MutationObserverOptions,
type QueryClient, type QueryClient,
} from '@tanstack/react-query' } 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 { ApiError, useApi, type ApiClient } from '../api/index.ts'
import { isRecord } from '../api/errors.ts' import { isRecord } from '../api/errors.ts'
import { meQueryKey } from '../AuthBootstrap.tsx' import { meQueryKey } from '../AuthBootstrap.tsx'
@@ -200,6 +200,10 @@ function toggleKey(weekStart: string) {
return ['shopping', weekStart, 'toggle'] as const 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 // 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 // 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 // once the server can be reached, the latest failed toggle no later toggle
@@ -228,12 +232,13 @@ function reachable(network: NetworkStatus): Promise<void> {
// made on the Supply run screen (useToggles) or replayed from the queue saved // made on the Supply run screen (useToggles) or replayed from the queue saved
// on this device (useSupplyRunSync). // on this device (useSupplyRunSync).
// //
// The week's toggles are sent one at a time, in the order made (one // Toggles are sent one at a time, in the order made, whatever their week
// mutation scope), so the counts each is answered with include every toggle // (one mutation scope, TOGGLE_SCOPE), so the counts each is answered with
// before it and none after. While the server cannot be reached — the device // include every toggle before it and none after, and a replayed toggle never
// is offline, or the toggle's PATCH failed with a network error (fetch // overlaps another replayed or made here. While the server cannot be
// rejected with a TypeError) — a toggle waits, pending, and is sent once it // reached — the device is offline, or the toggle's PATCH failed with a
// can be: those after it wait behind it. Any answer from the server settles // 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 // 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 // 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 // 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) const mutationKey = toggleKey(weekStart)
return { return {
mutationKey, mutationKey,
scope: { id: mutationKey.join(' ') }, scope: { id: TOGGLE_SCOPE },
networkMode: 'always', networkMode: 'always',
retry: false, retry: false,
gcTime: Infinity, gcTime: Infinity,
@@ -312,8 +317,9 @@ function toggleOptions(
// the counts it answers with there. Until then the toggle is pending and is // 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 // 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. // once; a refused toggle just stops being pending, which puts both back.
// While the server cannot be reached, the items with pending toggles will // An item whose pending toggle was made, or waited, while the server could
// sync. // 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 // The Supply run and the toggles are read straight from the query and
// mutation caches as each render begins, never from the caches' batched // mutation caches as each render begins, never from the caches' batched
@@ -339,6 +345,9 @@ export function useToggles(weekStart: string): Toggles {
useMutationState({ filters: { mutationKey } }) useMutationState({ filters: { mutationKey } })
const offline = useSyncExternalStore(api.network.subscribe, api.network.offline) const offline = useSyncExternalStore(api.network.subscribe, api.network.offline)
const [, renderAgain] = useReducer((n: number) => n + 1, 0) 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<ReadonlySet<string>>(new Set())
const run = queryClient.getQueryData<SupplyRun | null>(key) const run = queryClient.getQueryData<SupplyRun | null>(key)
if (!run) throw new Error(`The week of ${weekStart} has no Supply run read to toggle items of.`) if (!run) throw new Error(`The week of ${weekStart} has no Supply run read to toggle items of.`)
const toggles = queryClient const toggles = queryClient
@@ -346,13 +355,14 @@ export function useToggles(weekStart: string): Toggles {
.findAll({ mutationKey }) .findAll({ mutationKey })
.map((mutation) => ({ status: mutation.state.status, toggle: mutation.state.variables as KeyedToggle, error: mutation.state.error })) .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) 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 const failed = toggles
.filter((t, i) => t.status === 'error' && !toggles.slice(i + 1).some((later) => later.toggle.id === t.toggle.id)) .filter((t, i) => t.status === 'error' && !toggles.slice(i + 1).some((later) => later.toggle.id === t.toggle.id))
.at(-1) .at(-1)
const { mutate } = useMutation(toggleOptions(weekStart, api, queryClient)) const { mutate } = useMutation(toggleOptions(weekStart, api, queryClient))
return { return {
shown: shown(run, pending), 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! }, failed: failed === undefined ? null : { toggle: { id: failed.toggle.id, needed: failed.toggle.needed }, error: failed.error! },
toggle: ({ id, needed }) => { toggle: ({ id, needed }) => {
const queuedToggle = { key: crypto.randomUUID(), weekStart, 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 // 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 // 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 // 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 // the server answered them.
// 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. // 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() { export function useSupplyRunSync() {
const api = useApi() const api = useApi()
const queryClient = useQueryClient() const queryClient = useQueryClient()
+156 -25
View File
@@ -21,6 +21,18 @@ const ITEMS = [
{ id: 'i3', name: 'Taco seasoning', qty: '1 packet', needed: false }, { 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 // The browser's online flag; goOffline and goOnline change it and send the
// page's window the event a browser sends. // page's window the event a browser sends.
let onLine: boolean let onLine: boolean
@@ -53,51 +65,78 @@ function goOnline() {
act(() => void win.dispatchEvent(new Event('online'))) act(() => void win.dispatchEvent(new Event('online')))
} }
// stubServer is the server's Supply run of the week: a PATCH marks its item // stubServer is the server's Supply runs of this week and next: a PATCH
// and answers with its counts, unless refuse answers it instead. It counts // marks its item and answers with its week's counts, unless refuse answers
// as archived once archive is called. // it instead; when hold is given, the item is marked at once but the answer
function stubServer(refuse: (id: string) => Response | undefined = () => undefined) { // waits for the promise hold returns. This week's counts as archived once
const needed = new Map(ITEMS.map((item) => [item.id, item.needed])) // 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' let status = 'current'
const counts = () => ({ needed_count: [...needed.values()].filter(Boolean).length, total_count: needed.size }) // How many PATCHes are unanswered now, and the most there have been at once.
const run = () => ({ let inFlight = 0
weekStart: '2026-10-05', let mostInFlight = 0
label: 'Week 41 · Oct 5 – 11', 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, title: null,
status, status: week === '2026-10-05' ? status : 'current',
sent_to_chat_at: null, sent_to_chat_at: null,
...counts(), ...counts(week),
groups: [ groups: [
{ {
dinner: { id: 'd0', name: 'Beef tacos' }, dinner: { id: week === '2026-10-05' ? 'd0' : 'd1', name: week === '2026-10-05' ? 'Beef tacos' : 'Chicken curry' },
date: '2026-10-05', date: week,
items: ITEMS.map((item) => ({ ...item, needed: needed.get(item.id)! })), items: itemsOf(week).map((item) => ({ ...item, needed: needed.get(item.id)! })),
}, },
], ],
}) })
const fetchMock = vi.fn<typeof fetch>((input, init) => { const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
if (!onLine || !reachable) return Promise.reject(new TypeError('Failed to fetch')) if (!onLine || !reachable) throw new TypeError('Failed to fetch')
const key = `${init?.method ?? 'GET'} ${String(input)}` const key = `${init?.method ?? 'GET'} ${String(input)}`
if (key === 'GET /api/v1/me') return Promise.resolve(jsonResponse(200, ME)) if (key === 'GET /api/v1/me') return jsonResponse(200, ME)
if (key === 'GET /api/v1/settings/household') return Promise.resolve(jsonResponse(200, HOUSEHOLD_SETTINGS)) if (key === 'GET /api/v1/settings/household') return jsonResponse(200, HOUSEHOLD_SETTINGS)
if (key === `GET ${THIS_WEEK}`) return Promise.resolve(jsonResponse(200, run())) if (key === `GET ${THIS_WEEK}`) return jsonResponse(200, run('2026-10-05'))
const item = ITEMS.find((i) => key === `PATCH ${ITEM(i.id)}`) const week = Object.keys(WEEKS).find((w) => itemsOf(w).some((i) => key === `PATCH ${ITEM(i.id)}`))
if (item === undefined) throw new Error(`unexpected fetch: ${key}`) if (week === undefined) throw new Error(`unexpected fetch: ${key}`)
const refusal = refuse(item.id) const id = itemsOf(week).find((i) => key === `PATCH ${ITEM(i.id)}`)!.id
if (refusal !== undefined) return Promise.resolve(refusal) const refusal = refuse(id)
if (refusal !== undefined) return refusal
const body = JSON.parse(String(init?.body)) as { needed: boolean } const body = JSON.parse(String(init?.body)) as { needed: boolean }
needed.set(item.id, body.needed) needed.set(id, body.needed)
return Promise.resolve(jsonResponse(200, { id: item.id, needed: body.needed, ...counts() })) 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) vi.stubGlobal('fetch', fetchMock)
return { return {
fetchMock, fetchMock,
run,
mostInFlight: () => mostInFlight,
archive: () => { archive: () => {
status = 'archived' 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". // patches lists the PATCHes sent, as "path body".
function patches(fetchMock: Mock<typeof fetch>) { function patches(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls.filter(([, init]) => init?.method === 'PATCH').map(([input, init]) => `${String(input)} ${String(init?.body)}`) return fetchMock.mock.calls.filter(([, init]) => init?.method === 'PATCH').map(([input, init]) => `${String(input)} ${String(init?.body)}`)
@@ -145,6 +184,7 @@ beforeEach(() => {
}) })
afterEach(() => { afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers() vi.useRealTimers()
vi.unstubAllGlobals() vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia') Reflect.deleteProperty(window, 'matchMedia')
@@ -277,3 +317,94 @@ describe('offline toggle queue', () => {
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`])) 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)
})
})