diff --git a/web/src/App.tsx b/web/src/App.tsx index 17c0a7c..0472284 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,6 +16,7 @@ import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx' import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx' import { SettingsPage } from './settings/SettingsPage.tsx' import { AppShell } from './shell/AppShell.tsx' +import { SupplyRunSync } from './shopping/api.ts' import { SupplyRunPage } from './shopping/SupplyRunPage.tsx' // Every route the app serves, with the heading its placeholder page shows. @@ -88,6 +89,7 @@ export default function App({ api, serviceWorker }: AppProps) { + diff --git a/web/src/shopping/SupplyRunPage.module.css b/web/src/shopping/SupplyRunPage.module.css index 94c0671..b682c0c 100644 --- a/web/src/shopping/SupplyRunPage.module.css +++ b/web/src/shopping/SupplyRunPage.module.css @@ -200,6 +200,21 @@ 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 { flex: none; font-family: var(--font-body); diff --git a/web/src/shopping/SupplyRunPage.tsx b/web/src/shopping/SupplyRunPage.tsx index 4799fa6..6bc4bf5 100644 --- a/web/src/shopping/SupplyRunPage.tsx +++ b/web/src/shopping/SupplyRunPage.tsx @@ -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 { Unavailable } from '../plan/PlanPage.tsx' import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts' @@ -20,6 +20,7 @@ import { type SupplyRunItem, type Toggles, } from './api.ts' +import { keep, savedSchedule, useSavedProblem } from './offline.ts' import styles from './SupplyRunPage.module.css' // 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 // (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 -// 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 }) { - const name = {item.name} + const name = ( + <> + {item.name} + {toggles?.willSync.has(item.id) && ( + + + will sync + + )} + + ) return (
  • {toggles === null ? ( @@ -194,7 +206,9 @@ function WeekSupplyRun({ weekStart, timezone, layout }: { weekStart: string; tim const run = useSupplyRun(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 void run.refetch()}>Couldn't load the shopping list. {run.error.message} } if (run.isPending || generate.isPending) return @@ -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 // 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 -// ?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() { + const problem = useSavedProblem() + return ( + <> + {problem !== null && ( + Changes to the shopping list can't be kept on this device while offline. {problem.message} + )} + + + ) +} + +function ChosenSupplyRun() { const layout = useBreakpoint() const settings = useHouseholdSettings() const [params] = useSearchParams() @@ -226,12 +253,14 @@ export function SupplyRunPage() { if (chosen !== null && !isMonday(chosen)) { return {`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`} } - 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 void settings.refetch()}>Couldn't load the household settings. {settings.error.message} } - if (!settings.data) return - const week = chosen ?? defaultWeek(settings.data, new Date()) - return + if (!schedule) return + const week = chosen ?? defaultWeek(schedule, new Date()) + return } // isMonday says whether date is a real YYYY-MM-DD Monday. diff --git a/web/src/shopping/api.ts b/web/src/shopping/api.ts index 56f8b96..343b4f8 100644 --- a/web/src/shopping/api.ts +++ b/web/src/shopping/api.ts @@ -1,8 +1,20 @@ -import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query' -import { useReducer } from 'react' -import { ApiError, useApi } from '../api/index.ts' +import { + MutationObserver, + 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 { meQueryKey } from '../AuthBootstrap.tsx' 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' @@ -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 // (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) { const { apiRead } = useApi() return useQuery({ queryKey: supplyRunKey(weekStart), + initialData: () => keep(() => savedSupplyRun(weekStart)), + initialDataUpdatedAt: 0, queryFn: async (): Promise => { const path = supplyRunPath(weekStart) 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). 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. function toggleKey(weekStart: string) { return ['shopping', weekStart, 'toggle'] as const } // 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 -// toggle of its item has superseded, and toggle to make another. +// 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 +// of its item has superseded, and toggle to make another. export type Toggles = { shown: SupplyRun + willSync: ReadonlySet failed: { toggle: Toggle; error: Error } | null toggle: (toggle: Toggle) => void } -// useToggles marks items of the week's Supply run, already read, to buy or -// "have it" (PATCH /api/v1/shopping/items/{id}). 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. -// -// 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. +// reachable resolves once network says the server can be reached: at once +// when it says so now. +function reachable(network: NetworkStatus): Promise { + if (!network.offline()) return Promise.resolve() + return new Promise((resolve) => { + const stop = network.subscribe(() => { + if (network.offline()) return + stop() + resolve() + }) + }) +} + +// 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 // 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 -// device is offline (networkMode always): an item never shows a state for -// longer than it takes the server to refuse it. +// 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 +// 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 -// 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) reads the Supply run again, which -// then renders read-only. -export function useToggles(weekStart: string): Toggles { - const { apiFetch } = useApi() - const queryClient = useQueryClient() +// Every toggle is in the queue saved on this device (enqueue, by useToggles) +// until it settles, so a reload replays those the server never answered. +function toggleOptions( + weekStart: string, + api: ApiClient, + queryClient: QueryClient, +): MutationObserverOptions { const key = supplyRunKey(weekStart) const mutationKey = toggleKey(weekStart) - useSupplyRun(weekStart) - useMutationState({ filters: { mutationKey } }) - const [, renderAgain] = useReducer((n: number) => n + 1, 0) - 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 - .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({ + return { mutationKey, scope: { id: mutationKey.join(' ') }, networkMode: 'always', retry: false, gcTime: Infinity, - mutationFn: async ({ id, needed }: Toggle): Promise => { + mutationFn: async ({ id, needed }: KeyedToggle): Promise => { const path = `${SHOPPING_PATH}/items/${encodeURIComponent(id)}` const where = `PATCH ${path}` - const body = await apiFetch(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(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`) 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 }) }, // 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. - onSettled: (_counts, _error, { id }) => { + // for it: the settled toggles of its item are the earlier ones. Settled, + // it leaves the saved queue. + onSettled: (_counts, _error, { key: settled, id }) => { const cache = queryClient.getMutationCache() 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(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 { - shown: shown(run, toggles.filter((t) => t.status === 'pending').map((t) => t.toggle)), - failed: failed === undefined ? null : { toggle: failed.toggle, error: failed.error! }, - toggle: (toggle) => { - mutate(toggle) + shown: shown(run, pending), + willSync: new Set(offline ? pending.map((toggle) => toggle.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 } + keep(() => enqueue(queuedToggle)) + mutate({ key: queuedToggle.key, id, needed }) 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 // it: each item as its latest pending toggle marks it, and the needed count // moved to match. diff --git a/web/src/shopping/offline.ts b/web/src/shopping/offline.ts new file mode 100644 index 0000000..6b1c323 --- /dev/null +++ b/web/src/shopping/offline.ts @@ -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; 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(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) +} diff --git a/web/src/shopping/offlineQueue.test.tsx b/web/src/shopping/offlineQueue.test.tsx new file mode 100644 index 0000000..8ec514c --- /dev/null +++ b/web/src/shopping/offlineQueue.test.tsx @@ -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((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) { + 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( + + + + + + + + , + ) +} + +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}`])) + }) +})