Files
MealQuest/web/src/shopping/api.ts
T
dev_agentandClaude Opus 5.5 c2a63192a6
CI / ci (push) Successful in 8m9s
CI / ci (pull_request) Successful in 8m7s
Mark unfrozen Supply archive posts and open them in the Supply run
The Supply archive presented every post as a frozen, read-only list, even a
past week's Supply run that week_rollover had not frozen and that the
Supply run route could still change. Each archive post now carries frozen;
a post not yet frozen is marked (Current, or Still open for a past week)
and links to the Supply run, where it can still change. Only frozen posts
open read-only in the archive.

Maestro task 29b629aa-1ad9-48a3-be75-0380b645d4fe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:35:55 -04:00

610 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { settleUnmadePushes } from '../plan/api.ts'
import type { NetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS_KEY, type HouseholdSettings } from '../settings/api.ts'
import {
dequeue,
enqueue,
keep,
markWillSync,
queued,
SAVED_KEY,
saveRun,
savedRun,
saveSchedule,
synced,
willSync,
} from './offline.ts'
export const SHOPPING_PATH = '/api/v1/shopping'
// WEEK_PARAM names the search parameter choosing the week the Supply run
// screen shows, its Monday as YYYY-MM-DD: /plan/shopping?week=2026-09-28.
export const WEEK_PARAM = 'week'
// supplyRunScreen is the Supply run screen on the week starting on Monday
// weekStart.
export function supplyRunScreen(weekStart: string): string {
return `/plan/shopping?${new URLSearchParams({ [WEEK_PARAM]: weekStart })}`
}
// supplyRunPath is the Supply run of the week starting on Monday weekStart.
export function supplyRunPath(weekStart: string): string {
return `${SHOPPING_PATH}/${encodeURIComponent(weekStart)}`
}
// supplyRunKey is the query of the Supply run of the week starting on
// weekStart.
export function supplyRunKey(weekStart: string) {
return ['shopping', weekStart] as const
}
export const SUPPLY_RUN_STATUSES = ['current', 'archived'] as const
// SupplyRunStatus is current until the week ends and the Supply run is
// frozen into the Supply archive: archived, after which nothing on it
// changes.
export type SupplyRunStatus = (typeof SUPPLY_RUN_STATUSES)[number]
// SupplyRunItem is one ingredient: qty is null when the recipe gives none,
// and needed is false once it is marked "have it".
export type SupplyRunItem = { id: string; name: string; qty: string | null; needed: boolean }
// SupplyRunGroup is the items of one plan day's dinner: date is the plan
// day, household-local YYYY-MM-DD.
export type SupplyRunGroup = { dinner: { id: string; name: string }; date: string; items: SupplyRunItem[] }
// Counts is how many of a Supply run's items are still to buy (needed) of
// all of them (total).
export type Counts = { needed_count: number; total_count: number }
// SupplyRun is GET /api/v1/shopping/{weekStart}: label is the week label,
// "Week 41 · Oct 5 – 11", title the AI's title once written,
// sent_to_chat_at when it was sent to Google Chat, if it was, and
// chat_send_unconfirmed_at when a send whose outcome is not known began,
// if one did: it may or may not have reached Google Chat.
export type SupplyRun = Counts & {
weekStart: string
label: string
title: string | null
status: SupplyRunStatus
sent_to_chat_at: Date | null
chat_send_unconfirmed_at: Date | null
groups: SupplyRunGroup[]
}
function count(where: string, field: string, value: unknown): number {
if (!Number.isInteger(value) || (value as number) < 0) throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not a count`)
return value as number
}
function parseCounts(where: string, body: Record<string, unknown>): Counts {
const counts = { needed_count: count(where, 'needed_count', body.needed_count), total_count: count(where, 'total_count', body.total_count) }
if (counts.needed_count > counts.total_count) throw new Error(`${where}: needed_count is more than total_count`)
return counts
}
function parseItem(where: string, body: unknown): SupplyRunItem {
if (
!isRecord(body) ||
typeof body.id !== 'string' ||
typeof body.name !== 'string' ||
(body.qty !== null && typeof body.qty !== 'string') ||
typeof body.needed !== 'boolean'
) {
throw new Error(`${where}: response has an item that is not a Supply run item`)
}
return { id: body.id, name: body.name, qty: body.qty, needed: body.needed }
}
function parseGroup(where: string, body: unknown): SupplyRunGroup {
if (
!isRecord(body) ||
!isRecord(body.dinner) ||
typeof body.dinner.id !== 'string' ||
typeof body.dinner.name !== 'string' ||
typeof body.date !== 'string' ||
!Array.isArray(body.items)
) {
throw new Error(`${where}: response has a group that is not a dinner's items`)
}
return {
dinner: { id: body.dinner.id, name: body.dinner.name },
date: body.date,
items: body.items.map((item) => parseItem(where, item)),
}
}
// instant reads field, an RFC 3339 instant or null.
function instant(where: string, field: string, value: unknown): Date | null {
if (value === null) return null
const at = typeof value === 'string' ? new Date(value) : null
if (at === null || Number.isNaN(at.getTime())) {
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not an instant`)
}
return at
}
export function parseSupplyRun(where: string, weekStart: string, body: unknown): SupplyRun {
if (
!isRecord(body) ||
body.weekStart !== weekStart ||
typeof body.label !== 'string' ||
(body.title !== null && typeof body.title !== 'string') ||
!Array.isArray(body.groups)
) {
throw new Error(`${where}: response is not the Supply run of the week of ${weekStart}`)
}
return {
weekStart,
label: body.label,
title: body.title,
status: oneOf(where, 'status', SUPPLY_RUN_STATUSES, body.status),
sent_to_chat_at: instant(where, 'sent_to_chat_at', body.sent_to_chat_at),
chat_send_unconfirmed_at: instant(where, 'chat_send_unconfirmed_at', body.chat_send_unconfirmed_at),
...parseCounts(where, body),
groups: body.groups.map((group) => parseGroup(where, group)),
}
}
// 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<SupplyRun | null> => {
const path = supplyRunPath(weekStart)
let body: unknown
try {
body = (await apiRead<unknown>(path)).body
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null
throw error
}
return parseSupplyRun(`GET ${path}`, weekStart, body)
},
})
}
// useGenerateSupplyRun makes the week's Supply run from its plan (POST
// /api/v1/shopping/{weekStart}/generate); the Supply run it answers with is
// the one shown. Once made, from whichever screen, a push of the week whose
// Supply run was not made is settled (settleUnmadePushes).
export function useGenerateSupplyRun(weekStart: string) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
onMutate: () => settleUnmadePushes(queryClient, weekStart),
mutationFn: async (): Promise<SupplyRun> => {
const path = `${supplyRunPath(weekStart)}/generate`
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
},
onSuccess: (run, _, settle) => {
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
settle()
},
})
}
// useSendToChat posts the week's Supply run to Google Chat (POST
// /api/v1/shopping/{weekStart}/send-chat); the Supply run it answers with,
// sent_to_chat_at recorded, is the one shown. A week already sent, or with
// a send unconfirmed, is posted again only with resend: without it the
// server answers with the Supply run as it is. A send that fails re-reads
// the Supply run, so a send left unconfirmed shows as such.
export function useSendToChat(weekStart: string) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (resend: boolean): Promise<SupplyRun> => {
const path = `${supplyRunPath(weekStart)}/send-chat`
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST', body: { resend } }))
},
onSuccess: (run) => {
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
},
onError: () => queryClient.invalidateQueries({ queryKey: supplyRunKey(weekStart) }),
})
}
// 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
}
// 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
// of its item has superseded, and toggle to make another.
export type Toggles = {
shown: SupplyRun
willSync: ReadonlySet<string>
failed: { toggle: Toggle; error: Error } | null
toggle: (toggle: Toggle) => void
}
// TOGGLE_DEADLINE is how long, in milliseconds, one try of a toggle — its
// GET /api/v1/me, its PATCH and the /me refresh a csrf 403 to it makes — may
// take before it counts as the server not answering. Only toggles have a
// deadline: the API client sets none.
export const TOGGLE_DEADLINE = 10_000
// RETRY_FIRST and RETRY_CEILING bound, in milliseconds, how long a toggle
// waits while the server cannot be reached before it is tried again anyway:
// the first wait, doubled after each failed try up to the ceiling.
export const RETRY_FIRST = 1_000
export const RETRY_CEILING = 30_000
// reachable resolves once network says the server can be reached — at once
// when it says so now — or after delay milliseconds, whichever is first. The
// server coming back says nothing by itself, and the browser sends no online
// event when it never thought it was offline, so the toggle is tried again
// after delay whatever network says.
function reachable(network: NetworkStatus, delay: number): Promise<void> {
if (!network.offline()) return Promise.resolve()
return new Promise((resolve) => {
const done = () => {
clearTimeout(timer)
stop()
resolve()
}
const timer = setTimeout(done, delay)
const stop = network.subscribe(() => {
if (!network.offline()) done()
})
})
}
// 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).
//
// 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), or a try ran past its
// deadline (TOGGLE_DEADLINE) — a toggle waits, pending, and is tried again
// once network says the server can be reached, or after a wait that doubles
// from RETRY_FIRST up to RETRY_CEILING, whichever is first: those after it
// wait behind it, in order. A try past its deadline is aborted and marks the
// network failed, as a network error does, so the item says it will sync; the
// server may have applied the aborted PATCH, and sending it again is safe for
// the reason a replay is (see useSupplyRunSync). 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.
//
// 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<Counts, Error, KeyedToggle> {
const key = supplyRunKey(weekStart)
const mutationKey = toggleKey(weekStart)
return {
mutationKey,
scope: { id: TOGGLE_SCOPE },
networkMode: 'always',
retry: false,
gcTime: Infinity,
mutationFn: async ({ key, id, needed }: KeyedToggle): Promise<Counts> => {
const path = `${SHOPPING_PATH}/items/${encodeURIComponent(id)}`
const where = `PATCH ${path}`
let body: unknown
for (let failures = 0; ; failures++) {
if (api.network.offline()) markWillSync(key)
await reachable(api.network, Math.min(RETRY_FIRST * 2 ** failures, RETRY_CEILING))
const deadline = new AbortController()
const timer = setTimeout(() => deadline.abort(new DOMException(`${where}: no answer in time`, 'TimeoutError')), TOGGLE_DEADLINE)
try {
// Each try loads /me itself, under its deadline, rather than share
// a /me already in flight that may never answer.
if (queryClient.getQueryData(meQueryKey) === undefined) {
queryClient.setQueryData(meQueryKey, await api.loadMe(deadline.signal))
}
body = await api.apiFetch<unknown>(path, { method: 'PATCH', body: { needed }, signal: deadline.signal })
break
} catch (error) {
if (deadline.signal.aborted) {
api.network.requestFailed()
continue
}
// 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
} finally {
clearTimeout(timer)
}
}
if (!isRecord(body) || body.id !== id || body.needed !== needed) throw new Error(`${where}: response is not item ${id} as toggled`)
return parseCounts(where, body)
},
// A read of the Supply run sent before the toggle must not land after
// its answer, over it.
onMutate: () => queryClient.cancelQueries({ queryKey: key }),
onSuccess: (counts, { id, needed }) => {
queryClient.setQueryData<SupplyRun | null>(
key,
(run) =>
run && {
...run,
...counts,
groups: run.groups.map((group) => ({
...group,
items: group.items.map((item) => (item.id === id ? { ...item, needed } : item)),
})),
},
)
},
onError: (error) => {
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. 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 KeyedToggle).id === id) cache.remove(earlier)
}
keep(() => dequeue(settled))
synced(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.
// 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, and even when the screen is left and
// opened again meanwhile (markWillSync keeps it with the page, not here).
//
// 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)
// Every change of the network status renders again, so each pending
// toggle seen while the server cannot be reached is marked.
if (offline) pending.forEach((t) => markWillSync(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(pending.filter((t) => willSync(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 }
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.
//
// 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()
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.
function shown(run: SupplyRun, pending: Toggle[]): SupplyRun {
const marked = new Map(pending.map((toggle) => [toggle.id, toggle.needed]))
let needed = run.needed_count
const groups = run.groups.map((group) => ({
...group,
items: group.items.map((item) => {
const mark = marked.get(item.id)
if (mark === undefined || mark === item.needed) return item
needed += mark ? 1 : -1
return { ...item, needed: mark }
}),
}))
return { ...run, needed_count: needed, groups }
}
export const ARCHIVE_PERIODS = ['week', 'month', 'year'] as const
// ArchivePeriod is what the Supply archive is grouped by.
export type ArchivePeriod = (typeof ARCHIVE_PERIODS)[number]
// ArchivePost is one week's Supply run in the Supply archive: label is the
// week label, "Week 41 · Oct 5 – 11", which stands in for title while it is
// null; onHandCount counts the items marked "have it", and dinners names the
// dinners in day order. current is true for the household's current week,
// and frozen once the list is frozen into the Supply archive: until then it
// can still be changed from the Supply run.
export type ArchivePost = {
weekStart: string
label: string
title: string | null
dinnerCount: number
itemCount: number
onHandCount: number
dinners: string[]
current: boolean
frozen: boolean
}
// ArchiveGroup is one period of the Supply archive — a week, a month
// ("October 2026") or a year ("2026") — and its posts, newest first.
export type ArchiveGroup = { key: string; heading: string; posts: ArchivePost[] }
function parsePost(where: string, body: unknown): ArchivePost {
if (
!isRecord(body) ||
typeof body.weekStart !== 'string' ||
typeof body.label !== 'string' ||
(body.title !== null && typeof body.title !== 'string') ||
!Array.isArray(body.dinners) ||
!body.dinners.every((name) => typeof name === 'string') ||
typeof body.current !== 'boolean' ||
typeof body.frozen !== 'boolean'
) {
throw new Error(`${where}: response has a post that is not a Supply archive post`)
}
return {
weekStart: body.weekStart,
label: body.label,
title: body.title,
dinnerCount: count(where, 'dinnerCount', body.dinnerCount),
itemCount: count(where, 'itemCount', body.itemCount),
onHandCount: count(where, 'onHandCount', body.onHandCount),
dinners: body.dinners as string[],
current: body.current,
frozen: body.frozen,
}
}
function parseArchiveGroup(where: string, body: unknown): ArchiveGroup {
if (!isRecord(body) || typeof body.key !== 'string' || typeof body.heading !== 'string' || !Array.isArray(body.posts)) {
throw new Error(`${where}: response has a period that is not a Supply archive period`)
}
return { key: body.key, heading: body.heading, posts: body.posts.map((post) => parsePost(where, post)) }
}
// useSupplyArchive is the Supply archive grouped by by, newest first (GET
// /api/v1/shopping/archive?by=week|month|year): the server groups it in the
// household's zone, so it is shown as answered, never grouped again here.
export function useSupplyArchive(by: ArchivePeriod) {
const { apiRead } = useApi()
return useQuery({
queryKey: ['supply archive', by] as const,
queryFn: async (): Promise<ArchiveGroup[]> => {
const path = `${SHOPPING_PATH}/archive?${new URLSearchParams({ by })}`
const where = `GET ${path}`
const { body } = await apiRead<unknown>(path)
if (!isRecord(body) || body.by !== by || !Array.isArray(body.periods)) {
throw new Error(`${where}: response is not the Supply archive by ${by}`)
}
return body.periods.map((period) => parseArchiveGroup(where, period))
},
})
}