diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
index b05e13c..ed77cf6 100644
--- a/web/src/App.test.tsx
+++ b/web/src/App.test.tsx
@@ -1,19 +1,23 @@
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createApiClient } from './api/index.ts'
import { mockViewport } from './test/matchMedia.ts'
+import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts'
import App, { routes } from './App.tsx'
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
+ vi.unstubAllGlobals()
})
-function renderAt(path: string) {
+function renderAt(path: string, location = fakeLocation(path)) {
render(
-
+
,
)
+ return location
}
describe('App routes', () => {
@@ -39,11 +43,59 @@ describe('App routes', () => {
it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
'%s renders its placeholder page inside the shell',
- (path, title) => {
+ async (path, title) => {
mockViewport(390)
+ stubFetch(jsonResponse(200, ME))
renderAt(path)
- expect(screen.getByRole('main')).toContainElement(screen.getByRole('heading', { level: 1, name: title }))
+ const heading = await screen.findByRole('heading', { level: 1, name: title })
+ expect(screen.getByRole('main')).toContainElement(heading)
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
},
)
})
+
+describe('App bootstrap', () => {
+ it('shows the skeleton until /me loads, then the routes', async () => {
+ mockViewport(390)
+ const fetchMock = stubFetch(jsonResponse(200, ME))
+ renderAt('/plan')
+
+ expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
+ expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
+
+ expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
+ expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
+ expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
+ })
+
+ it('sends a signed-out visitor to sign-in and never renders the routes', async () => {
+ const fetchMock = stubFetch(errorResponse(401, 'unauthenticated'))
+ const location = renderAt('/discover', fakeLocation('/discover', '?swipe=1'))
+
+ await vi.waitFor(() => expect(location.assign).toHaveBeenCalledWith('/auth/login?return=%2Fdiscover%3Fswipe%3D1'))
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toBeInTheDocument()
+ expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
+ })
+
+ it('shows the not-in-this-household screen on a 403', async () => {
+ const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
+ const location = renderAt('/')
+
+ expect(await screen.findByText(/not in this household/)).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: 'Not in this household' })).toBeInTheDocument()
+ expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ expect(location.assign).not.toHaveBeenCalled()
+ })
+
+ it('reports a /me failure with a retry instead of rendering the routes', async () => {
+ mockViewport(390)
+ stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME))
+ renderAt('/')
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
+ screen.getByRole('button', { name: 'Retry' }).click()
+ expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
+ })
+})
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 5e820e5..13cdf10 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -1,6 +1,8 @@
import { useState } from 'react'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { QueryClientProvider } from '@tanstack/react-query'
import { Route, Routes } from 'react-router'
+import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
+import { AuthBootstrap } from './AuthBootstrap.tsx'
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
import { AppShell } from './shell/AppShell.tsx'
@@ -23,19 +25,34 @@ export const routes = [
{ path: '/settings/calendar', title: 'Calendar' },
] as const
-// App expects to be rendered inside a router (BrowserRouter in main.tsx,
-// MemoryRouter in tests).
-export default function App() {
- const [queryClient] = useState(() => new QueryClient())
+// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
+export function AppRoutes() {
return (
-
-
- }>
- {routes.map(({ path, title }) => (
- } />
- ))}
-
-
-
+
+ }>
+ {routes.map(({ path, title }) => (
+ } />
+ ))}
+
+
+ )
+}
+
+export type AppProps = {
+ api: ApiClient
+}
+
+// App expects to be rendered inside a router (BrowserRouter in main.tsx,
+// MemoryRouter in tests).
+export default function App({ api }: AppProps) {
+ const [queryClient] = useState(createQueryClient)
+ return (
+
+
+
+
+
+
+
)
}
diff --git a/web/src/AuthBootstrap.tsx b/web/src/AuthBootstrap.tsx
new file mode 100644
index 0000000..53f8661
--- /dev/null
+++ b/web/src/AuthBootstrap.tsx
@@ -0,0 +1,38 @@
+import { useSyncExternalStore, type ReactNode } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { ApiError, ME_PATH, useApi } from './api/index.ts'
+import { NotInHouseholdPage } from './pages/NotInHouseholdPage.tsx'
+import { ErrorBanner } from './ui/ErrorBanner.tsx'
+import { Skeleton } from './ui/Skeleton.tsx'
+import styles from './pages/FullScreen.module.css'
+
+export const meQueryKey = [ME_PATH] as const
+
+// AuthBootstrap loads GET /api/v1/me before rendering children. A 401 is
+// already navigating to sign-in, so the skeleton stays up; an account outside
+// the household sees only the "not in this household" screen.
+export function AuthBootstrap({ children }: { children: ReactNode }) {
+ const api = useApi()
+ const forbidden = useSyncExternalStore(api.subscribe, api.forbidden)
+ const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity })
+
+ if (forbidden) return
+ if (me.isSuccess) return children
+ if (me.isError && !(me.error instanceof ApiError && me.error.status === 401)) {
+ return (
+
+ void me.refetch()}>MealQuest could not load: {me.error.message}
+
+ )
+ }
+ return (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/src/api/ApiContext.tsx b/web/src/api/ApiContext.tsx
new file mode 100644
index 0000000..b12ae8a
--- /dev/null
+++ b/web/src/api/ApiContext.tsx
@@ -0,0 +1,15 @@
+import { createContext, useContext, type ReactNode } from 'react'
+import type { ApiClient } from './client.ts'
+
+const ApiContext = createContext(null)
+
+export function ApiProvider({ client, children }: { client: ApiClient; children: ReactNode }) {
+ return {children}
+}
+
+// useApi returns the app's API client. It must be rendered inside ApiProvider.
+export function useApi(): ApiClient {
+ const client = useContext(ApiContext)
+ if (client === null) throw new Error('useApi: no ApiProvider above this component')
+ return client
+}
diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts
new file mode 100644
index 0000000..9a2d2c8
--- /dev/null
+++ b/web/src/api/client.test.ts
@@ -0,0 +1,134 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from '../test/fetchStub.ts'
+import { ApiError, createApiClient, CSRF_HEADER } from './index.ts'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('apiFetch', () => {
+ it('sends same-origin JSON and the cached CSRF token on non-GET requests only', async () => {
+ const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, { ok: 1 }), jsonResponse(200, { ok: 2 }))
+ const api = createApiClient(fakeLocation())
+ await api.loadMe()
+
+ await expect(api.apiFetch('/api/v1/settings/household')).resolves.toEqual({ ok: 1 })
+ await expect(
+ api.apiFetch('/api/v1/settings/household', { method: 'PATCH', body: { dinnerTime: '18:00' } }),
+ ).resolves.toEqual({ ok: 2 })
+
+ const get = request(fetchMock, 1)
+ expect(get).toMatchObject({ path: '/api/v1/settings/household', method: 'GET', credentials: 'same-origin' })
+ expect(get.headers.has(CSRF_HEADER)).toBe(false)
+
+ const patch = request(fetchMock, 2)
+ expect(patch).toMatchObject({ method: 'PATCH', credentials: 'same-origin', body: '{"dinnerTime":"18:00"}' })
+ expect(patch.headers.get(CSRF_HEADER)).toBe('token-1')
+ expect(patch.headers.get('Content-Type')).toBe('application/json')
+ })
+
+ it('refuses a non-GET request before /me has loaded', async () => {
+ const fetchMock = stubFetch()
+ const api = createApiClient(fakeLocation())
+ await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('rejects with a typed ApiError from the error body', async () => {
+ stubFetch(errorResponse(503, 'unavailable', true))
+ const api = createApiClient(fakeLocation())
+ const error = await api.apiFetch('/api/v1/jobs/j1').catch((e: unknown) => e)
+ expect(error).toBeInstanceOf(ApiError)
+ expect(error).toMatchObject({ status: 503, code: 'unavailable', message: 'unavailable message', retryable: true })
+ })
+
+ it('rejects a response without the API error body', async () => {
+ stubFetch(new Response('bad gateway', { status: 502 }))
+ const api = createApiClient(fakeLocation())
+ await expect(api.apiFetch('/api/v1/jobs/j1')).rejects.toThrow('GET /api/v1/jobs/j1: 502 response is not JSON')
+ })
+
+ it('on 401 navigates to sign-in with the current path and search to return to', async () => {
+ stubFetch(errorResponse(401, 'unauthenticated'))
+ const location = fakeLocation('/plan/shopping', '?week=2026-10-05')
+ const api = createApiClient(location)
+
+ await expect(api.apiFetch('/api/v1/settings/household')).rejects.toMatchObject({ status: 401 })
+ expect(location.assign).toHaveBeenCalledExactlyOnceWith(
+ '/auth/login?return=%2Fplan%2Fshopping%3Fweek%3D2026-10-05',
+ )
+ })
+
+ it('on a csrf 403 refreshes /me once and retries with the new token', async () => {
+ const fetchMock = stubFetch(
+ jsonResponse(200, ME),
+ errorResponse(403, 'csrf'),
+ jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
+ jsonResponse(201, { id: 'j1' }),
+ )
+ const api = createApiClient(fakeLocation())
+ await api.loadMe()
+
+ await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: { kind: 'noop' } })).resolves.toEqual({
+ id: 'j1',
+ })
+ expect(fetchMock).toHaveBeenCalledTimes(4)
+ expect(request(fetchMock, 1).headers.get(CSRF_HEADER)).toBe('token-1')
+ expect(request(fetchMock, 2)).toMatchObject({ path: '/api/v1/me', method: 'GET' })
+ expect(request(fetchMock, 3)).toMatchObject({ path: '/api/v1/jobs', method: 'POST', body: '{"kind":"noop"}' })
+ expect(request(fetchMock, 3).headers.get(CSRF_HEADER)).toBe('token-2')
+ expect(api.forbidden()).toBe(false)
+ })
+
+ it('does not loop when the retry gets a csrf 403 too', async () => {
+ const fetchMock = stubFetch(
+ jsonResponse(200, ME),
+ errorResponse(403, 'csrf'),
+ jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
+ errorResponse(403, 'csrf'),
+ )
+ const api = createApiClient(fakeLocation())
+ await api.loadMe()
+
+ await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toMatchObject({
+ status: 403,
+ code: 'csrf',
+ })
+ expect(fetchMock).toHaveBeenCalledTimes(4)
+ expect(api.forbidden()).toBe(false)
+ })
+
+ it('marks any other 403 as not in this household and notifies subscribers', async () => {
+ const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
+ const api = createApiClient(fakeLocation())
+ const listener = vi.fn()
+ api.subscribe(listener)
+
+ await expect(api.apiFetch('/api/v1/me')).rejects.toMatchObject({ status: 403, code: 'forbidden' })
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ expect(api.forbidden()).toBe(true)
+ expect(listener).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not take a 403 without the API error body as not in this household', async () => {
+ stubFetch(new Response('denied by proxy', { status: 403 }))
+ const api = createApiClient(fakeLocation())
+ await expect(api.apiFetch('/api/v1/me')).rejects.toThrow('GET /api/v1/me: 403 response is not JSON')
+ expect(api.forbidden()).toBe(false)
+ })
+
+ it('does not refresh /me for a csrf 403 on a GET, which carries no token', async () => {
+ const fetchMock = stubFetch(errorResponse(403, 'csrf'))
+ const api = createApiClient(fakeLocation())
+ await expect(api.loadMe()).rejects.toMatchObject({ status: 403, code: 'csrf' })
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('loadMe', () => {
+ it('rejects a /me response without a CSRF token', async () => {
+ stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
+ const api = createApiClient(fakeLocation())
+ await expect(api.loadMe()).rejects.toThrow('response is not a member with a CSRF token')
+ })
+})
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
new file mode 100644
index 0000000..ac5cc2c
--- /dev/null
+++ b/web/src/api/client.ts
@@ -0,0 +1,136 @@
+import { ApiError, isRecord, parseApiError } from './errors.ts'
+
+// CSRF_HEADER carries the session's CSRF token on every non-GET request.
+export const CSRF_HEADER = 'X-MealQuest-CSRF'
+
+export const ME_PATH = '/api/v1/me'
+
+// Me is the GET /api/v1/me response: the signed-in member, the household and
+// the session's CSRF token.
+export type Me = {
+ name: string
+ email: string
+ household: { label: string }
+ csrfToken: string
+}
+
+// ApiLocation is the part of window.location the client reads and navigates.
+export type ApiLocation = Pick
+
+export type ApiRequest = {
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
+ // Sent as JSON.
+ body?: unknown
+}
+
+export type ApiClient = {
+ // apiFetch sends a same-origin JSON request to path and resolves with the
+ // decoded response body, or rejects with an ApiError.
+ apiFetch(path: string, request?: ApiRequest): Promise
+ // loadMe fetches GET /api/v1/me and caches its CSRF token.
+ loadMe(): Promise
+ // forbidden reports whether a request was refused because the signed-in
+ // account is not in this household; subscribe is notified when it changes.
+ forbidden(): boolean
+ subscribe(listener: () => void): () => void
+}
+
+// createApiClient builds the app's API client. location is window.location in
+// the app: a 401 sends it to /auth/login with the current path to return to.
+export function createApiClient(location: ApiLocation): ApiClient {
+ let csrfToken: string | null = null
+ let refreshing: Promise | null = null
+ let forbidden = false
+ const listeners = new Set<() => void>()
+
+ async function send(method: string, path: string, body: unknown): Promise {
+ const headers: Record = { Accept: 'application/json' }
+ if (body !== undefined) headers['Content-Type'] = 'application/json'
+ if (method !== 'GET') {
+ if (csrfToken === null) {
+ throw new Error(`${method} ${path}: no CSRF token; ${ME_PATH} has not loaded`)
+ }
+ headers[CSRF_HEADER] = csrfToken
+ }
+ return fetch(path, {
+ method,
+ headers,
+ credentials: 'same-origin',
+ body: body === undefined ? undefined : JSON.stringify(body),
+ })
+ }
+
+ // refreshMe shares one in-flight /me request between every request that
+ // got a csrf 403 at the same time.
+ function refreshMe(): Promise {
+ refreshing ??= loadMe().finally(() => {
+ refreshing = null
+ })
+ return refreshing
+ }
+
+ async function apiFetch(path: string, request: ApiRequest = {}): Promise {
+ const method = request.method ?? 'GET'
+ let response = await send(method, path, request.body)
+ if (response.status === 403) {
+ const error = await parseForbidden(method, path, response)
+ // Only a non-GET carries the token, so only it can be refused for it.
+ if (error.code !== 'csrf' || method === 'GET') throw error
+ // The session's token changed: refresh it once and retry once.
+ await refreshMe()
+ response = await send(method, path, request.body)
+ if (response.status === 403) throw await parseForbidden(method, path, response)
+ }
+ return decode(method, path, response)
+ }
+
+ // parseForbidden reads a 403. Any API error other than csrf means the
+ // account is not in this household.
+ async function parseForbidden(method: string, path: string, response: Response): Promise {
+ const error = await parseApiError(method, path, response)
+ if (error.code !== 'csrf') markForbidden()
+ return error
+ }
+
+ function markForbidden() {
+ if (forbidden) return
+ forbidden = true
+ listeners.forEach((listener) => listener())
+ }
+
+ async function decode(method: string, path: string, response: Response): Promise {
+ if (response.status === 401) {
+ location.assign(`/auth/login?return=${encodeURIComponent(location.pathname + location.search)}`)
+ }
+ if (!response.ok) throw await parseApiError(method, path, response)
+ if (response.status === 204) return undefined as T
+ return (await response.json()) as T
+ }
+
+ async function loadMe(): Promise {
+ const me = await apiFetch(ME_PATH)
+ if (
+ !isRecord(me) ||
+ typeof me.name !== 'string' ||
+ typeof me.email !== 'string' ||
+ !isRecord(me.household) ||
+ typeof me.household.label !== 'string' ||
+ typeof me.csrfToken !== 'string' ||
+ me.csrfToken === ''
+ ) {
+ throw new Error(`GET ${ME_PATH}: response is not a member with a CSRF token`)
+ }
+ csrfToken = me.csrfToken
+ return me as Me
+ }
+
+ return {
+ apiFetch,
+ loadMe,
+ forbidden: () => forbidden,
+ subscribe(listener) {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+ },
+ }
+}
diff --git a/web/src/api/errors.ts b/web/src/api/errors.ts
new file mode 100644
index 0000000..94b81e2
--- /dev/null
+++ b/web/src/api/errors.ts
@@ -0,0 +1,41 @@
+// ApiError is a failed API response carrying the server's
+// {"error":{"code","message","retryable"}} body.
+export class ApiError extends Error {
+ readonly status: number
+ readonly code: string
+ readonly retryable: boolean
+
+ constructor(status: number, code: string, message: string, retryable: boolean) {
+ super(message)
+ this.name = 'ApiError'
+ this.status = status
+ this.code = code
+ this.retryable = retryable
+ }
+}
+
+// parseApiError reads an error response body. A body that is not the API
+// error shape is itself an error: the server always sends one.
+export async function parseApiError(method: string, path: string, response: Response): Promise {
+ const text = await response.text()
+ let body: unknown
+ try {
+ body = JSON.parse(text)
+ } catch {
+ throw new Error(`${method} ${path}: ${response.status} response is not JSON: ${text.slice(0, 200)}`)
+ }
+ const error = isRecord(body) ? body.error : undefined
+ if (
+ !isRecord(error) ||
+ typeof error.code !== 'string' ||
+ typeof error.message !== 'string' ||
+ typeof error.retryable !== 'boolean'
+ ) {
+ throw new Error(`${method} ${path}: ${response.status} response has no API error body`)
+ }
+ return new ApiError(response.status, error.code, error.message, error.retryable)
+}
+
+export function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
diff --git a/web/src/api/index.ts b/web/src/api/index.ts
new file mode 100644
index 0000000..ea7a989
--- /dev/null
+++ b/web/src/api/index.ts
@@ -0,0 +1,5 @@
+export { ApiProvider, useApi } from './ApiContext.tsx'
+export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts'
+export { ApiError } from './errors.ts'
+export { createQueryClient, shouldRetry } from './queryClient.ts'
+export { useJob, type Job, type JobState, type JobStatus } from './useJob.ts'
diff --git a/web/src/api/queryClient.test.ts b/web/src/api/queryClient.test.ts
new file mode 100644
index 0000000..81a1378
--- /dev/null
+++ b/web/src/api/queryClient.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest'
+import { ApiError, createQueryClient, shouldRetry } from './index.ts'
+
+describe('query retries', () => {
+ it('retry only an ApiError the server marked retryable, a bounded number of times', () => {
+ expect(shouldRetry(0, new ApiError(503, 'unavailable', 'try later', true))).toBe(true)
+ expect(shouldRetry(3, new ApiError(503, 'unavailable', 'try later', true))).toBe(false)
+ expect(shouldRetry(0, new ApiError(401, 'unauthenticated', 'sign in required', false))).toBe(false)
+ expect(shouldRetry(0, new ApiError(403, 'csrf', 'bad token', false))).toBe(false)
+ expect(shouldRetry(0, new Error('network down'))).toBe(false)
+ })
+
+ it('are the QueryClient default for queries and mutations', () => {
+ const options = createQueryClient().getDefaultOptions()
+ expect(options.queries?.retry).toBe(shouldRetry)
+ expect(options.mutations?.retry).toBe(shouldRetry)
+ })
+})
diff --git a/web/src/api/queryClient.ts b/web/src/api/queryClient.ts
new file mode 100644
index 0000000..b703055
--- /dev/null
+++ b/web/src/api/queryClient.ts
@@ -0,0 +1,20 @@
+import { QueryClient } from '@tanstack/react-query'
+import { ApiError } from './errors.ts'
+
+// MAX_RETRIES bounds retries of a failure the server marked retryable.
+export const MAX_RETRIES = 3
+
+// shouldRetry retries only an ApiError the server marked retryable — never a
+// 401, a 403 or a malformed response.
+export function shouldRetry(failureCount: number, error: unknown): boolean {
+ return error instanceof ApiError && error.retryable && failureCount < MAX_RETRIES
+}
+
+export function createQueryClient(): QueryClient {
+ return new QueryClient({
+ defaultOptions: {
+ queries: { retry: shouldRetry, staleTime: 30_000 },
+ mutations: { retry: shouldRetry },
+ },
+ })
+}
diff --git a/web/src/api/useJob.test.tsx b/web/src/api/useJob.test.tsx
new file mode 100644
index 0000000..f93ce20
--- /dev/null
+++ b/web/src/api/useJob.test.tsx
@@ -0,0 +1,169 @@
+import { act, renderHook } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { errorResponse, fakeLocation, jsonResponse, request, stubFetch } from '../test/fetchStub.ts'
+import { ApiProvider, createApiClient, useJob } from './index.ts'
+
+type Draft = { dinners: string[] }
+
+function job(status: string, result: Draft | null, error: string | null = null) {
+ return jsonResponse(200, { id: 'j1', kind: 'plan.draft', status, result, error })
+}
+
+function renderJob(jobId: string | null) {
+ const api = createApiClient(fakeLocation())
+ const wrapper = ({ children }: { children: ReactNode }) => {children}
+ return renderHook(({ id }) => useJob(id), { wrapper, initialProps: { id: jobId } })
+}
+
+// advance runs the timers due in ms and lets the polls they start answer.
+async function advance(ms: number) {
+ await act(() => vi.advanceTimersByTimeAsync(ms))
+}
+
+beforeEach(() => {
+ vi.useFakeTimers()
+})
+
+afterEach(() => {
+ vi.clearAllTimers()
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+})
+
+describe('useJob', () => {
+ it('exposes the partial result while running, then the terminal result, backing off 500 ms to 2 s', async () => {
+ const fetchMock = stubFetch(
+ job('queued', null),
+ job('running', { dinners: ['Tacos'] }),
+ job('running', { dinners: ['Tacos', 'Chili'] }),
+ job('running', { dinners: ['Tacos', 'Chili'] }),
+ job('succeeded', { dinners: ['Tacos', 'Chili', 'Soup'] }),
+ )
+ const { result } = renderJob('j1')
+ expect(result.current).toEqual({ status: undefined, result: null, error: null })
+
+ await advance(0)
+ expect(result.current.status).toBe('queued')
+ expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/jobs/j1', method: 'GET' })
+
+ await advance(499)
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ await advance(1)
+ expect(result.current).toEqual({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
+
+ await advance(999)
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ await advance(1)
+ expect(result.current.result).toEqual({ dinners: ['Tacos', 'Chili'] })
+
+ await advance(2000)
+ expect(fetchMock).toHaveBeenCalledTimes(4)
+ await advance(2000)
+ expect(fetchMock).toHaveBeenCalledTimes(5)
+ expect(result.current).toEqual({
+ status: 'succeeded',
+ result: { dinners: ['Tacos', 'Chili', 'Soup'] },
+ error: null,
+ })
+
+ // Terminal: no timer left, no further polls.
+ expect(vi.getTimerCount()).toBe(0)
+ await advance(10_000)
+ expect(fetchMock).toHaveBeenCalledTimes(5)
+ })
+
+ it('exposes a failed job as its error and stops', async () => {
+ const fetchMock = stubFetch(job('running', { dinners: ['Tacos'] }), job('failed', null, 'model refused'))
+ const { result } = renderJob('j1')
+
+ await advance(0)
+ await advance(500)
+ expect(result.current.status).toBe('failed')
+ expect(result.current.error?.message).toBe('model refused')
+ expect(vi.getTimerCount()).toBe(0)
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('exposes a failed poll as the error and keeps polling to the terminal status', async () => {
+ const fetchMock = stubFetch(
+ job('running', { dinners: ['Tacos'] }),
+ errorResponse(503, 'unavailable', true),
+ job('succeeded', { dinners: ['Tacos', 'Chili'] }),
+ )
+ const { result } = renderJob('j1')
+
+ await advance(0)
+ await advance(500)
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] } })
+ expect(result.current.error).toMatchObject({ status: 503, code: 'unavailable' })
+ expect(vi.getTimerCount()).toBe(1)
+
+ // The backoff carries on from the failed poll: the next one is 1 s later.
+ await advance(999)
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ await advance(1)
+ expect(fetchMock).toHaveBeenCalledTimes(3)
+ expect(result.current).toEqual({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null })
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it('stops polling on unmount after a failed poll', async () => {
+ const fetchMock = stubFetch(errorResponse(503, 'unavailable', true))
+ const { result, unmount } = renderJob('j1')
+
+ await advance(0)
+ expect(result.current).toMatchObject({ status: undefined, result: null })
+ expect(result.current.error).toMatchObject({ status: 503 })
+ expect(vi.getTimerCount()).toBe(1)
+ unmount()
+ expect(vi.getTimerCount()).toBe(0)
+ await advance(10_000)
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('stops polling the old job when the job id changes', async () => {
+ const fetchMock = stubFetch(job('running', null), job('running', null))
+ const { result, rerender } = renderJob('j1')
+
+ await advance(0)
+ rerender({ id: 'j2' })
+ expect(result.current).toEqual({ status: undefined, result: null, error: null })
+ await advance(0)
+ expect(request(fetchMock, 1).path).toBe('/api/v1/jobs/j2')
+ expect(vi.getTimerCount()).toBe(1)
+ })
+
+ it('stops polling on unmount', async () => {
+ const fetchMock = stubFetch(job('running', null))
+ const { unmount } = renderJob('j1')
+
+ await advance(0)
+ expect(vi.getTimerCount()).toBe(1)
+ unmount()
+ expect(vi.getTimerCount()).toBe(0)
+ await advance(10_000)
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('ignores a poll that answers after unmount', async () => {
+ let answer!: (response: Response) => void
+ const fetchMock = stubFetch()
+ fetchMock.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)))
+ const { result, unmount } = renderJob('j1')
+
+ unmount()
+ await act(async () => answer(await Promise.resolve(job('running', null))))
+ expect(vi.getTimerCount()).toBe(0)
+ expect(result.current.status).toBeUndefined()
+ })
+
+ it('polls nothing without a job id', async () => {
+ const fetchMock = stubFetch()
+ const { result } = renderJob(null)
+ await advance(5000)
+ expect(fetchMock).not.toHaveBeenCalled()
+ expect(result.current).toEqual({ status: undefined, result: null, error: null })
+ })
+})
diff --git a/web/src/api/useJob.ts b/web/src/api/useJob.ts
new file mode 100644
index 0000000..2a26c2c
--- /dev/null
+++ b/web/src/api/useJob.ts
@@ -0,0 +1,83 @@
+import { useEffect, useState } from 'react'
+import { useApi } from './ApiContext.tsx'
+
+export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed'
+
+// Job is the GET /api/v1/jobs/{id} response. result may be set while the job
+// is running (a partial result); error is set only when it failed.
+export type Job = {
+ id: string
+ kind: string
+ status: JobStatus
+ result: R | null
+ error: string | null
+}
+
+export type JobState = {
+ // undefined until the first poll answers.
+ status: JobStatus | undefined
+ // The latest result, partial while the job runs.
+ result: R | null
+ // The job's failure, or the latest polling request's own failure (cleared
+ // by the next poll that answers).
+ error: Error | null
+}
+
+// Polling starts at FIRST_DELAY_MS and doubles up to MAX_DELAY_MS.
+export const FIRST_DELAY_MS = 500
+export const MAX_DELAY_MS = 2000
+
+const idle = { status: undefined, result: null, error: null }
+
+function isTerminal(status: JobStatus): boolean {
+ return status === 'succeeded' || status === 'failed'
+}
+
+// useJob polls GET /api/v1/jobs/{jobId} until the job succeeds or fails,
+// backing off from 500 ms to 2 s. A failed poll is exposed as the error and
+// polling continues. Polling stops only at a terminal status, when jobId
+// changes and on unmount. A null jobId polls nothing.
+export function useJob(jobId: string | null): JobState {
+ const { apiFetch } = useApi()
+ const [state, setState] = useState<{ jobId: string | null; job: JobState }>({ jobId, job: idle })
+
+ useEffect(() => {
+ if (jobId === null) return
+ let active = true
+ let timer: ReturnType | undefined
+ let delay = FIRST_DELAY_MS
+
+ function next() {
+ timer = setTimeout(poll, delay)
+ delay = Math.min(delay * 2, MAX_DELAY_MS)
+ }
+
+ async function poll() {
+ try {
+ const job = await apiFetch>(`/api/v1/jobs/${encodeURIComponent(jobId!)}`)
+ if (!active) return
+ const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null
+ setState({ jobId, job: { status: job.status, result: job.result, error } })
+ if (isTerminal(job.status)) return
+ } catch (error) {
+ if (!active) return
+ // A failed poll is not the job's end: keep the last known status and
+ // result, expose the request's error, and poll again.
+ setState((prev) => ({
+ jobId,
+ job: { ...(prev.jobId === jobId ? prev.job : idle), error: error instanceof Error ? error : new Error(String(error)) },
+ }))
+ }
+ next()
+ }
+
+ void poll()
+ return () => {
+ active = false
+ clearTimeout(timer)
+ }
+ }, [apiFetch, jobId])
+
+ // State left from a previous jobId is not this job's.
+ return state.jobId === jobId ? state.job : idle
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
index a2dcd7b..fe00a99 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -4,16 +4,19 @@ import { BrowserRouter } from 'react-router'
import './styles/tokens.css'
import './styles/base.css'
import App from './App.tsx'
+import { createApiClient } from './api/index.ts'
const root = document.getElementById('root')
if (!root) {
throw new Error('MealQuest: #root element missing from index.html')
}
+const api = createApiClient(window.location)
+
createRoot(root).render(
-
+
,
)
diff --git a/web/src/pages/FullScreen.module.css b/web/src/pages/FullScreen.module.css
new file mode 100644
index 0000000..12c55ec
--- /dev/null
+++ b/web/src/pages/FullScreen.module.css
@@ -0,0 +1,23 @@
+/* A full-viewport screen shown instead of the app shell. */
+.screen {
+ min-height: 100dvh;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: var(--space-3);
+ padding: var(--space-8) var(--space-4);
+ background: var(--color-bg);
+}
+
+.loading {
+ width: min(100%, 480px);
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.title {
+ height: 32px;
+ width: 60%;
+}
diff --git a/web/src/pages/NotInHouseholdPage.tsx b/web/src/pages/NotInHouseholdPage.tsx
new file mode 100644
index 0000000..f1bb3db
--- /dev/null
+++ b/web/src/pages/NotInHouseholdPage.tsx
@@ -0,0 +1,14 @@
+import { EmptyState } from '../ui/EmptyState.tsx'
+import styles from './FullScreen.module.css'
+
+// Shown when the server refuses a signed-in account that is not in this
+// household. Nothing else renders: the account can do nothing here.
+export function NotInHouseholdPage() {
+ return (
+
+
+ You are signed in, but this account is not in this household. Ask someone in Our kitchen to add it.
+
+
+ )
+}
diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx
index 48e0bc8..03d7bfd 100644
--- a/web/src/shell/AppShell.test.tsx
+++ b/web/src/shell/AppShell.test.tsx
@@ -1,7 +1,7 @@
import { render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it } from 'vitest'
-import App from '../App.tsx'
+import { AppRoutes } from '../App.tsx'
import { declaredStyle, outerSize } from '../test/cssRules.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts'
@@ -22,7 +22,7 @@ function renderAt(path: string, width: number) {
const resize = mockViewport(width)
render(
-
+
,
)
return resize
diff --git a/web/src/shell/BottomBar.test.tsx b/web/src/shell/BottomBar.test.tsx
index 6f93884..3ff7db6 100644
--- a/web/src/shell/BottomBar.test.tsx
+++ b/web/src/shell/BottomBar.test.tsx
@@ -1,7 +1,7 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
-import App from '../App.tsx'
+import { AppRoutes } from '../App.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { mockViewport } from '../test/matchMedia.ts'
import styles from './BottomBar.module.css'
@@ -71,7 +71,7 @@ function renderAt(path: string, reducedMotion = false) {
mockViewport(MOBILE, { reducedMotion })
render(
-
+
,
)
}
diff --git a/web/src/test/fetchStub.ts b/web/src/test/fetchStub.ts
new file mode 100644
index 0000000..9e84d14
--- /dev/null
+++ b/web/src/test/fetchStub.ts
@@ -0,0 +1,48 @@
+import { vi, type Mock } from 'vitest'
+import type { ApiLocation, Me } from '../api/index.ts'
+
+export const ME: Me = {
+ name: 'Sam',
+ email: 'sam@example.com',
+ household: { label: 'Our kitchen' },
+ csrfToken: 'token-1',
+}
+
+export function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
+}
+
+export function errorResponse(status: number, code: string, retryable = false): Response {
+ return jsonResponse(status, { error: { code, message: `${code} message`, retryable } })
+}
+
+// stubFetch replaces the global fetch with a mock answered from responses in
+// order; a request beyond them fails the test. Undo with vi.unstubAllGlobals.
+export function stubFetch(...responses: Response[]): Mock {
+ const fetchMock = vi.fn()
+ for (const response of responses) fetchMock.mockResolvedValueOnce(response)
+ fetchMock.mockImplementation((input, init) => {
+ throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${String(input)}`)
+ })
+ vi.stubGlobal('fetch', fetchMock)
+ return fetchMock
+}
+
+// request returns the method, path and headers of the fetch mock's nth call.
+export function request(fetchMock: Mock, n: number) {
+ const call = fetchMock.mock.calls[n]
+ if (call === undefined) throw new Error(`fetch was called ${fetchMock.mock.calls.length} times, not ${n + 1}`)
+ const [input, init] = call
+ return {
+ path: String(input),
+ method: init?.method,
+ credentials: init?.credentials,
+ headers: new Headers(init?.headers),
+ body: init?.body,
+ }
+}
+
+// fakeLocation stands in for window.location, recording navigations.
+export function fakeLocation(pathname = '/', search = ''): ApiLocation & { assign: Mock<(url: string | URL) => void> } {
+ return { pathname, search, assign: vi.fn<(url: string | URL) => void>() }
+}