Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 ce2e94869a Refuse a GET request with a body before fetching so it never marks the network offline
CI / ci (push) Successful in 1m34s
CI / ci (pull_request) Successful in 1m34s
fetch rejects a GET with a body with a TypeError before sending anything,
which the client took for a network failure and showed the offline pill.
apiFetch now refuses such a request with its own error before calling fetch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:56:33 -04:00
dev_agentandClaude Opus 5.5 0573b39f6d Serialize the request body before fetching so it never marks the network offline
CI / ci (push) Successful in 1m33s
CI / ci (pull_request) Successful in 1m33s
A body JSON.stringify cannot serialize, such as a cyclic one, threw inside
the fetch try/catch and was recorded as a network failure, showing the
offline pill. It now fails before the fetch with its own error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:49:15 -04:00
dev_agentandClaude Opus 5.5 c1e49db87f Add the update prompt and the offline pill
CI / ci (push) Successful in 1m36s
CI / ci (pull_request) Successful in 1m33s
Independent review Changes required — 1 medium finding(s) (#52)
When a new service worker is waiting to replace the one controlling the
page, a "New version ready · Reload" toast offers to reload. Reload sends
SKIP_WAITING, which src/sw.ts answers with skipWaiting(), and reloads once
on the controllerchange; dismissing hides the toast until a different
worker is waiting.

An "Offline — showing saved data" pill shows while navigator.onLine is
false or the last API request failed with a network error. The network
status store the API client reports to clears on the online event or the
next response, and removes its window listeners on unsubscribe.

Maestro task 63f6c6c2-2936-4738-9869-432d7a11141d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:11:08 -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
30 changed files with 6461 additions and 29 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": "npm run lint:colors && 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()
+2 -1
View File
@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
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'
@@ -14,7 +15,7 @@ afterEach(() => {
function renderAt(path: string, location = fakeLocation(path)) {
render(
<MemoryRouter initialEntries={[path]}>
<App api={createApiClient(location)} />
<App api={createApiClient(location, createNetworkStatus(window))} serviceWorker={undefined} />
</MemoryRouter>,
)
return location
+11 -1
View File
@@ -4,6 +4,8 @@ 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.
@@ -40,11 +42,17 @@ export function AppRoutes() {
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 }: AppProps) {
export default function App({ api, serviceWorker }: AppProps) {
const [queryClient] = useState(createQueryClient)
return (
<ApiProvider client={api}>
@@ -52,6 +60,8 @@ export default function App({ api }: AppProps) {
<AuthBootstrap>
<AppRoutes />
</AuthBootstrap>
<OfflinePill />
<UpdatePrompt container={serviceWorker} reload={reloadPage} />
</QueryClientProvider>
</ApiProvider>
)
+13 -12
View File
@@ -1,6 +1,7 @@
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()
@@ -9,7 +10,7 @@ afterEach(() => {
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())
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
await api.loadMe()
await expect(api.apiFetch('/api/v1/settings/household')).resolves.toEqual({ ok: 1 })
@@ -29,7 +30,7 @@ describe('apiFetch', () => {
it('refuses a non-GET request before /me has loaded', async () => {
const fetchMock = stubFetch()
const api = createApiClient(fakeLocation())
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()
})
@@ -47,7 +48,7 @@ describe('apiFetch', () => {
'api/v1/me',
])('refuses %s without sending it or the CSRF token', async (path) => {
const fetchMock = stubFetch(jsonResponse(200, ME))
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
await api.loadMe()
await expect(api.apiFetch(path, { method: 'POST', body: {} })).rejects.toThrow(
@@ -60,7 +61,7 @@ describe('apiFetch', () => {
it('rejects with a typed ApiError from the error body', async () => {
stubFetch(errorResponse(503, 'unavailable', true))
const api = createApiClient(fakeLocation())
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 })
@@ -68,14 +69,14 @@ describe('apiFetch', () => {
it('rejects a response without the API error body', async () => {
stubFetch(new Response('<html>bad gateway</html>', { status: 502 }))
const api = createApiClient(fakeLocation())
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)
const api = createApiClient(location, createNetworkStatus(window))
await expect(api.apiFetch('/api/v1/settings/household')).rejects.toMatchObject({ status: 401 })
expect(location.assign).toHaveBeenCalledExactlyOnceWith(
@@ -90,7 +91,7 @@ describe('apiFetch', () => {
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
jsonResponse(201, { id: 'j1' }),
)
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
await api.loadMe()
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: { kind: 'noop' } })).resolves.toEqual({
@@ -111,7 +112,7 @@ describe('apiFetch', () => {
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
errorResponse(403, 'csrf'),
)
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
await api.loadMe()
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toMatchObject({
@@ -124,7 +125,7 @@ describe('apiFetch', () => {
it('marks any other 403 as not in this household and notifies subscribers', async () => {
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
const listener = vi.fn()
api.subscribe(listener)
@@ -136,14 +137,14 @@ describe('apiFetch', () => {
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())
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())
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
await expect(api.loadMe()).rejects.toMatchObject({ status: 403, code: 'csrf' })
expect(fetchMock).toHaveBeenCalledTimes(1)
})
@@ -152,7 +153,7 @@ describe('apiFetch', () => {
describe('loadMe', () => {
it('rejects a /me response without a CSRF token', async () => {
stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
await expect(api.loadMe()).rejects.toThrow('response is not a member with a CSRF token')
})
})
+31 -8
View File
@@ -1,3 +1,4 @@
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.
@@ -36,7 +37,7 @@ export type ApiLocation = Pick<Location, 'origin' | 'pathname' | 'search' | 'ass
export type ApiRequest = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
// Sent as JSON.
// Sent as JSON. A GET must not have one.
body?: unknown
}
@@ -51,11 +52,14 @@ export type ApiClient = {
// 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): ApiClient {
export function createApiClient(location: ApiLocation, network: NetworkStatus): ApiClient {
let csrfToken: string | null = null
let refreshing: Promise<Me> | null = null
let forbidden = false
@@ -70,12 +74,25 @@ export function createApiClient(location: ApiLocation): ApiClient {
}
headers[CSRF_HEADER] = csrfToken
}
return fetch(path, {
method,
headers,
credentials: 'same-origin',
body: body === undefined ? undefined : JSON.stringify(body),
})
// 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
@@ -90,6 +107,11 @@ export function createApiClient(location: ApiLocation): ApiClient {
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)
@@ -151,5 +173,6 @@ export function createApiClient(location: ApiLocation): ApiClient {
listeners.add(listener)
return () => listeners.delete(listener)
},
network,
}
}
+2 -1
View File
@@ -3,6 +3,7 @@ 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[] }
@@ -11,7 +12,7 @@ function job(status: string, result: Draft | null, error: string | null = null)
}
function renderJob(jobId: string | null) {
const api = createApiClient(fakeLocation())
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 } })
}
+3 -2
View File
@@ -5,18 +5,19 @@ 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)
const api = createApiClient(window.location, createNetworkStatus(window))
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<App api={api} />
<App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />
</BrowserRouter>
</StrictMode>,
)
+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"')
})
})
+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),
}
}
+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\(\)/,
)
})
})
+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,