Offline toggle queue for the Supply run (Feature 17) #138
@@ -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 <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner>
|
||||
}
|
||||
// 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 <ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
|
||||
}
|
||||
|
||||
+33
-14
@@ -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<void> {
|
||||
// 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<ReadonlySet<string>>(new Set())
|
||||
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.`)
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user