Queue Supply run toggles made offline and replay them in order (Feature 17)
CI / ci (push) Successful in 4m57s
CI / ci (pull_request) Successful in 5m5s

A toggle made while the server cannot be reached (the browser is offline,
or its PATCH fails with a network error) now stays pending in the existing
serial toggle mutation, laid over the list with a "will sync" marker, and
is sent once the network status says the server is reachable again. Every
toggle is saved in order in localStorage until it settles, and
SupplyRunSync replays the saved ones after a reload. Any answer from the
server settles a toggle: a 409 archived discards it, puts the count back
and shows the refusal; other refusals surface as before and are never
queued.

The Supply run and the household schedule are saved on the device too, so
the Supply run screen opens offline from the saved list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 09:58:49 -04:00
co-authored by Claude Opus 5.5
parent 5d7906ae0d
commit b7608d0a9a
6 changed files with 655 additions and 64 deletions
+2
View File
@@ -16,6 +16,7 @@ import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx' import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
import { SettingsPage } from './settings/SettingsPage.tsx' import { SettingsPage } from './settings/SettingsPage.tsx'
import { AppShell } from './shell/AppShell.tsx' import { AppShell } from './shell/AppShell.tsx'
import { SupplyRunSync } from './shopping/api.ts'
import { SupplyRunPage } from './shopping/SupplyRunPage.tsx' import { SupplyRunPage } from './shopping/SupplyRunPage.tsx'
// Every route the app serves, with the heading its placeholder page shows. // Every route the app serves, with the heading its placeholder page shows.
@@ -88,6 +89,7 @@ export default function App({ api, serviceWorker }: AppProps) {
<ApiProvider client={api}> <ApiProvider client={api}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthBootstrap> <AuthBootstrap>
<SupplyRunSync />
<AppRoutes /> <AppRoutes />
</AuthBootstrap> </AuthBootstrap>
<OfflinePill /> <OfflinePill />
+15
View File
@@ -200,6 +200,21 @@
color: var(--color-neutral-700); color: var(--color-neutral-700);
} }
/* Toggled while the server cannot be reached: the change is saved here and
sent once it can be. */
.willSync {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
text-decoration: none;
vertical-align: middle;
}
.qty { .qty {
flex: none; flex: none;
font-family: var(--font-body); font-family: var(--font-body);
+38 -9
View File
@@ -1,4 +1,4 @@
import { Archive, Check, MessageSquare } from 'lucide-react' import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-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'
@@ -20,6 +20,7 @@ import {
type SupplyRunItem, type SupplyRunItem,
type Toggles, type Toggles,
} from './api.ts' } from './api.ts'
import { keep, savedSchedule, useSavedProblem } from './offline.ts'
import styles from './SupplyRunPage.module.css' import styles from './SupplyRunPage.module.css'
// SUPPLY_ARCHIVE is the Supply archive screen. // SUPPLY_ARCHIVE is the Supply archive screen.
@@ -39,9 +40,20 @@ export function sentAt(at: Date, timezone: string): string {
// ItemRow is one ingredient: on a current Supply run a checkbox, checked // ItemRow is one ingredient: on a current Supply run a checkbox, checked
// (grape) while it is to buy; unchecked, the name is struck through and // (grape) while it is to buy; unchecked, the name is struck through and
// "have it" stands in for the quantity. An archived Supply run shows the // "have it" stands in for the quantity. An archived Supply run shows the
// same marks, read-only. // same marks, read-only. An item toggled while the server cannot be reached
// says it will sync.
function ItemRow({ item, toggles }: { item: SupplyRunItem; toggles: Toggles | null }) { function ItemRow({ item, toggles }: { item: SupplyRunItem; toggles: Toggles | null }) {
const name = <span className={styles.name}>{item.name}</span> const name = (
<>
<span className={styles.name}>{item.name}</span>
{toggles?.willSync.has(item.id) && (
<span className={styles.willSync}>
<Icon icon={CloudUpload} size="inline" />
will sync
</span>
)}
</>
)
return ( return (
<li className={[styles.item, !item.needed && styles.have].filter(Boolean).join(' ')}> <li className={[styles.item, !item.needed && styles.have].filter(Boolean).join(' ')}>
{toggles === null ? ( {toggles === null ? (
@@ -194,7 +206,9 @@ function WeekSupplyRun({ weekStart, timezone, layout }: { weekStart: string; tim
const run = useSupplyRun(weekStart) const run = useSupplyRun(weekStart)
const generate = useGenerateSupplyRun(weekStart) const generate = useGenerateSupplyRun(weekStart)
if (run.isError) { // Offline, the Supply run saved on this device stays shown; the offline
// pill says it is saved data.
if (run.isError && !(run.data !== undefined && run.error instanceof TypeError)) {
return <ErrorBanner onRetry={() => void run.refetch()}>Couldn't load the shopping list. {run.error.message}</ErrorBanner> return <ErrorBanner onRetry={() => void run.refetch()}>Couldn't load the shopping list. {run.error.message}</ErrorBanner>
} }
if (run.isPending || generate.isPending) return <SkeletonGroups layout={layout} /> if (run.isPending || generate.isPending) return <SkeletonGroups layout={layout} />
@@ -216,8 +230,21 @@ function WeekSupplyRun({ weekStart, timezone, layout }: { weekStart: string; tim
// SupplyRunPage is the Supply run (mock 1l on mobile, 2b on tablet, 2f on // SupplyRunPage is the Supply run (mock 1l on mobile, 2b on tablet, 2f on
// desktop) of the week the Plan opens on — next week once this week's // desktop) of the week the Plan opens on — next week once this week's
// plan_draft time has come, otherwise this week — or of the week chosen by // plan_draft time has come, otherwise this week — or of the week chosen by
// ?week=, such as a week in the Supply archive. // ?week=, such as a week in the Supply archive. Offline, where the settings
// cannot be read, it chooses the week by the schedule saved on this device.
export function SupplyRunPage() { export function SupplyRunPage() {
const problem = useSavedProblem()
return (
<>
{problem !== null && (
<ErrorBanner>Changes to the shopping list can't be kept on this device while offline. {problem.message}</ErrorBanner>
)}
<ChosenSupplyRun />
</>
)
}
function ChosenSupplyRun() {
const layout = useBreakpoint() const layout = useBreakpoint()
const settings = useHouseholdSettings() const settings = useHouseholdSettings()
const [params] = useSearchParams() const [params] = useSearchParams()
@@ -226,12 +253,14 @@ export function SupplyRunPage() {
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>
} }
if (settings.isError) { // fetch rejects with a TypeError only when the network fails.
const schedule = settings.data ?? (settings.error instanceof TypeError ? keep(savedSchedule) : null)
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>
} }
if (!settings.data) return <SkeletonGroups layout={layout} /> if (!schedule) return <SkeletonGroups layout={layout} />
const week = chosen ?? defaultWeek(settings.data, new Date()) const week = chosen ?? defaultWeek(schedule, new Date())
return <WeekSupplyRun key={week} weekStart={week} timezone={settings.data.timezone} layout={layout} /> return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} />
} }
// isMonday says whether date is a real YYYY-MM-DD Monday. // isMonday says whether date is a real YYYY-MM-DD Monday.
+185 -55
View File
@@ -1,8 +1,20 @@
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query' import {
import { useReducer } from 'react' MutationObserver,
import { ApiError, useApi } from '../api/index.ts' useMutation,
useMutationState,
useQuery,
useQueryClient,
type MutationObserverOptions,
type QueryClient,
} from '@tanstack/react-query'
import { useEffect, useReducer, useSyncExternalStore } from 'react'
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 { oneOf } from '../dinners/api.ts' import { oneOf } from '../dinners/api.ts'
import type { NetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS_KEY, type HouseholdSettings } from '../settings/api.ts'
import { dequeue, enqueue, keep, queued, SAVED_KEY, saveRun, savedRun, saveSchedule } from './offline.ts'
export const SHOPPING_PATH = '/api/v1/shopping' export const SHOPPING_PATH = '/api/v1/shopping'
@@ -131,12 +143,22 @@ export function parseSupplyRun(where: string, weekStart: string, body: unknown):
} }
} }
// savedSupplyRun is the week's Supply run saved on this device, or undefined.
function savedSupplyRun(weekStart: string): SupplyRun | undefined {
const saved = savedRun(weekStart)
return saved === undefined ? undefined : parseSupplyRun(`Saved ${SAVED_KEY}`, weekStart, saved)
}
// useSupplyRun is the Supply run of the week starting on Monday weekStart // useSupplyRun is the Supply run of the week starting on Monday weekStart
// (GET /api/v1/shopping/{weekStart}), or null when the week has none (404). // (GET /api/v1/shopping/{weekStart}), or null when the week has none (404).
// It starts from the Supply run saved on this device, if there is one, so
// the list opens offline; the saved one is always read again.
export function useSupplyRun(weekStart: string) { export function useSupplyRun(weekStart: string) {
const { apiRead } = useApi() const { apiRead } = useApi()
return useQuery({ return useQuery({
queryKey: supplyRunKey(weekStart), queryKey: supplyRunKey(weekStart),
initialData: () => keep(() => savedSupplyRun(weekStart)),
initialDataUpdatedAt: 0,
queryFn: async (): Promise<SupplyRun | null> => { queryFn: async (): Promise<SupplyRun | null> => {
const path = supplyRunPath(weekStart) const path = supplyRunPath(weekStart)
let body: unknown let body: unknown
@@ -169,75 +191,85 @@ export function useGenerateSupplyRun(weekStart: string) {
// Toggle is one item marked to buy (needed) or "have it" (not needed). // Toggle is one item marked to buy (needed) or "have it" (not needed).
export type Toggle = { id: string; needed: boolean } export type Toggle = { id: string; needed: boolean }
// KeyedToggle is a toggle as sent: key tells it apart from any other toggle
// of the same item, here and in the queue saved on this device.
type KeyedToggle = Toggle & { key: string }
// toggleKey keys the toggles of the week starting on weekStart. // toggleKey keys the toggles of the week starting on weekStart.
function toggleKey(weekStart: string) { function toggleKey(weekStart: string) {
return ['shopping', weekStart, 'toggle'] as const return ['shopping', weekStart, 'toggle'] as const
} }
// 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 latest failed toggle no later // with those still pending laid over it, the items whose toggles will sync
// toggle of its item has superseded, and toggle to make another. // once the server can be reached, the latest failed toggle no later toggle
// of its item has superseded, and toggle to make another.
export type Toggles = { export type Toggles = {
shown: SupplyRun shown: SupplyRun
willSync: ReadonlySet<string>
failed: { toggle: Toggle; error: Error } | null failed: { toggle: Toggle; error: Error } | null
toggle: (toggle: Toggle) => void toggle: (toggle: Toggle) => void
} }
// useToggles marks items of the week's Supply run, already read, to buy or // reachable resolves once network says the server can be reached: at once
// "have it" (PATCH /api/v1/shopping/items/{id}). The Supply run cached is // when it says so now.
// the server's: a toggle's answer writes the item and the counts it answers function reachable(network: NetworkStatus): Promise<void> {
// with there. Until then the toggle is pending and is laid over the Supply if (!network.offline()) return Promise.resolve()
// run shown, so the item and the needed count change at once; a refused return new Promise((resolve) => {
// toggle just stops being pending, which puts both back. const stop = network.subscribe(() => {
// if (network.offline()) return
// The Supply run and the toggles are read straight from the query and stop()
// mutation caches as each render begins, never from the caches' batched resolve()
// notices, which can arrive apart: a toggle is in the mutation cache as })
// pending as soon as it is made, and its answer is in the query cache })
// before it stops being pending. Making a toggle renders again at once, so }
// the checkbox clicked never shows its old state; useSupplyRun and
// useMutationState render again on every later change. // toggleOptions is the mutation every toggle of the week's Supply run is,
// 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 // 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 // mutation scope), so the counts each is answered with include every toggle
// before it and none after. Each is sent once, and not held back while the // before it and none after. While the server cannot be reached — the device
// device is offline (networkMode always): an item never shows a state for // is offline, or the toggle's PATCH failed with a network error (fetch
// longer than it takes the server to refuse it. // 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
// CSRF token even when the app opened offline.
// //
// A failed toggle stays failed, and shown, until a later toggle of its item // Every toggle is in the queue saved on this device (enqueue, by useToggles)
// supersedes it, such as its Retry: a toggle of another item succeeding // until it settles, so a reload replays those the server never answered.
// does not hide it. So that a failure is never collected unseen, toggles are function toggleOptions(
// kept (gcTime Infinity), and a settled toggle removes the earlier toggles weekStart: string,
// of its item it supersedes. A toggle refused because the week was frozen api: ApiClient,
// into the Supply archive (409 archived) reads the Supply run again, which queryClient: QueryClient,
// then renders read-only. ): MutationObserverOptions<Counts, Error, KeyedToggle> {
export function useToggles(weekStart: string): Toggles {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
const key = supplyRunKey(weekStart) const key = supplyRunKey(weekStart)
const mutationKey = toggleKey(weekStart) const mutationKey = toggleKey(weekStart)
useSupplyRun(weekStart) return {
useMutationState({ filters: { mutationKey } })
const [, renderAgain] = useReducer((n: number) => n + 1, 0)
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
.getMutationCache()
.findAll({ mutationKey })
.map((mutation) => ({ status: mutation.state.status, toggle: mutation.state.variables as Toggle, error: mutation.state.error }))
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({
mutationKey, mutationKey,
scope: { id: mutationKey.join(' ') }, scope: { id: mutationKey.join(' ') },
networkMode: 'always', networkMode: 'always',
retry: false, retry: false,
gcTime: Infinity, gcTime: Infinity,
mutationFn: async ({ id, needed }: Toggle): Promise<Counts> => { mutationFn: async ({ id, needed }: KeyedToggle): Promise<Counts> => {
const path = `${SHOPPING_PATH}/items/${encodeURIComponent(id)}` const path = `${SHOPPING_PATH}/items/${encodeURIComponent(id)}`
const where = `PATCH ${path}` const where = `PATCH ${path}`
const body = await apiFetch<unknown>(path, { method: 'PATCH', body: { needed } }) let body: unknown
for (;;) {
await reachable(api.network)
try {
await queryClient.ensureQueryData({ queryKey: meQueryKey, queryFn: api.loadMe, networkMode: 'always' })
body = await api.apiFetch<unknown>(path, { method: 'PATCH', body: { needed } })
break
} catch (error) {
// The API client marks the network offline as fetch rejects; a
// TypeError it did not mark is not a network failure.
if (!(error instanceof TypeError) || !api.network.offline()) throw error
}
}
if (!isRecord(body) || body.id !== id || body.needed !== needed) throw new Error(`${where}: response is not item ${id} as toggled`) if (!isRecord(body) || body.id !== id || body.needed !== needed) throw new Error(`${where}: response is not item ${id} as toggled`)
return parseCounts(where, body) return parseCounts(where, body)
}, },
@@ -262,24 +294,122 @@ export function useToggles(weekStart: string): Toggles {
if (error instanceof ApiError && error.code === 'archived') void queryClient.invalidateQueries({ queryKey: key }) if (error instanceof ApiError && error.code === 'archived') void queryClient.invalidateQueries({ queryKey: key })
}, },
// This toggle is still pending as it settles, and those after it wait // This toggle is still pending as it settles, and those after it wait
// for it: the settled toggles of its item are the earlier ones. // for it: the settled toggles of its item are the earlier ones. Settled,
onSettled: (_counts, _error, { id }) => { // it leaves the saved queue.
onSettled: (_counts, _error, { key: settled, id }) => {
const cache = queryClient.getMutationCache() const cache = queryClient.getMutationCache()
for (const earlier of cache.findAll({ mutationKey, predicate: (m) => m.state.status !== 'pending' })) { for (const earlier of cache.findAll({ mutationKey, predicate: (m) => m.state.status !== 'pending' })) {
if ((earlier.state.variables as Toggle).id === id) cache.remove(earlier) if ((earlier.state.variables as KeyedToggle).id === id) cache.remove(earlier)
} }
keep(() => dequeue(settled))
}, },
}) }
}
// useToggles marks items of the week's Supply run, already read, to buy or
// "have it" (PATCH /api/v1/shopping/items/{id}; see toggleOptions). The
// Supply run cached is the server's: a toggle's answer writes the item and
// 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.
//
// The Supply run and the toggles are read straight from the query and
// mutation caches as each render begins, never from the caches' batched
// notices, which can arrive apart: a toggle is in the mutation cache as
// pending as soon as it is made, and its answer is in the query cache
// before it stops being pending. Making a toggle renders again at once, so
// the checkbox clicked never shows its old state; useSupplyRun and
// useMutationState render again on every later change.
//
// A failed toggle stays failed, and shown, until a later toggle of its item
// supersedes it, such as its Retry: a toggle of another item succeeding
// does not hide it. So that a failure is never collected unseen, toggles are
// kept (gcTime Infinity), and a settled toggle removes the earlier toggles
// of its item it supersedes. A toggle refused because the week was frozen
// into the Supply archive (409 archived), even one replayed after the device
// was offline, reads the Supply run again, which then renders read-only.
export function useToggles(weekStart: string): Toggles {
const api = useApi()
const queryClient = useQueryClient()
const key = supplyRunKey(weekStart)
const mutationKey = toggleKey(weekStart)
useSupplyRun(weekStart)
useMutationState({ filters: { mutationKey } })
const offline = useSyncExternalStore(api.network.subscribe, api.network.offline)
const [, renderAgain] = useReducer((n: number) => n + 1, 0)
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
.getMutationCache()
.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)
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 { return {
shown: shown(run, toggles.filter((t) => t.status === 'pending').map((t) => t.toggle)), shown: shown(run, pending),
failed: failed === undefined ? null : { toggle: failed.toggle, error: failed.error! }, willSync: new Set(offline ? pending.map((toggle) => toggle.id) : []),
toggle: (toggle) => { failed: failed === undefined ? null : { toggle: { id: failed.toggle.id, needed: failed.toggle.needed }, error: failed.error! },
mutate(toggle) toggle: ({ id, needed }) => {
const queuedToggle = { key: crypto.randomUUID(), weekStart, id, needed }
keep(() => enqueue(queuedToggle))
mutate({ key: queuedToggle.key, id, needed })
renderAgain() renderAgain()
}, },
} }
} }
// useSupplyRunSync keeps, on this device, what the Supply run screen needs
// 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.
export function useSupplyRunSync() {
const api = useApi()
const queryClient = useQueryClient()
useEffect(() => {
const stop = queryClient.getQueryCache().subscribe((event) => {
if (event.type !== 'updated' || event.action.type !== 'success') return
const [scope, week] = event.query.queryKey
const data: unknown = event.query.state.data
if (scope === 'shopping' && typeof week === 'string' && event.query.queryKey.length === 2) {
keep(() => saveRun(week, data as SupplyRun | null))
} else if (scope === HOUSEHOLD_SETTINGS_KEY[0] && week === HOUSEHOLD_SETTINGS_KEY[1]) {
keep(() => saveSchedule(data as HouseholdSettings))
}
})
const pending = new Set(
queryClient
.getMutationCache()
.findAll({ mutationKey: ['shopping'], predicate: (m) => m.state.status === 'pending' })
.map((m) => (m.state.variables as KeyedToggle).key),
)
for (const { key, weekStart, id, needed } of keep(queued) ?? []) {
if (pending.has(key)) continue
const runKey = supplyRunKey(weekStart)
if (queryClient.getQueryData(runKey) === undefined) {
const saved = keep(() => savedSupplyRun(weekStart))
if (saved !== undefined) queryClient.setQueryData(runKey, saved, { updatedAt: 0 })
}
// A refusal is the mutation's state, which the Supply run screen shows.
new MutationObserver(queryClient, toggleOptions(weekStart, api, queryClient)).mutate({ key, id, needed }).catch(() => {})
}
return stop
}, [api, queryClient])
}
// SupplyRunSync runs useSupplyRunSync for the whole app.
export function SupplyRunSync(): null {
useSupplyRunSync()
return null
}
// shown is run as the household sees it with the pending toggles laid over // shown is run as the household sees it with the pending toggles laid over
// it: each item as its latest pending toggle marks it, and the needed count // it: each item as its latest pending toggle marks it, and the needed count
// moved to match. // moved to match.
+136
View File
@@ -0,0 +1,136 @@
import { useSyncExternalStore } from 'react'
import { isRecord } from '../api/errors.ts'
// SAVED_KEY is the localStorage key holding, on this device, what the Supply
// run screen needs to open offline — the household's schedule and the
// Supply runs last read — and the toggles made that the server has not
// answered yet, in the order they were made.
export const SAVED_KEY = 'mealquest.supply-run'
// SAVED_WEEKS is how many weeks' Supply runs are kept: this week's and next
// week's are the only ones still current.
const SAVED_WEEKS = 2
// Schedule is the part of the household settings the Supply run screen
// needs to choose the week and say times. The service worker never caches
// the settings, so it is kept here for the screen to open offline.
export type Schedule = { timezone: string; plan_draft_dow: number; plan_draft_time: string }
// QueuedToggle is a toggle the server has not answered yet: key tells it
// apart from any other toggle of the same item.
export type QueuedToggle = { key: string; weekStart: string; id: string; needed: boolean }
// Saved is the stored value: runs holds each week's Supply run as the server
// last sent it, by its Monday.
type Saved = { schedule: Schedule | null; runs: Record<string, unknown>; queue: QueuedToggle[] }
function isSchedule(value: unknown): value is Schedule {
return (
isRecord(value) &&
typeof value.timezone === 'string' &&
Number.isInteger(value.plan_draft_dow) &&
typeof value.plan_draft_time === 'string'
)
}
function isQueuedToggle(value: unknown): value is QueuedToggle {
return (
isRecord(value) &&
typeof value.key === 'string' &&
typeof value.weekStart === 'string' &&
typeof value.id === 'string' &&
typeof value.needed === 'boolean'
)
}
// read is the stored value, empty when nothing is stored. A value that is not
// one MealQuest stored is an error.
function read(): Saved {
const text = window.localStorage.getItem(SAVED_KEY)
if (text === null) return { schedule: null, runs: {}, queue: [] }
const saved: unknown = JSON.parse(text)
if (
!isRecord(saved) ||
(saved.schedule !== null && !isSchedule(saved.schedule)) ||
!isRecord(saved.runs) ||
!Array.isArray(saved.queue) ||
!saved.queue.every(isQueuedToggle)
) {
throw new Error(`The Supply run saved on this device (${SAVED_KEY}) is not one MealQuest saved.`)
}
return { schedule: saved.schedule as Schedule | null, runs: saved.runs, queue: saved.queue }
}
function write(change: (saved: Saved) => Saved) {
window.localStorage.setItem(SAVED_KEY, JSON.stringify(change(read())))
}
// savedSchedule is the household's schedule as last read, or null.
export function savedSchedule(): Schedule | null {
return read().schedule
}
export function saveSchedule({ timezone, plan_draft_dow, plan_draft_time }: Schedule) {
write((saved) => ({ ...saved, schedule: { timezone, plan_draft_dow, plan_draft_time } }))
}
// savedRun is the week's Supply run as the server last sent it, unparsed, or
// undefined when none is saved.
export function savedRun(weekStart: string): unknown {
return read().runs[weekStart]
}
// saveRun keeps the week's Supply run as the server sent it, with the latest
// weeks'. A week with none (null) or one frozen into the Supply archive is
// not kept: nothing on it can change.
export function saveRun(weekStart: string, run: { status: string } | null) {
write((saved) => {
const runs = { ...saved.runs }
delete runs[weekStart]
if (run !== null && run.status !== 'archived') runs[weekStart] = run
const latest = Object.keys(runs).sort().slice(-SAVED_WEEKS)
return { ...saved, runs: Object.fromEntries(latest.map((week) => [week, runs[week]])) }
})
}
// queued is the toggles the server has not answered, in the order made.
export function queued(): QueuedToggle[] {
return read().queue
}
export function enqueue(toggle: QueuedToggle) {
write((saved) => ({ ...saved, queue: [...saved.queue, toggle] }))
}
export function dequeue(key: string) {
write((saved) => ({ ...saved, queue: saved.queue.filter((toggle) => toggle.key !== key) }))
}
// The latest failure to read or write the saved Supply run, which the Supply
// run screen shows: a toggle that cannot be kept may be lost on a reload.
let problem: Error | null = null
const listeners = new Set<() => void>()
// keep runs one read or write of the saved Supply run, and returns its
// result, or undefined when it failed: the failure is reported to the Supply
// run screen. Listeners hear of it after the current render.
export function keep<T>(action: () => T): T | undefined {
try {
return action()
} catch (error) {
problem = error instanceof Error ? error : new Error(String(error))
queueMicrotask(() => listeners.forEach((listener) => listener()))
return undefined
}
}
function subscribe(listener: () => void) {
listeners.add(listener)
return () => listeners.delete(listener)
}
// useSavedProblem is the latest failure to read or write the saved Supply
// run, or null.
export function useSavedProblem(): Error | null {
return useSyncExternalStore(subscribe, () => problem)
}
+279
View File
@@ -0,0 +1,279 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter } 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, type NetworkWindow } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { SupplyRunSync } from './api.ts'
import { SAVED_KEY } from './offline.ts'
import { SupplyRunPage } from './SupplyRunPage.tsx'
const THIS_WEEK = '/api/v1/shopping/2026-10-05'
const ITEM = (id: string) => `/api/v1/shopping/items/${id}`
// Monday's tacos: two items to buy and one the household has.
const ITEMS = [
{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true },
{ id: 'i2', name: 'Corn tortillas', qty: '12', needed: true },
{ id: 'i3', name: 'Taco seasoning', qty: '1 packet', needed: false },
]
// The browser's online flag; goOffline and goOnline change it and send the
// page's window the event a browser sends.
let onLine: boolean
// The server can be reached: false, every request fails as fetch does when
// the network fails, whatever onLine says.
let reachable: boolean
// The window of the page now open: a reload opens a new one, and the old
// page hears nothing more.
let win: NetworkWindow & EventTarget
function openWindow() {
win = Object.assign(new EventTarget(), {
navigator: {
get onLine() {
return onLine
},
},
}) as unknown as NetworkWindow & EventTarget
return win
}
function goOffline() {
onLine = false
act(() => void win.dispatchEvent(new Event('offline')))
}
function goOnline() {
onLine = true
reachable = true
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]))
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',
title: null,
status,
sent_to_chat_at: null,
...counts(),
groups: [
{
dinner: { id: 'd0', name: 'Beef tacos' },
date: '2026-10-05',
items: ITEMS.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 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)
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() }))
})
vi.stubGlobal('fetch', fetchMock)
return {
fetchMock,
archive: () => {
status = 'archived'
},
}
}
// 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)}`)
}
// queue is the toggles saved on this device, as "id needed".
function queue(): string[] {
const saved = localStorage.getItem(SAVED_KEY)
if (saved === null) return []
return (JSON.parse(saved) as { queue: { id: string; needed: boolean }[] }).queue.map((t) => `${t.id} ${t.needed}`)
}
// openPage opens the Supply run screen in a new window, as the app does:
// with SupplyRunSync, and with /me loaded unless the server cannot be
// reached.
async function openPage() {
const api = createApiClient(fakeLocation('/plan/shopping'), createNetworkStatus(openWindow()), createVerifiedSession(localStorage))
if (onLine && reachable) await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={['/plan/shopping']}>
<SupplyRunSync />
<SupplyRunPage />
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
function checkbox(name: string) {
return screen.getByRole('checkbox', { name: new RegExp(`^${name}`) })
}
function row(name: string) {
return screen.getByText(name).closest('li')!
}
beforeEach(() => {
onLine = true
reachable = true
mockViewport(390)
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
describe('offline toggle queue', () => {
it('applies toggles made offline at once, saves them in order and sends them in that order once online', async () => {
const { fetchMock } = stubServer()
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Taco seasoning'))
fireEvent.click(checkbox('Ground beef'))
fireEvent.click(checkbox('Taco seasoning'))
// Applied here, counted, marked to sync and saved in order; nothing sent.
expect(checkbox('Taco seasoning')).not.toBeChecked()
expect(checkbox('Ground beef')).not.toBeChecked()
expect(screen.getByText('1 / 3')).toBeInTheDocument()
expect(within(row('Taco seasoning')).getByText('will sync')).toBeInTheDocument()
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(within(row('Corn tortillas')).queryByText('will sync')).not.toBeInTheDocument()
expect(queue()).toEqual(['i3 true', 'i1 false', 'i3 false'])
expect(patches(fetchMock)).toEqual([])
goOnline()
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i1')} {"needed":false}`,
`${ITEM('i3')} {"needed":false}`,
])
expect(screen.getByText('1 / 3')).toBeInTheDocument()
expect(checkbox('Taco seasoning')).not.toBeChecked()
expect(checkbox('Ground beef')).not.toBeChecked()
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('queues a toggle whose PATCH fails with a network error and sends it again once back online', async () => {
const { fetchMock } = stubServer()
await openPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
await waitFor(() => expect(queue()).toEqual([]))
expect(screen.getByText('3 / 3')).toBeInTheDocument()
// The browser still says online, but the server cannot be reached.
reachable = false
fireEvent.click(checkbox('Corn tortillas'))
expect(await within(row('Corn tortillas')).findByText('will sync')).toBeInTheDocument()
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i2')} {"needed":false}`])
expect(checkbox('Corn tortillas')).not.toBeChecked()
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(queue()).toEqual(['i2 false'])
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
goOnline()
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i2')} {"needed":false}`, `${ITEM('i2')} {"needed":false}`])
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
})
it('opens the saved list after a reload offline and replays the saved toggles in order once online', async () => {
const { fetchMock } = stubServer()
const first = await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Taco seasoning'))
fireEvent.click(checkbox('Ground beef'))
expect(queue()).toEqual(['i3 true', 'i1 false'])
// Reload while offline: the settings, /me and the list cannot be read.
first.unmount()
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
expect(checkbox('Taco seasoning')).toBeChecked()
expect(checkbox('Ground beef')).not.toBeChecked()
expect(within(row('Taco seasoning')).getByText('will sync')).toBeInTheDocument()
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(patches(fetchMock)).toEqual([])
goOnline()
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(checkbox('Taco seasoning')).toBeChecked()
expect(checkbox('Ground beef')).not.toBeChecked()
})
it('discards a queued toggle the server refuses because the list was archived, puts the count back and says so', async () => {
const server = stubServer(() => errorResponse(409, 'archived'))
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Taco seasoning'))
expect(screen.getByText('3 / 3')).toBeInTheDocument()
server.archive()
goOnline()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. archived message")
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
expect(queue()).toEqual([])
// Sent once, never again.
goOnline()
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`]))
})
it('does not queue a toggle the server refuses: it is put back and says why', async () => {
const server = stubServer(() => errorResponse(400, 'invalid'))
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Ground beef'))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't mark Ground beef “have it”. invalid message")
expect(checkbox('Ground beef')).toBeChecked()
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
expect(queue()).toEqual([])
goOffline()
goOnline()
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`]))
})
})