Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 695003c5f7 Tighten iOS Safari detection and report install hint storage failures
CI / ci (push) Successful in 1m33s
CI / ci (pull_request) Successful in 1m39s
Detect Safari by its exact user agent tail instead of a "Safari/" token
plus an exclusion list, so third-party iOS browsers such as MQQBrowser
no longer get the hint. When storage cannot be read or written, keep the
hint visible and show an error banner with Retry instead of treating the
dismissal as saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:25:06 -04:00
dev_agentandClaude Opus 5.5 ceccbda61d Add iOS install hint component
CI / ci (push) Successful in 1m34s
CI / ci (pull_request) Successful in 1m33s
InstallHint shows "Tap Share, then Add to Home Screen to get notifications"
only in iOS and iPadOS Safari browser tabs (not when display-mode is
standalone or navigator.standalone is true), since iOS delivers Web Push
only to installed apps. Dismissing it is remembered per device in
localStorage; when storage is unavailable the hint still shows and
dismisses for the page's lifetime. Exported from src/pwa/InstallHint.tsx
for onboarding step 3 and the Notifications screen.

Maestro task: ad6b3d80-9f41-40ac-8e2a-f786e1fa9777

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:16:11 -04:00
dev_agent 78c33370f9 Update prompt and offline indicator (#52)
CI / ci (push) Successful in 1m33s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-05 08:13:50 -04:00
dev_agent 21b2672d40 Service worker caching strategy (#51)
CI / ci (push) Successful in 1m31s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 16:36:43 -04:00
dev_agent dd58865323 Web app manifest and icons (#50)
CI / ci (push) Successful in 1m35s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 16:16:41 -04:00
approver_agent d80012a821 Merge pull request 'Feature 05: Frontend foundation and design system' (#43) from feature/64e5bb19-c9a2-4c6a-8f0c-aceae43fb732-frontend-foundation into dev
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (pull_request) Successful in 1m32s
CI / ci (push) Successful in 1m36s
2026-10-04 09:18:57 -04:00
dev_agent 5758f198e4 Keep the CSRF token on same-origin API calls and lint colours in the build (#45)
CI / ci (push) Successful in 1m33s
CI / ci (pull_request) Successful in 1m31s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 09:08:42 -04:00
dev_agent 048d5dd71a API client, auth bootstrap and job polling (#42)
CI / ci (push) Successful in 1m32s
CI / ci (pull_request) Successful in 1m33s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 08:46:40 -04:00
45 changed files with 7581 additions and 28 deletions
+2
View File
@@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#5a358c" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>MealQuest</title>
</head>
<body>
+19
View File
@@ -0,0 +1,19 @@
{
"name": "MealQuest",
"short_name": "MealQuest",
"start_url": "/",
"scope": "/",
"display": "standalone",
"theme_color": "#5a358c",
"background_color": "#f9f6f1",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+5227
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -5,9 +5,9 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build && npm run check:dist-fonts",
"build": "npm run lint:colors && npm run typecheck && vite build && npm run check:dist-fonts",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.sw.json",
"test": "npm run lint:colors && vitest run",
"lint:colors": "node scripts/lint-colors.mjs",
"check:dist-fonts": "node scripts/check-dist-fonts.mjs"
@@ -29,6 +29,7 @@
"jsdom": "^30.1.1",
"typescript": "^7.0.2",
"vite": "^8.3.2",
"vite-plugin-pwa": "^2.0.0",
"vitest": "^5.0.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

+59
View File
@@ -0,0 +1,59 @@
"""Generates the PWA icons in web/public from the MealQuest logo.
Run from the repository root with Pillow installed:
python3 web/scripts/generate-icons.py
The logo fills the regular icons edge to edge. The maskable icon shrinks it
onto the colour of its own parchment, so the banner stays inside
the circular safe zone (radius 40% of the icon) that launchers may crop to.
"""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter, ImageStat
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "design" / "logos" / "logo_v1_2Oct2026.jpg"
PUBLIC = ROOT / "web" / "public"
MASKABLE_SCALE = 0.66
def resized(logo: Image.Image, size: int) -> Image.Image:
return logo.resize((size, size), Image.Resampling.LANCZOS)
def maskable(logo: Image.Image, size: int) -> Image.Image:
# The padding is the median colour of the logo's top edge, which is bare
# parchment; a feathered edge hides the seam.
top_edge = ImageStat.Stat(logo.crop((0, 0, logo.width, logo.height // 40)))
canvas = Image.new("RGB", (size, size), tuple(round(c) for c in top_edge.median))
inner = round(size * MASKABLE_SCALE)
offset = (size - inner) // 2
feather = Image.new("L", (inner, inner), 0)
margin = inner // 24
ImageDraw.Draw(feather).rectangle((margin, margin, inner - margin, inner - margin), fill=255)
feather = feather.filter(ImageFilter.GaussianBlur(margin / 2))
canvas.paste(resized(logo, inner), (offset, offset), feather)
return canvas
def main() -> None:
logo = Image.open(SOURCE).convert("RGB")
if logo.width != logo.height:
raise SystemExit(f"{SOURCE} is {logo.width}x{logo.height}; the logo must be square")
outputs = {
PUBLIC / "icons" / "icon-192.png": resized(logo, 192),
PUBLIC / "icons" / "icon-512.png": resized(logo, 512),
PUBLIC / "icons" / "icon-maskable-512.png": maskable(logo, 512),
PUBLIC / "apple-touch-icon.png": resized(logo, 180),
}
for path, image in outputs.items():
path.parent.mkdir(parents=True, exist_ok=True)
image.save(path, optimize=True)
print(f"wrote {path.relative_to(ROOT)} ({image.width}x{image.height})")
if __name__ == "__main__":
main()
+58 -5
View File
@@ -1,19 +1,24 @@
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 { createNetworkStatus } from './pwa/networkStatus.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(
<MemoryRouter initialEntries={[path]}>
<App />
<App api={createApiClient(location, createNetworkStatus(window))} serviceWorker={undefined} />
</MemoryRouter>,
)
return location
}
describe('App routes', () => {
@@ -39,11 +44,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()
})
})
+41 -14
View File
@@ -1,7 +1,11 @@
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 { OfflinePill } from './pwa/OfflinePill.tsx'
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
import { AppShell } from './shell/AppShell.tsx'
// Every route the app serves, with the heading its placeholder page shows.
@@ -23,19 +27,42 @@ 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 (
<QueryClientProvider client={queryClient}>
<Routes>
<Route element={<AppShell />}>
{routes.map(({ path, title }) => (
<Route key={path} path={path} element={<PlaceholderPage title={title} />} />
))}
</Route>
</Routes>
</QueryClientProvider>
<Routes>
<Route element={<AppShell />}>
{routes.map(({ path, title }) => (
<Route key={path} path={path} element={<PlaceholderPage title={title} />} />
))}
</Route>
</Routes>
)
}
export type AppProps = {
api: ApiClient
// navigator.serviceWorker, or undefined where there is none (and in tests).
serviceWorker: UpdateContainer | undefined
}
function reloadPage() {
window.location.reload()
}
// App expects to be rendered inside a router (BrowserRouter in main.tsx,
// MemoryRouter in tests).
export default function App({ api, serviceWorker }: AppProps) {
const [queryClient] = useState(createQueryClient)
return (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>
<AuthBootstrap>
<AppRoutes />
</AuthBootstrap>
<OfflinePill />
<UpdatePrompt container={serviceWorker} reload={reloadPage} />
</QueryClientProvider>
</ApiProvider>
)
}
+38
View File
@@ -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 <NotInHouseholdPage />
if (me.isSuccess) return children
if (me.isError && !(me.error instanceof ApiError && me.error.status === 401)) {
return (
<main className={styles.screen}>
<ErrorBanner onRetry={() => void me.refetch()}>MealQuest could not load: {me.error.message}</ErrorBanner>
</main>
)
}
return (
<main className={styles.screen} aria-busy="true" aria-label="Loading MealQuest">
<div className={styles.loading}>
<Skeleton className={styles.title} />
<Skeleton />
<Skeleton />
<Skeleton />
</div>
</main>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { createContext, useContext, type ReactNode } from 'react'
import type { ApiClient } from './client.ts'
const ApiContext = createContext<ApiClient | null>(null)
export function ApiProvider({ client, children }: { client: ApiClient; children: ReactNode }) {
return <ApiContext.Provider value={client}>{children}</ApiContext.Provider>
}
// 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
}
+159
View File
@@ -0,0 +1,159 @@
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'
import { createNetworkStatus } from '../pwa/networkStatus.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(), createNetworkStatus(window))
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(), createNetworkStatus(window))
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
expect(fetchMock).not.toHaveBeenCalled()
})
it.each([
'https://evil.example/api/v1/me',
'//evil.example/api/v1/me',
// The placeholder origin an earlier guard compared against.
'//same-origin.invalid/api/v1/me',
'https://same-origin.invalid/api/v1/me',
'/\\evil.example/api/v1/me',
'/auth/logout',
'/api/v1/../auth/logout',
'/api/v1/%2e%2e/auth/logout',
'api/v1/me',
])('refuses %s without sending it or the CSRF token', async (path) => {
const fetchMock = stubFetch(jsonResponse(200, ME))
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
await api.loadMe()
await expect(api.apiFetch(path, { method: 'POST', body: {} })).rejects.toThrow(
`POST ${path}: not a same-origin /api/v1/ path`,
)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' })
expect(request(fetchMock, 0).headers.has(CSRF_HEADER)).toBe(false)
})
it('rejects with a typed ApiError from the error body', async () => {
stubFetch(errorResponse(503, 'unavailable', true))
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
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('<html>bad gateway</html>', { status: 502 }))
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
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, createNetworkStatus(window))
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(), createNetworkStatus(window))
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(), createNetworkStatus(window))
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(), createNetworkStatus(window))
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('<html>denied by proxy</html>', { status: 403 }))
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
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(), createNetworkStatus(window))
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(), createNetworkStatus(window))
await expect(api.loadMe()).rejects.toThrow('response is not a member with a CSRF token')
})
})
+178
View File
@@ -0,0 +1,178 @@
import type { NetworkStatus } from '../pwa/networkStatus.ts'
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'
// API_PREFIX is the only path apiFetch sends to.
const API_PREFIX = '/api/v1/'
// apiURL resolves path against the page origin and returns it as the
// path-absolute URL to fetch. It refuses any path that would not resolve to a
// same-origin URL under /api/v1/, so the CSRF token is never sent anywhere
// else: absolute and protocol-relative URLs on another origin, and dot
// segments that climb out of /api/v1/.
function apiURL(origin: string, method: string, path: string): string {
const url = new URL(path, origin)
if (!path.startsWith('/') || url.origin !== origin || !url.pathname.startsWith(API_PREFIX)) {
throw new Error(`${method} ${path}: not a same-origin ${API_PREFIX} path`)
}
return url.pathname + url.search
}
// 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.
// origin is the page origin every request must stay on.
export type ApiLocation = Pick<Location, 'origin' | 'pathname' | 'search' | 'assign'>
export type ApiRequest = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
// Sent as JSON. A GET must not have one.
body?: unknown
}
export type ApiClient = {
// apiFetch sends a same-origin JSON request to path, which must be under
// /api/v1/, and resolves with the decoded response body, or rejects with an
// ApiError.
apiFetch<T>(path: string, request?: ApiRequest): Promise<T>
// loadMe fetches GET /api/v1/me and caches its CSRF token.
loadMe(): Promise<Me>
// 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
// network learns of every request that failed with a network error and of
// every request that got a response.
network: NetworkStatus
}
// 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, network: NetworkStatus): ApiClient {
let csrfToken: string | null = null
let refreshing: Promise<Me> | null = null
let forbidden = false
const listeners = new Set<() => void>()
async function send(method: string, path: string, body: unknown): Promise<Response> {
const headers: Record<string, string> = { 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
}
// Serialize before fetching so a body that cannot be sent is its own error
// and never counts as a network failure.
let json: string | undefined
try {
json = body === undefined ? undefined : JSON.stringify(body)
} catch (error) {
throw new Error(`${method} ${path}: request body is not serializable as JSON`, { cause: error })
}
let response: Response
try {
response = await fetch(path, { method, headers, credentials: 'same-origin', body: json })
} catch (error) {
// fetch rejects with a TypeError when the network fails; an abort is a
// DOMException and says nothing about the network.
if (error instanceof TypeError) network.requestFailed()
throw error
}
network.requestSucceeded()
return response
}
// refreshMe shares one in-flight /me request between every request that
// got a csrf 403 at the same time.
function refreshMe(): Promise<Me> {
refreshing ??= loadMe().finally(() => {
refreshing = null
})
return refreshing
}
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
const method = request.method ?? 'GET'
const url = apiURL(location.origin, method, path)
// fetch refuses a GET with a body by rejecting with a TypeError, the same
// error as a network failure, so refuse it here before anything is sent.
if (method === 'GET' && request.body !== undefined) {
throw new Error(`GET ${path}: a GET request cannot carry a body`)
}
let response = await send(method, url, 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, url, request.body)
if (response.status === 403) throw await parseForbidden(method, path, response)
}
return decode<T>(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<ApiError> {
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<T>(method: string, path: string, response: Response): Promise<T> {
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<Me> {
const me = await apiFetch<unknown>(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)
},
network,
}
}
+41
View File
@@ -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<ApiError> {
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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
+5
View File
@@ -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'
+18
View File
@@ -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)
})
})
+20
View File
@@ -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 },
},
})
}
+170
View File
@@ -0,0 +1,170 @@
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'
import { createNetworkStatus } from '../pwa/networkStatus.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(), createNetworkStatus(window))
const wrapper = ({ children }: { children: ReactNode }) => <ApiProvider client={api}>{children}</ApiProvider>
return renderHook(({ id }) => useJob<Draft>(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 })
})
})
+83
View File
@@ -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<R> = {
id: string
kind: string
status: JobStatus
result: R | null
error: string | null
}
export type JobState<R> = {
// 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<R>(jobId: string | null): JobState<R> {
const { apiFetch } = useApi()
const [state, setState] = useState<{ jobId: string | null; job: JobState<R> }>({ jobId, job: idle })
useEffect(() => {
if (jobId === null) return
let active = true
let timer: ReturnType<typeof setTimeout> | 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<Job<R>>(`/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
}
+5 -1
View File
@@ -4,16 +4,20 @@ import { BrowserRouter } from 'react-router'
import './styles/tokens.css'
import './styles/base.css'
import App from './App.tsx'
import { createApiClient } from './api/index.ts'
import { createNetworkStatus } from './pwa/networkStatus.ts'
const root = document.getElementById('root')
if (!root) {
throw new Error('MealQuest: #root element missing from index.html')
}
const api = createApiClient(window.location, createNetworkStatus(window))
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<App />
<App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />
</BrowserRouter>
</StrictMode>,
)
+23
View File
@@ -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%;
}
+14
View File
@@ -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 (
<main className={styles.screen}>
<EmptyState title="Not in this household">
You are signed in, but this account is not in this household. Ask someone in Our kitchen to add it.
</EmptyState>
</main>
)
}
+157
View File
@@ -0,0 +1,157 @@
// @vitest-environment node
// Builds the app with the production config and serves the result, so the
// assertions cover what ships in dist: the web manifest, the service worker,
// every icon, and the service worker registration that only builds include.
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { build, createServer, preview, type PreviewServer, type ViteDevServer } from 'vite'
import tokens from './styles/tokens.css?raw'
const outDir = 'node_modules/.tmp/pwa-test-dist'
const icons = [
{ path: '/icons/icon-192.png', size: 192 },
{ path: '/icons/icon-512.png', size: 512 },
{ path: '/icons/icon-maskable-512.png', size: 512 },
{ path: '/apple-touch-icon.png', size: 180 },
]
function token(name: string): string {
const value = tokens.match(new RegExp(`^\\s*${name}:\\s*([^;\\s]+);`, 'm'))?.[1]
if (!value) throw new Error(`${name} is not defined in tokens.css`)
return value
}
// Width and height from a PNG's IHDR chunk, which follows the 8-byte
// signature, the chunk length and the chunk type.
// The precache list vite-plugin-pwa injects into sw.js: a JSON array of
// { revision, url } entries.
function precacheEntries(sw: string): Array<{ revision: string | null; url: string }> {
const start = sw.indexOf('[{"revision":')
expect(start, 'injected precache list').toBeGreaterThanOrEqual(0)
return JSON.parse(sw.slice(start, sw.indexOf('}]', start) + 2))
}
function pngSize(bytes: Uint8Array): { width: number; height: number } {
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
expect(Array.from(bytes.subarray(0, 8))).toEqual(signature)
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
return { width: view.getUint32(16), height: view.getUint32(20) }
}
describe('production build', () => {
let server: PreviewServer
let origin: string
async function get(path: string): Promise<Response> {
const response = await fetch(new URL(path, origin))
expect(response.status, path).toBe(200)
return response
}
beforeAll(async () => {
await build({ logLevel: 'error', build: { outDir, emptyOutDir: true } })
server = await preview({
logLevel: 'error',
build: { outDir },
preview: { port: 0, strictPort: false, host: '127.0.0.1' },
})
const url = server.resolvedUrls?.local[0]
if (!url) throw new Error('preview server did not report a URL')
origin = url
}, 60_000)
afterAll(async () => {
await server?.close()
})
it('emits manifest.webmanifest with the MealQuest identity', async () => {
const manifest = await (await get('/manifest.webmanifest')).json()
expect(manifest).toMatchObject({
name: 'MealQuest',
short_name: 'MealQuest',
start_url: '/',
scope: '/',
display: 'standalone',
// --color-accent and --color-bg hold the manifest colours; hex colours
// may not be written outside tokens.css.
theme_color: token('--color-accent'),
background_color: token('--color-bg'),
})
expect(manifest.icons).toEqual([
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{
src: '/icons/icon-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
])
})
it.each(icons)('ships $path as a $size px square PNG', async ({ path, size }) => {
const bytes = new Uint8Array(await (await get(path)).arrayBuffer())
expect(pngSize(bytes)).toEqual({ width: size, height: size })
})
it('emits sw.js with the precache list injected', async () => {
const sw = await (await get('/sw.js')).text()
expect(sw).not.toContain('self.__WB_MANIFEST')
expect(sw).toContain('"url":"index.html"')
for (const { path } of icons.slice(0, 3)) {
expect(sw).toContain(`"url":"${path.slice(1)}"`)
}
})
it('precaches the shell, JS, CSS and every self-hosted font', async () => {
const entries = precacheEntries(await (await get('/sw.js')).text())
const urls = entries.map((entry) => entry.url)
// index.html keeps its URL across builds, so it carries a content
// revision; the other assets have content hashes in their names.
expect(entries.find((entry) => entry.url === 'index.html')?.revision).toMatch(/^[0-9a-f]{32}$/)
expect(urls.some((url) => url.endsWith('.js'))).toBe(true)
expect(urls.some((url) => url.endsWith('.css'))).toBe(true)
for (const font of [
'BagelFatOne-LatinExt',
'BagelFatOne-Latin',
'Figtree-Regular',
'Figtree-SemiBold',
'Figtree-Bold',
]) {
expect(urls.some((url) => url.startsWith(`assets/${font}`) && url.endsWith('.woff2'))).toBe(true)
}
})
it('links the manifest, theme colour and apple-touch-icon from index.html', async () => {
const html = await (await get('/')).text()
expect(html.match(/<link rel="manifest" href="\/manifest\.webmanifest">/g)).toHaveLength(1)
expect(html).toContain(`<meta name="theme-color" content="${token('--color-accent')}" />`)
expect(html).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png" />')
})
it('registers /sw.js at scope / from index.html', async () => {
const html = await (await get('/')).text()
expect(html).toContain('<script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script>')
const register = await (await get('/registerSW.js')).text()
expect(register).toContain("navigator.serviceWorker.register('/sw.js', { scope: '/' })")
})
})
describe('dev server', () => {
let server: ViteDevServer
beforeAll(async () => {
server = await createServer({ logLevel: 'error', server: { middlewareMode: true } })
})
afterAll(async () => {
await server?.close()
})
it('does not register a service worker', async () => {
const html = await server.transformIndexHtml('/', '<html><head></head><body></body></html>')
expect(html).not.toContain('registerSW')
expect(html).not.toContain('serviceWorker')
expect(html).not.toContain('rel="manifest"')
})
})
+18
View File
@@ -0,0 +1,18 @@
.hint {
display: flex;
align-items: center;
gap: var(--space-3);
}
.icon {
color: var(--color-accent);
}
.message {
flex: 1;
margin: 0;
font-family: var(--font-body);
font-size: 15px;
font-weight: 600;
line-height: 1.35;
}
+146
View File
@@ -0,0 +1,146 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DISMISSED_KEY, InstallHint } from './InstallHint.tsx'
const IPHONE_SAFARI =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1'
const IPAD_SAFARI =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15'
const MAC_SAFARI = IPAD_SAFARI
const IPHONE_CHROME =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/138.0.7204.119 Mobile/15E148 Safari/604.1'
const IPHONE_FIREFOX =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/141.0 Mobile/15E148 Safari/605.1.15'
const IPHONE_EDGE =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 EdgiOS/138.3351.83 Mobile/15E148 Safari/605.1.15'
const IPHONE_OPERA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1 OPT/5.4.0'
const IPHONE_MQQBROWSER =
'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 MQQBrowser/11.3.5 Mobile/15E148 Safari/604.1'
const ANDROID_CHROME =
'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36'
const DESKTOP_CHROME =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'
const copy = 'Tap Share, then Add to Home Screen to get notifications'
type Device = {
userAgent: string
maxTouchPoints?: number
// display-mode: standalone
displayStandalone?: boolean
// navigator.standalone (Safari only; undefined elsewhere)
navigatorStandalone?: boolean
}
// mockDevice pretends to be a browser with the given user agent and
// display mode; jsdom has no matchMedia, navigator.maxTouchPoints or
// navigator.standalone.
function mockDevice({ userAgent, maxTouchPoints = 0, displayStandalone = false, navigatorStandalone }: Device) {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(userAgent)
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: maxTouchPoints })
Object.defineProperty(navigator, 'standalone', { configurable: true, value: navigatorStandalone })
vi.stubGlobal('matchMedia', (query: string) => {
if (query !== '(display-mode: standalone)') throw new Error(`unexpected media query ${query}`)
return { matches: displayStandalone, media: query } as MediaQueryList
})
}
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
Reflect.deleteProperty(navigator, 'maxTouchPoints')
Reflect.deleteProperty(navigator, 'standalone')
localStorage.clear()
})
describe('InstallHint', () => {
it('shows on iPhone Safari in a browser tab', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
render(<InstallHint />)
expect(screen.getByRole('note')).toHaveTextContent(copy)
})
it('shows on iPadOS Safari, which reports itself as a Mac with a touch screen', () => {
mockDevice({ userAgent: IPAD_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
render(<InstallHint />)
expect(screen.getByRole('note')).toHaveTextContent(copy)
})
it('is hidden when navigator.standalone is true', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: true })
render(<InstallHint />)
expect(screen.queryByRole('note')).toBeNull()
})
it('is hidden when display-mode is standalone', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, displayStandalone: true })
render(<InstallHint />)
expect(screen.queryByRole('note')).toBeNull()
})
it.each([
['Android Chrome', { userAgent: ANDROID_CHROME, maxTouchPoints: 5 }],
['desktop Chrome', { userAgent: DESKTOP_CHROME }],
['desktop Safari on a Mac', { userAgent: MAC_SAFARI }],
['Chrome on iPhone', { userAgent: IPHONE_CHROME, maxTouchPoints: 5 }],
['Firefox on iPhone', { userAgent: IPHONE_FIREFOX, maxTouchPoints: 5 }],
['Edge on iPhone', { userAgent: IPHONE_EDGE, maxTouchPoints: 5 }],
['Opera on iPhone', { userAgent: IPHONE_OPERA, maxTouchPoints: 5 }],
['MQQBrowser on iPhone', { userAgent: IPHONE_MQQBROWSER, maxTouchPoints: 5 }],
])('is hidden on %s', (_name, device: Device) => {
mockDevice(device)
render(<InstallHint />)
expect(screen.queryByRole('note')).toBeNull()
})
it('stays dismissed on this device', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
const first = render(<InstallHint />)
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
expect(screen.queryByRole('note')).toBeNull()
expect(localStorage.getItem(DISMISSED_KEY)).toBe('1')
first.unmount()
render(<InstallHint />)
expect(screen.queryByRole('note')).toBeNull()
})
it('stays up and reports the error when the dismissal cannot be saved', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('quota exceeded', 'QuotaExceededError')
})
render(<InstallHint />)
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
expect(screen.getByRole('note')).toHaveTextContent(copy)
expect(screen.getByRole('alert')).toHaveTextContent(
'Could not remember on this device that this hint was dismissed: QuotaExceededError: quota exceeded',
)
setItem.mockRestore()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(screen.queryByRole('note')).toBeNull()
expect(screen.queryByRole('alert')).toBeNull()
expect(localStorage.getItem(DISMISSED_KEY)).toBe('1')
})
it('shows and reports the error when storage cannot be read', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('storage blocked', 'SecurityError')
})
render(<InstallHint />)
expect(screen.getByRole('note')).toHaveTextContent(copy)
expect(screen.getByRole('alert')).toHaveTextContent(
'Could not check whether this hint was dismissed on this device: SecurityError: storage blocked',
)
getItem.mockRestore()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(screen.getByRole('note')).toHaveTextContent(copy)
expect(screen.queryByRole('alert')).toBeNull()
})
})
+97
View File
@@ -0,0 +1,97 @@
import { useState } from 'react'
import { Share, X } from 'lucide-react'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import styles from './InstallHint.module.css'
// DISMISSED_KEY is the localStorage key remembering, on this device, that the
// household dismissed the hint.
export const DISMISSED_KEY = 'mealquest.installHint.dismissed'
// Safari's user agent ends "(KHTML, like Gecko) Version/<v> Mobile/<build>
// Safari/<v>" (iPadOS leaves out Mobile/). Every other iOS browser and in-app
// web view adds its own token (CriOS, FxiOS, EdgiOS, OPiOS, MQQBrowser, GSA,
// ...) or drops Version/ or Safari/, so only this exact tail is Safari.
const SAFARI_TAIL = /\(KHTML, like Gecko\) Version\/[\d.]+ (?:Mobile\/\w+ )?Safari\/[\d.]+$/
// isIosSafari reports whether the page is open in Safari on iPhone, iPod or
// iPad. iPadOS 13+ asks for the desktop site and reports itself as a Mac, so
// a "Macintosh" with a touch screen is an iPad.
export function isIosSafari(nav: Pick<Navigator, 'userAgent' | 'maxTouchPoints'>): boolean {
const ua = nav.userAgent
const ios = /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && nav.maxTouchPoints > 1)
return ios && SAFARI_TAIL.test(ua)
}
// isStandalone reports whether the app is running from the Home Screen.
// Safari sets navigator.standalone; other browsers answer the display-mode
// media query.
export function isStandalone(): boolean {
const legacy = (navigator as Navigator & { standalone?: boolean }).standalone
return legacy === true || window.matchMedia('(display-mode: standalone)').matches
}
// HintState is whether the household dismissed the hint on this device, and
// the storage failure, if any, that stopped us reading or saving that.
type HintState = {
dismissed: boolean
failure: { action: 'read' | 'write'; message: string } | null
}
// Storage can be unavailable (blocked by the browser or privacy settings), in
// which case using it throws. The failure is reported, never treated as a
// remembered dismissal.
function readDismissed(): HintState {
try {
return { dismissed: window.localStorage.getItem(DISMISSED_KEY) === '1', failure: null }
} catch (err) {
return { dismissed: false, failure: { action: 'read', message: String(err) } }
}
}
function writeDismissed(): HintState {
try {
window.localStorage.setItem(DISMISSED_KEY, '1')
return { dismissed: true, failure: null }
} catch (err) {
return { dismissed: false, failure: { action: 'write', message: String(err) } }
}
}
const failureText = {
read: 'Could not check whether this hint was dismissed on this device',
write: 'Could not remember on this device that this hint was dismissed',
}
// InstallHint tells iOS and iPadOS Safari users how to install the app, which
// iOS requires before it delivers Web Push. It shows only in a Safari browser
// tab, never once installed, and stays dismissed on this device. If storage
// fails, the hint stays up with an error saying so. Used by onboarding step 3
// and the Notifications screen.
export function InstallHint() {
const [eligible] = useState(() => isIosSafari(navigator) && !isStandalone())
const [state, setState] = useState<HintState>(() =>
eligible ? readDismissed() : { dismissed: false, failure: null },
)
if (!eligible || state.dismissed) return null
const { failure } = state
return (
<>
<Card tone="muted" role="note" className={styles.hint}>
<Icon icon={Share} className={styles.icon} />
<p className={styles.message}>Tap Share, then Add to Home Screen to get notifications</p>
<IconButton label="Dismiss" onClick={() => setState(writeDismissed())}>
<Icon icon={X} />
</IconButton>
</Card>
{failure && (
<ErrorBanner onRetry={() => setState(failure.action === 'read' ? readDismissed() : writeDismissed())}>
{failureText[failure.action]}: {failure.message}
</ErrorBanner>
)}
</>
)
}
+22
View File
@@ -0,0 +1,22 @@
.pill {
position: fixed;
top: calc(var(--space-3) + env(safe-area-inset-top));
left: 50%;
z-index: 20;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: var(--space-2);
margin: 0;
padding: 6px 14px;
border-radius: var(--radius-pill);
background: var(--color-neutral-800);
color: var(--on-accent);
box-shadow: var(--shadow-md);
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
pointer-events: none;
}
+134
View File
@@ -0,0 +1,134 @@
import { act, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, type ApiClient } from '../api/index.ts'
import { fakeLocation, jsonResponse, ME, stubFetch } from '../test/fetchStub.ts'
import { createNetworkStatus } from './networkStatus.ts'
import { OfflinePill } from './OfflinePill.tsx'
const copy = 'Offline — showing saved data'
let onLine: boolean
beforeEach(() => {
onLine = true
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function goOffline() {
onLine = false
window.dispatchEvent(new Event('offline'))
}
function goOnline() {
onLine = true
window.dispatchEvent(new Event('online'))
}
function renderPill(): ApiClient {
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
render(
<ApiProvider client={api}>
<OfflinePill />
</ApiProvider>,
)
return api
}
describe('OfflinePill', () => {
it('is hidden while online', () => {
renderPill()
expect(screen.queryByRole('status')).toBeNull()
})
it('shows while the browser is offline when it renders', () => {
onLine = false
renderPill()
expect(screen.getByRole('status')).toHaveTextContent(copy)
})
it('appears on the offline event and disappears on the online event', () => {
renderPill()
act(goOffline)
expect(screen.getByRole('status')).toHaveTextContent(copy)
act(goOnline)
expect(screen.queryByRole('status')).toBeNull()
})
it('appears when a request fails with a network error', async () => {
const api = renderPill()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow(TypeError))
expect(screen.getByRole('status')).toHaveTextContent(copy)
act(goOnline)
expect(screen.queryByRole('status')).toBeNull()
})
it('disappears when a later request gets a response', async () => {
const api = renderPill()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow(TypeError))
expect(screen.getByRole('status')).toBeInTheDocument()
stubFetch(jsonResponse(200, {}))
await act(() => api.apiFetch('/api/v1/plan'))
expect(screen.queryByRole('status')).toBeNull()
})
it('ignores a request body that cannot be serialized', async () => {
const api = renderPill()
const fetchMock = stubFetch(jsonResponse(200, ME))
await act(() => api.loadMe())
const body: Record<string, unknown> = {}
body.self = body
await act(() =>
expect(api.apiFetch('/api/v1/plan', { method: 'PUT', body })).rejects.toThrow(
'PUT /api/v1/plan: request body is not serializable as JSON',
),
)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('status')).toBeNull()
})
it('ignores a GET request with a body, which fetch would refuse to send', async () => {
const api = renderPill()
const fetchMock = stubFetch()
await act(() =>
expect(api.apiFetch('/api/v1/plan', { body: { week: 1 } })).rejects.toThrow(
'GET /api/v1/plan: a GET request cannot carry a body',
),
)
expect(fetchMock).not.toHaveBeenCalled()
expect(screen.queryByRole('status')).toBeNull()
})
it('ignores an aborted request', async () => {
const api = renderPill()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new DOMException('aborted', 'AbortError')))
await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow('aborted'))
expect(screen.queryByRole('status')).toBeNull()
})
it('removes its online and offline listeners on unmount', () => {
const add = vi.spyOn(window, 'addEventListener')
const remove = vi.spyOn(window, 'removeEventListener')
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
const { unmount } = render(
<ApiProvider client={api}>
<OfflinePill />
</ApiProvider>,
)
const added = add.mock.calls.filter(([type]) => type === 'online' || type === 'offline')
expect(added.map(([type]) => type).sort()).toEqual(['offline', 'online'])
unmount()
for (const [type, listener] of added) expect(remove).toHaveBeenCalledWith(type, listener)
})
})
+19
View File
@@ -0,0 +1,19 @@
import { useSyncExternalStore } from 'react'
import { WifiOff } from 'lucide-react'
import { useApi } from '../api/index.ts'
import { Icon } from '../ui/Icon.tsx'
import styles from './OfflinePill.module.css'
// OfflinePill tells the household that what they see came from the cache
// while the browser is offline or the server cannot be reached.
export function OfflinePill() {
const { network } = useApi()
const offline = useSyncExternalStore(network.subscribe, network.offline)
if (!offline) return null
return (
<p role="status" className={styles.pill}>
<Icon icon={WifiOff} />
Offline — showing saved data
</p>
)
}
+22
View File
@@ -0,0 +1,22 @@
.toast {
position: fixed;
left: 50%;
bottom: calc(110px + env(safe-area-inset-bottom));
z-index: 20;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: var(--space-3);
width: max-content;
max-width: calc(100vw - 2 * var(--space-4));
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
box-shadow: var(--shadow-lg);
}
.message {
margin: 0;
font-family: var(--font-body);
font-size: 15px;
font-weight: 600;
line-height: 1.35;
}
+165
View File
@@ -0,0 +1,165 @@
import { act, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { SKIP_WAITING, UpdatePrompt, type UpdateContainer } from './UpdatePrompt.tsx'
// FakeWorker is a ServiceWorker whose state the test moves along.
class FakeWorker extends EventTarget {
state: ServiceWorkerState = 'installing'
postMessage = vi.fn()
setState(state: ServiceWorkerState) {
this.state = state
this.dispatchEvent(new Event('statechange'))
}
}
class FakeRegistration extends EventTarget {
installing: FakeWorker | null = null
waiting: FakeWorker | null = null
// install starts a new worker, as the browser does on finding an update.
install(): FakeWorker {
const worker = new FakeWorker()
this.installing = worker
this.dispatchEvent(new Event('updatefound'))
return worker
}
// finishInstall moves the installing worker to waiting.
finishInstall(worker: FakeWorker) {
this.installing = null
this.waiting = worker
worker.setState('installed')
}
}
class FakeContainer extends EventTarget {
controller: object | null = {}
registration = new FakeRegistration()
ready = Promise.resolve(this.registration)
// takeOver makes the waiting worker the controller.
takeOver() {
const worker = this.registration.waiting
this.registration.waiting = null
this.controller = worker
worker?.setState('activated')
this.dispatchEvent(new Event('controllerchange'))
}
}
async function renderPrompt(container: FakeContainer | undefined) {
const reload = vi.fn()
const view = render(<UpdatePrompt container={container as unknown as UpdateContainer} reload={reload} />)
await act(async () => {
await container?.ready
})
return { reload, ...view }
}
const copy = 'New version ready · Reload'
describe('UpdatePrompt', () => {
it('shows nothing without a service worker', async () => {
await renderPrompt(undefined)
expect(screen.queryByRole('status')).toBeNull()
})
it('shows nothing while no worker is waiting', async () => {
const container = new FakeContainer()
await renderPrompt(container)
act(() => {
container.registration.install()
})
expect(screen.queryByRole('status')).toBeNull()
})
it('shows nothing for a waiting worker when the page has no controller', async () => {
const container = new FakeContainer()
container.controller = null
container.registration.waiting = new FakeWorker()
await renderPrompt(container)
expect(screen.queryByRole('status')).toBeNull()
})
it('shows the toast for a worker already waiting when the page loads', async () => {
const container = new FakeContainer()
container.registration.waiting = new FakeWorker()
await renderPrompt(container)
expect(screen.getByRole('status')).toHaveTextContent(copy)
})
it('shows the toast when an update finishes installing', async () => {
const container = new FakeContainer()
await renderPrompt(container)
act(() => container.registration.finishInstall(container.registration.install()))
expect(screen.getByRole('status')).toHaveTextContent(copy)
})
it('sends SKIP_WAITING on Reload and reloads once the new worker controls the page', async () => {
const container = new FakeContainer()
const { reload } = await renderPrompt(container)
const worker = container.registration.install()
act(() => container.registration.finishInstall(worker))
act(() => screen.getByRole('button', { name: 'Reload' }).click())
expect(worker.postMessage).toHaveBeenCalledExactlyOnceWith(SKIP_WAITING)
expect(screen.queryByRole('status')).toBeNull()
expect(reload).not.toHaveBeenCalled()
act(() => container.takeOver())
expect(reload).toHaveBeenCalledOnce()
// A later controller change (another tab's update) does not reload again.
act(() => container.dispatchEvent(new Event('controllerchange')))
expect(reload).toHaveBeenCalledOnce()
expect(screen.queryByRole('status')).toBeNull()
})
it('does not reappear for the worker the household chose to reload into', async () => {
const container = new FakeContainer()
container.registration.waiting = new FakeWorker()
await renderPrompt(container)
act(() => screen.getByRole('button', { name: 'Reload' }).click())
// The worker reports another state change before it takes over.
act(() => container.registration.waiting?.setState('installed'))
expect(screen.queryByRole('status')).toBeNull()
})
it('hides when dismissed until the next update is waiting', async () => {
const container = new FakeContainer()
const { reload } = await renderPrompt(container)
const first = container.registration.install()
act(() => container.registration.finishInstall(first))
act(() => screen.getByRole('button', { name: 'Dismiss' }).click())
expect(screen.queryByRole('status')).toBeNull()
expect(first.postMessage).not.toHaveBeenCalled()
act(() => first.setState('installed'))
expect(screen.queryByRole('status')).toBeNull()
act(() => container.registration.finishInstall(container.registration.install()))
expect(screen.getByRole('status')).toHaveTextContent(copy)
expect(reload).not.toHaveBeenCalled()
})
it('hides when another tab activates the waiting worker', async () => {
const container = new FakeContainer()
container.registration.waiting = new FakeWorker()
const { reload } = await renderPrompt(container)
act(() => container.takeOver())
expect(screen.queryByRole('status')).toBeNull()
expect(reload).not.toHaveBeenCalled()
})
it('removes its listeners on unmount', async () => {
const container = new FakeContainer()
const worker = new FakeWorker()
container.registration.waiting = worker
const { reload, unmount } = await renderPrompt(container)
const removeRegistration = vi.spyOn(container.registration, 'removeEventListener')
const removeWorker = vi.spyOn(worker, 'removeEventListener')
act(() => screen.getByRole('button', { name: 'Reload' }).click())
unmount()
expect(removeRegistration).toHaveBeenCalledWith('updatefound', expect.any(Function))
expect(removeWorker).toHaveBeenCalledWith('statechange', expect.any(Function))
container.takeOver()
expect(reload).not.toHaveBeenCalled()
})
})
+101
View File
@@ -0,0 +1,101 @@
import { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import styles from './UpdatePrompt.module.css'
// UpdateContainer is the part of navigator.serviceWorker the prompt uses.
export type UpdateContainer = Pick<ServiceWorkerContainer, 'ready' | 'controller' | 'addEventListener' | 'removeEventListener'>
export type UpdatePromptProps = {
// navigator.serviceWorker, or undefined where the browser has none.
container: UpdateContainer | undefined
// Reloads the page once the new worker controls it.
reload: () => void
}
// SKIP_WAITING is the message src/sw.ts answers by activating the waiting
// worker.
export const SKIP_WAITING = { type: 'SKIP_WAITING' } as const
// UpdatePrompt offers to reload when a new service worker is installed and
// waiting to replace the one controlling the page. Reload asks the waiting
// worker to activate and reloads once it controls the page; dismissing hides
// the prompt until a different worker is waiting.
export function UpdatePrompt({ container, reload }: UpdatePromptProps) {
const [waiting, setWaiting] = useState<ServiceWorker | null>(null)
const [dismissed, setDismissed] = useState<ServiceWorker | null>(null)
const [accepted, setAccepted] = useState<ServiceWorker | null>(null)
const reloadListener = useRef<(() => void) | null>(null)
useEffect(() => {
if (container === undefined) return
const sw = container
let registration: ServiceWorkerRegistration | null = null
let unmounted = false
const watched = new Set<ServiceWorker>()
// A waiting worker only replaces something when the page has a
// controller; without one there is no controllerchange to reload on.
function check() {
if (registration === null) return
setWaiting(sw.controller === null ? null : registration.waiting)
}
function watch(worker: ServiceWorker | null) {
if (worker === null || watched.has(worker)) return
watched.add(worker)
worker.addEventListener('statechange', check)
}
function updateFound() {
watch(registration?.installing ?? null)
}
void sw.ready.then((ready) => {
if (unmounted) return
registration = ready
ready.addEventListener('updatefound', updateFound)
watch(ready.installing)
watch(ready.waiting)
check()
})
return () => {
unmounted = true
registration?.removeEventListener('updatefound', updateFound)
watched.forEach((worker) => worker.removeEventListener('statechange', check))
if (reloadListener.current !== null) {
sw.removeEventListener('controllerchange', reloadListener.current)
reloadListener.current = null
}
}
}, [container])
if (container === undefined || waiting === null || waiting === dismissed || waiting === accepted) return null
function update() {
if (container === undefined || waiting === null || reloadListener.current !== null) return
const listener = () => {
reloadListener.current = null
reload()
}
reloadListener.current = listener
container.addEventListener('controllerchange', listener, { once: true })
setAccepted(waiting)
waiting.postMessage(SKIP_WAITING)
}
return (
<Card role="status" className={styles.toast}>
<p className={styles.message}>
New version ready <span aria-hidden="true">·</span>
</p>
{/* The space keeps the copy "New version ready · Reload" in the text;
a flex container does not render it. */}{' '}
<Button onClick={update}>Reload</Button>
<IconButton label="Dismiss" onClick={() => setDismissed(waiting)}>
<Icon icon={X} />
</IconButton>
</Card>
)
}
+50
View File
@@ -0,0 +1,50 @@
// NetworkWindow is the part of window the network status reads: the browser's
// online flag and its online/offline events.
export type NetworkWindow = Pick<Window, 'navigator' | 'addEventListener' | 'removeEventListener'>
export type NetworkStatus = {
// offline reports whether the browser says it is offline or the last API
// request failed with a network error.
offline(): boolean
// subscribe is notified when offline may have changed. It listens for the
// window's online/offline events until the returned function is called.
subscribe(listener: () => void): () => void
// requestFailed records a fetch that rejected with a network error.
requestFailed(): void
// requestSucceeded records a fetch that got a response.
requestSucceeded(): void
}
// createNetworkStatus tracks whether MealQuest can reach the server. A failed
// request counts as offline until a later request gets a response or the
// browser reports that it is back online.
export function createNetworkStatus(win: NetworkWindow): NetworkStatus {
let failed = false
const listeners = new Set<() => void>()
function setFailed(value: boolean) {
if (failed === value) return
failed = value
listeners.forEach((listener) => listener())
}
return {
offline: () => !win.navigator.onLine || failed,
subscribe(listener) {
const online = () => {
failed = false
listener()
}
listeners.add(listener)
win.addEventListener('online', online)
win.addEventListener('offline', listener)
return () => {
listeners.delete(listener)
win.removeEventListener('online', online)
win.removeEventListener('offline', listener)
}
},
requestFailed: () => setFailed(true),
requestSucceeded: () => setFailed(false),
}
}
+2 -2
View File
@@ -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(
<MemoryRouter initialEntries={[path]}>
<App />
<AppRoutes />
</MemoryRouter>,
)
return resize
+2 -2
View File
@@ -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(
<MemoryRouter initialEntries={[path]}>
<App />
<AppRoutes />
</MemoryRouter>,
)
}
+103
View File
@@ -0,0 +1,103 @@
// MealQuest service worker. vite-plugin-pwa replaces self.__WB_MANIFEST with
// the build's precache list (shell, JS, CSS and fonts, each with a revision).
// Every other request goes through the route src/sw/routes.ts decides.
// Fonts are precached too, but their route is registered ahead of the
// precache route, so a font fetch goes through the expiring CacheFirst
// strategy and falls back to the precached copy when the network fails.
import { cacheNames as workboxCacheNames, setCacheNameDetails } from 'workbox-core'
import type { RouteHandlerCallbackOptions } from 'workbox-core/types'
import { ExpirationPlugin } from 'workbox-expiration'
import { PrecacheFallbackPlugin, matchPrecache, precacheAndRoute } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import { CacheFirst, NetworkFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
import {
cacheNames,
cacheVersion,
networkTimeoutSeconds,
routeFor,
staleCaches,
type Route,
} from './sw/routes'
declare const self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: Array<{ url: string; revision: string | null }>
}
// The precache becomes mealquest-precache-v1.
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion })
const day = 24 * 60 * 60
const strategies = {
[cacheNames.api]: new NetworkFirst({ cacheName: cacheNames.api, networkTimeoutSeconds }),
[cacheNames.media]: new CacheFirst({
cacheName: cacheNames.media,
plugins: [
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * day, purgeOnQuotaError: true }),
],
}),
[cacheNames.fonts]: new CacheFirst({
cacheName: cacheNames.fonts,
plugins: [
new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 365 * day, purgeOnQuotaError: true }),
// Offline with no runtime copy yet: the font from the precache.
{ handlerDidError: async ({ request }) => matchPrecache(request.url) },
],
}),
}
const networkOnly = new NetworkOnly()
const navigation = new NetworkOnly({
plugins: [new PrecacheFallbackPlugin({ fallbackURL: 'index.html' })],
})
function strategyFor(route: Route): Strategy {
switch (route.strategy) {
case 'network-first':
case 'cache-first':
return strategies[route.cacheName]
case 'network-only':
return networkOnly
case 'navigation-fallback':
return navigation
}
}
function routeOf({ request, url }: { request: Request; url: URL }): Route {
return routeFor({ method: request.method, url, mode: request.mode }, self.location.origin)
}
function handle({ request, url, event }: RouteHandlerCallbackOptions): Promise<Response> {
return strategyFor(routeOf({ request, url })).handle({ request, event })
}
function isFont(options: { request: Request; url: URL }): boolean {
const route = routeOf(options)
return route.strategy === 'cache-first' && route.cacheName === cacheNames.fonts
}
// Workbox uses the first route that matches, so the font route comes before
// the precache route, which would otherwise answer every precached font.
registerRoute(isFont, handle, 'GET')
precacheAndRoute(self.__WB_MANIFEST)
// Workbox routes are per method; every method goes through routeFor, which
// sends every non-GET to the network only.
for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as const) {
registerRoute(() => true, handle, method)
}
self.addEventListener('activate', (event) => {
const current = [workboxCacheNames.precache, ...Object.values(cacheNames)]
event.waitUntil(
caches
.keys()
.then((names) => Promise.all(staleCaches(names, current).map((name) => caches.delete(name)))),
)
})
// The update prompt (src/pwa/UpdatePrompt.tsx) asks a waiting worker to take
// over when the household chooses Reload.
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') void self.skipWaiting()
})
+157
View File
@@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest'
import { cacheNames, routeFor, staleCaches, type Route } from './routes'
const origin = 'https://mealquest.test'
function route(path: string, method = 'GET', mode: RequestMode = 'cors'): Route {
return routeFor({ method, url: new URL(path, origin), mode }, origin)
}
const apiCache: Route = { strategy: 'network-first', cacheName: cacheNames.api }
const networkOnly: Route = { strategy: 'network-only' }
describe('routeFor', () => {
it.each([
'/api/v1/plans',
'/api/v1/plans/2026-W40',
'/api/v1/shopping/current',
'/api/v1/dinners',
'/api/v1/dinners/01J9ZK?include=recipe',
'/api/v1/home',
])('GET %s is network-first in the API cache', (path) => {
expect(route(path)).toEqual(apiCache)
})
it.each(['/media/photos/abc.jpg', '/media/thumbs/abc.webp'])(
'GET %s is cache-first in the media cache',
(path) => {
expect(route(path)).toEqual({ strategy: 'cache-first', cacheName: cacheNames.media })
},
)
it('GET of a font file is cache-first in the fonts cache', () => {
expect(route('/assets/Figtree-Bold-a1b2c3.woff2')).toEqual({
strategy: 'cache-first',
cacheName: cacheNames.fonts,
})
})
it.each([
'/auth/login',
'/auth/callback?code=x&state=y',
'/auth/logout',
'/auth/me',
'/api/v1/settings',
'/api/v1/settings/ai',
'/api/v1/jobs',
'/api/v1/jobs/01J9ZK',
])('GET %s is network-only', (path) => {
expect(route(path)).toEqual(networkOnly)
})
it.each(['/auth/login', '/auth/callback?code=x&state=y', '/api/v1/settings/ai'])(
'a navigation to %s is network-only with no offline fallback',
(path) => {
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
},
)
it.each([
'/%61uth/login',
'/auth%2Fcallback?code=x&state=y',
'/%61pi/v1/settings/household',
'/api/v1/%73ettings/ai',
'/api/v1/settings%2Fai',
'/%61pi/v1/jobs/01J9ZK',
'/api/v1/j%6Fbs',
'/api/v1/plans%2F..%2Fsettings',
'/plan%2F..%2Fauth%2Flogin',
])('an encoded reserved path %s is network-only, navigated or not', (path) => {
expect(route(path)).toEqual(networkOnly)
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
})
it.each(['/%E0%A4%A', '/auth%', '/plan/%zz', '/api/v1/plans/%C3'])(
'a malformed encoding in %s is network-only, navigated or not',
(path) => {
expect(route(path)).toEqual(networkOnly)
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
},
)
it('classifies other API paths by their decoded form', () => {
expect(route('/%61pi/v1/plans/2026-W40')).toEqual(apiCache)
expect(route('/%61pi/v1/recipes', 'GET', 'navigate')).toEqual(networkOnly)
})
it.each(['POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'])(
'%s is network-only on every URL class',
(method) => {
for (const path of [
'/api/v1/plans/2026-W40',
'/api/v1/shopping/current',
'/api/v1/dinners/01J9ZK',
'/api/v1/home',
'/media/photos/abc.jpg',
'/assets/Figtree-Bold-a1b2c3.woff2',
'/auth/logout',
'/plan',
]) {
expect(route(path, method), `${method} ${path}`).toEqual(networkOnly)
expect(route(path, method, 'navigate'), `${method} ${path}`).toEqual(networkOnly)
}
},
)
it('matches whole path segments only', () => {
expect(route('/api/v1/plansx')).toEqual(networkOnly)
expect(route('/api/v1/homepage')).toEqual(networkOnly)
expect(route('/mediax/a.jpg')).toEqual(networkOnly)
expect(route('/authors')).toEqual(networkOnly)
expect(route('/api/v1/settingsx')).toEqual(networkOnly)
})
it('leaves other API calls network-only', () => {
expect(route('/api/v1/recipes')).toEqual(networkOnly)
expect(route('/api/v1/ideas', 'GET', 'navigate')).toEqual(networkOnly)
})
it('leaves cross-origin requests network-only', () => {
for (const path of ['/api/v1/plans', '/media/a.jpg', '/assets/a.woff2']) {
const url = new URL(path, 'https://cdn.example')
expect(routeFor({ method: 'GET', url, mode: 'no-cors' }, origin)).toEqual(networkOnly)
}
})
it.each(['/', '/plan', '/supply-run', '/dinners/01J9ZK'])(
'a navigation to the app at %s falls back to the precached index.html',
(path) => {
expect(route(path, 'GET', 'navigate')).toEqual({ strategy: 'navigation-fallback' })
},
)
it('leaves other same-origin GETs network-only', () => {
expect(route('/health')).toEqual(networkOnly)
})
})
describe('cache names', () => {
it('all start with mealquest-', () => {
for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/)
})
})
describe('staleCaches', () => {
it('deletes only MealQuest caches that are not current', () => {
const current = ['mealquest-precache-v1', ...Object.values(cacheNames)]
const names = [
...current,
'mealquest-precache',
'mealquest-api-v0',
'workbox-precache-v2-https://mealquest.test/',
'other-app-cache',
'my-mealquest-cache',
]
expect(staleCaches(names, current)).toEqual(['mealquest-precache', 'mealquest-api-v0'])
})
})
+96
View File
@@ -0,0 +1,96 @@
// Decides how the service worker handles each request. This module is pure —
// no Workbox, no service worker globals — so the tests can assert every URL
// class without a worker; src/sw.ts maps each route to a Workbox strategy.
//
// Security rule: /auth/*, /api/v1/settings/*, /api/v1/jobs/* and every
// non-GET request are network-only and never reach a cache. Paths are matched
// as the server sees them — percent-decoded, before and after dot segments are
// resolved — so /%61uth/login is /auth/login; a malformed encoding is
// network-only.
export type Route =
// Network with a 3 s timeout, then the last cached response.
| { strategy: 'network-first'; cacheName: typeof cacheNames.api }
// Cached response when there is one, else the network; entries expire.
| { strategy: 'cache-first'; cacheName: typeof cacheNames.media | typeof cacheNames.fonts }
// Network only, nothing cached.
| { strategy: 'network-only' }
// Network only, nothing cached; offline, the precached index.html.
| { strategy: 'navigation-fallback' }
export interface RouteRequest {
method: string
url: URL
mode: RequestMode
}
// Bumping the version renames every cache; activate then deletes the old ones.
export const cacheVersion = 'v1'
export const cachePrefix = 'mealquest-'
export const cacheNames = {
api: `${cachePrefix}api-${cacheVersion}`,
media: `${cachePrefix}media-${cacheVersion}`,
fonts: `${cachePrefix}fonts-${cacheVersion}`,
} as const
export const networkTimeoutSeconds = 3
const neverCached = ['/auth', '/api/v1/settings', '/api/v1/jobs']
const networkFirst = ['/api/v1/plans', '/api/v1/shopping', '/api/v1/dinners', '/api/v1/home']
// True when path is prefix itself or a path below it: /api/v1/plans matches
// /api/v1/plans and /api/v1/plans/2026-W40, never /api/v1/plansx.
function under(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}/`)
}
// The pathname percent-decoded, as the server's router sees it, or undefined
// when the encoding is malformed.
function decodePath(pathname: string): string | undefined {
try {
return decodeURIComponent(pathname)
} catch {
return undefined
}
}
// Resolves "." and ".." segments the way the server's path.Clean does, so a
// decoded %2F cannot carry a request out of a reserved prefix:
// /api/v1/plans/../settings cleans to /api/v1/settings.
function cleanPath(path: string): string {
const segments: string[] = []
for (const segment of path.split('/')) {
if (segment === '' || segment === '.') continue
if (segment === '..') segments.pop()
else segments.push(segment)
}
return `/${segments.join('/')}`
}
const networkOnly: Route = { strategy: 'network-only' }
export function routeFor(request: RouteRequest, origin: string): Route {
const { method, url, mode } = request
if (method !== 'GET') return networkOnly
if (url.origin !== origin) return networkOnly
const path = decodePath(url.pathname)
if (path === undefined) return networkOnly
const cleaned = cleanPath(path)
const reserved = (prefix: string) => under(path, prefix) || under(cleaned, prefix)
if (neverCached.some(reserved)) return networkOnly
if (networkFirst.some((prefix) => under(path, prefix))) {
return { strategy: 'network-first', cacheName: cacheNames.api }
}
if (under(path, '/media')) return { strategy: 'cache-first', cacheName: cacheNames.media }
if (path.endsWith('.woff2')) return { strategy: 'cache-first', cacheName: cacheNames.fonts }
if (reserved('/api')) return networkOnly
if (mode === 'navigate') return { strategy: 'navigation-fallback' }
return networkOnly
}
// The caches activate deletes: MealQuest caches that are no longer current.
// Caches without the mealquest- prefix belong to someone else and are kept.
export function staleCaches(names: readonly string[], current: readonly string[]): string[] {
return names.filter((name) => name.startsWith(cachePrefix) && !current.includes(name))
}
+30
View File
@@ -0,0 +1,30 @@
// The service worker itself needs worker globals, so these tests read its
// source: they pin the parts routes.ts cannot express — the font strategy's
// expiration and the font route's place ahead of the precache route.
import { describe, expect, it } from 'vitest'
import source from '../sw.ts?raw'
describe('service worker source', () => {
it('gives the fonts cache a CacheFirst strategy with an expiration plugin', () => {
expect(source).toMatch(
/\[cacheNames\.fonts\]: new CacheFirst\(\{\s*cacheName: cacheNames\.fonts,\s*plugins: \[\s*new ExpirationPlugin\(\{ maxEntries: \d+, maxAgeSeconds: [^}]+\}\)/,
)
})
it('falls back to the precached font when the network fails', () => {
expect(source).toContain('handlerDidError: async ({ request }) => matchPrecache(request.url)')
})
it('registers the font route before the precache route', () => {
const fontRoute = source.indexOf("registerRoute(isFont, handle, 'GET')")
const precache = source.indexOf('precacheAndRoute(self.__WB_MANIFEST)')
expect(fontRoute).toBeGreaterThanOrEqual(0)
expect(precache).toBeGreaterThan(fontRoute)
})
it('activates a waiting worker when the update prompt sends SKIP_WAITING', () => {
expect(source).toMatch(
/self\.addEventListener\('message', \(event\) => \{\s*if \(event\.data\?\.type === 'SKIP_WAITING'\) void self\.skipWaiting\(\)/,
)
})
})
+49
View File
@@ -0,0 +1,49 @@
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<typeof fetch> {
const fetchMock = vi.fn<typeof fetch>()
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<typeof fetch>, 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 on https://mealquest.test,
// recording navigations.
export function fakeLocation(pathname = '/', search = ''): ApiLocation & { assign: Mock<(url: string | URL) => void> } {
return { origin: 'https://mealquest.test', pathname, search, assign: vi.fn<(url: string | URL) => void>() }
}
+3 -1
View File
@@ -13,9 +13,11 @@
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "vite.config.ts"]
"include": ["src", "vite.config.ts", "manifest.json"],
"exclude": ["src/sw.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"lib": ["ES2022", "WebWorker"],
"types": []
},
"include": ["src/sw.ts"],
"exclude": []
}
+21 -1
View File
@@ -1,10 +1,30 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { VitePWA, type ManifestOptions } from 'vite-plugin-pwa'
import manifest from './manifest.json' with { type: 'json' }
const backend = 'http://localhost:8080'
export default defineConfig({
plugins: [react()],
plugins: [
react(),
// The plugin writes manifest.webmanifest and sw.js, links the manifest
// from index.html and adds the registerSW.js script. It does both only
// in production builds: dev options stay off, so neither `vite` nor
// Vitest registers a service worker.
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
injectRegister: 'script',
// Precache the shell, JS, CSS and the self-hosted fonts.
injectManifest: { globPatterns: ['**/*.{html,js,css,woff2}'] },
// The web manifest lives in manifest.json: its theme and background
// colours are --color-accent and --color-bg from src/styles/tokens.css,
// and hex colours may not be written in TypeScript.
manifest: manifest as Partial<ManifestOptions>,
}),
],
server: {
proxy: {
'/api': backend,