import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import { useApi } from '../api/index.ts' import { isRecord } from '../api/errors.ts' export const DINNERS_PATH = '/api/v1/dinners' export const REGULARS_PATH = `${DINNERS_PATH}?status=regular` // Regular is the part of a dinner the Regulars list reads. pending is true // while an optimistic add waits for the server; its id is not the server's. export type Regular = { id: string; name: string; pending?: true } // CachedRegular is a Regular as the cache holds it. removing is true while // an optimistic removal waits for the server: the entry keeps its place, so // a refused removal returns exactly there whatever else changed meanwhile. type CachedRegular = Regular & { removing?: true } function shown(list: CachedRegular[]): Regular[] { return list.filter((regular) => !regular.removing) } // REGULARS_KEY prefixes every Regulars query, so a change refreshes them all // (the settings home's count included). export const REGULARS_KEY = ['dinners', 'regular'] as const const REGULARS_LIST_KEY = [...REGULARS_KEY, 'list'] as const const REGULARS_MUTATION_KEY = ['dinners', 'regular', 'change'] as const function parseDinner(where: string, body: unknown): Regular { if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') { throw new Error(`${where}: response is not a dinner`) } return { id: body.id, name: body.name } } // useRegulars is the household's Regulars, in the server's order. export function useRegulars() { const { apiFetch } = useApi() return useQuery({ queryKey: REGULARS_LIST_KEY, queryFn: async () => { const where = `GET ${REGULARS_PATH}` const body = await apiFetch(REGULARS_PATH) if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`) return body.dinners.map((dinner): CachedRegular => parseDinner(where, dinner)) }, select: shown, }) } // changeOptimistically stops Regulars reads in flight, so they cannot // overwrite the optimistic list, then applies change to it. async function changeOptimistically(queryClient: QueryClient, change: (list: CachedRegular[]) => CachedRegular[]) { await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY }) setRegulars(queryClient, change) } function setRegulars(queryClient: QueryClient, change: (list: CachedRegular[]) => CachedRegular[]) { queryClient.setQueryData(REGULARS_LIST_KEY, (list) => change(list ?? [])) } // settle refetches the Regulars once the last change in flight is done, so // an earlier change's refetch never drops a later change's optimistic entry. function settle(queryClient: QueryClient) { if (queryClient.isMutating({ mutationKey: REGULARS_MUTATION_KEY }) === 1) { void queryClient.invalidateQueries({ queryKey: REGULARS_KEY }) } } let pendingIds = 0 // useAddRegular adds a Regular by name (POST /api/v1/dinners). It shows in // the list at once and is taken out again if the server refuses it. export function useAddRegular() { const { apiFetch } = useApi() const queryClient = useQueryClient() return useMutation({ mutationKey: REGULARS_MUTATION_KEY, mutationFn: async (name: string) => parseDinner(`POST ${DINNERS_PATH}`, await apiFetch(DINNERS_PATH, { method: 'POST', body: { name } })), onMutate: async (name) => { const id = `pending-${++pendingIds}` await changeOptimistically(queryClient, (list) => [...list, { id, name, pending: true }]) return { id } }, onSuccess: (dinner, _name, context) => { setRegulars(queryClient, (list) => list.map((regular) => (regular.id === context.id ? dinner : regular))) }, onError: (_error, _name, context) => { if (context === undefined) return setRegulars(queryClient, (list) => list.filter((regular) => regular.id !== context.id)) }, onSettled: () => settle(queryClient), }) } // useRemoveRegular archives a Regular (DELETE /api/v1/dinners/{id}). It // leaves the list at once and is put back in its place if the server // refuses. export function useRemoveRegular() { const { apiFetch } = useApi() const queryClient = useQueryClient() return useMutation({ mutationKey: REGULARS_MUTATION_KEY, mutationFn: async (regular: Regular) => { await apiFetch(`${DINNERS_PATH}/${encodeURIComponent(regular.id)}`, { method: 'DELETE' }) }, onMutate: async (regular) => { await changeOptimistically(queryClient, (list) => list.map((r): CachedRegular => (r.id === regular.id ? { ...r, removing: true } : r)), ) }, onSuccess: (_data, regular) => { setRegulars(queryClient, (list) => list.filter((r) => r.id !== regular.id)) }, onError: (_error, regular) => { setRegulars(queryClient, (list) => list.map((r) => (r.id === regular.id ? { id: r.id, name: r.name } : r))) }, onSettled: () => settle(queryClient), }) }