From 26609d8b6233961b35b69414ec82dfe19d2aa2ed Mon Sep 17 00:00:00 2001 From: Samu Date: Sat, 29 Aug 2026 19:16:27 +0200 Subject: [PATCH] docs: frontend implementation plan (12 tasks) --- .../plans/2026-08-29-record-shop-frontend.md | 3980 +++++++++++++++++ 1 file changed, 3980 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-29-record-shop-frontend.md diff --git a/docs/superpowers/plans/2026-08-29-record-shop-frontend.md b/docs/superpowers/plans/2026-08-29-record-shop-frontend.md new file mode 100644 index 0000000..3f9b77a --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-record-shop-frontend.md @@ -0,0 +1,3980 @@ +# record-shop Frontend (Plan 2) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the record-shop React SPA — mobile-first PWA with phone barcode scanning, library browsing with rip-status filters, item management, text search, and settings — completing the app described in `docs/superpowers/specs/2026-08-29-record-shop-design.md`. + +**Architecture:** React SPA in `web/`, served by the existing Fastify backend (it already serves `web/dist` with SPA fallback and `/artwork` — backend Task 17). All data flows through the REST API from plan 1 (contract below — treat it as frozen; do not modify `server/` except the Dockerfile in Task 12). Barcode decoding is client-side: native `BarcodeDetector` when available, `@zxing/library` fallback. + +**Tech Stack:** React 19, Vite 7, Tailwind CSS v4, react-router-dom 7, @zxing/library. Tests: Vitest 3 (jsdom) + @testing-library/react. Dev servers: `concurrently`. + +**Prerequisites:** Backend merged on `main` (82/82 tests passing). Work on branch `record-shop-frontend`. Node >= 20. + +--- + +## API contract (frozen, from plan 1) + +All endpoints are same-origin, cookie-authenticated (`rs_session`, httpOnly — fetch with `credentials: 'same-origin'` default works, but the Vite dev proxy must forward cookies, which it does by default). + +| Method | Path | Success | Errors | +| --- | --- | --- | --- | +| GET | `/api/setup` | `{needed: boolean}` | — | +| POST | `/api/setup` | 200 `{user}` + cookie | 400 `invalid_input` (+detail), 403 `setup_already_done` | +| POST | `/api/login` | 200 `{user}` + cookie | 401 `invalid_credentials` | +| POST | `/api/logout` | `{ok:true}` | — | +| GET | `/api/me` | `{user}` | 401 `unauthorized` | +| GET | `/api/users` | `{users}` | 401, 403 `forbidden` | +| POST | `/api/users` | `{id,username,isAdmin}` | 400, 403, 409 `username_taken` | +| DELETE | `/api/users/:id` | `{ok:true}` | 400 `cannot_delete_self`, 403, 404 | +| GET | `/api/settings` | view (below) | 401 | +| PUT | `/api/settings` | view (below) | 400 `invalid_input`, 400 `subsonic_auth`/`subsonic_unreachable` (+detail) | +| GET | `/api/lookup/barcode/:code` | `{candidates}` | 404 `not_found`, 409 `no_discogs_token`, 429 `discogs_rate_limited`, 502 `discogs_auth`/`discogs_error`/`discogs_unreachable` | +| GET | `/api/lookup/search?q=&format=` | `{candidates}` | same + 400 `missing_query` | +| GET | `/api/lookup/release/:id` | `{release, duplicate, ripMatch, matchCandidates}` | 409 no token, 429/502 as above, 400 `invalid_input` | +| GET | `/api/collection?format=&ripped=&q=` | `{items, counts}` | 401 | +| POST | `/api/collection` | item | 409 `duplicate`, 409 `no_discogs_token`, 400 `invalid_input`, 404 `album_not_found`, 429/502 | +| GET | `/api/collection/:id` | item | 404 | +| DELETE | `/api/collection/:id` | `{ok:true}` | 404 | +| PATCH | `/api/collection/:id/rip` | item | 400 `invalid_input`, 404 | +| POST | `/api/collection/:id/match` | item | 404 `not_found`/`album_not_found` | +| GET | `/api/library/sync` | `{status, error, lastSyncedAt, albums}` | 401 | +| POST | `/api/library/sync` | 202 `{status,...}` | 409 `no_subsonic_config` | +| GET | `/api/library/albums?q=` | `{albums}` | 401 | + +Error body shape everywhere: `{error: string, detail?: string}`. + +**Types (JSON shapes):** + +```ts +interface User { id: number; username: string; isAdmin: boolean } +interface SettingsView { + hasDiscogsToken: boolean + discogsTokenMasked: string | null + subsonicUrl: string | null + subsonicUsername: string | null + hasSubsonicPassword: boolean +} +interface Candidate { + id: number; artist: string; title: string; year: number | null + formats: string[]; labels: string[]; country: string | null + catno: string | null; thumbUrl: string | null +} +interface Release extends Candidate { + genres: string[] + tracklist: { position: string; title: string }[] + coverUrl: string | null + barcodes: string[] +} +interface ReleasePreview { + release: Release + duplicate: boolean + ripMatch: 'ripped' | 'not_ripped' | 'ambiguous' + matchCandidates: { id: number; title: string; artist: string }[] +} +interface Item { + id: number; discogsReleaseId: number; title: string; artist: string + year: number | null; formats: string[]; genres: string[]; labels: string[] + tracklist: { position: string; title: string }[] + catno: string | null; country: string | null + artworkUrl: string | null; barcodes: string[]; dateAdded: string + ripOverride: boolean | null + ripStatus: 'ripped' | 'not_ripped' +} +interface CollectionResponse { + items: Item[] + counts: { total: number; ripped: number; notRipped: number } +} +interface SyncState { + status: 'idle' | 'running' | 'done' | 'error' + error: string | null + lastSyncedAt: string | null + albums: number +} +interface DigitalAlbum { id: number; subsonicId: string; title: string; artist: string } +``` + +PUT `/api/settings` semantics: send only changed fields; **empty string clears** a stored value; absent fields are untouched. The response is the fresh view. + +## File structure (plan 2) + +``` +record-shop/ +├── package.json # MODIFY: deps + scripts (dev/build/typecheck) +├── vitest.config.ts # MODIFY: projects (server node / web jsdom) +├── Dockerfile # MODIFY: web build + copy dist +├── web/ +│ ├── index.html +│ ├── package.json → none # single root package (no workspace) +│ ├── tsconfig.json +│ ├── vite.config.ts +│ ├── public/ +│ │ ├── icon.svg +│ │ ├── manifest.webmanifest +│ │ └── sw.js +│ ├── src/ +│ │ ├── main.tsx # entry: router + providers + SW registration +│ │ ├── App.tsx # routes: /setup /login + protected shell +│ │ ├── api.ts # ApiError + typed api client +│ │ ├── types.ts # contract types (copy from above) +│ │ ├── auth.tsx # AuthProvider: setup gate + /api/me +│ │ ├── shell.tsx # bottom tab bar layout (Library·Scan·Add·Settings) +│ │ ├── pages/ +│ │ │ ├── SetupPage.tsx +│ │ │ ├── LoginPage.tsx +│ │ │ ├── LibraryPage.tsx +│ │ │ ├── ItemPage.tsx +│ │ │ ├── ScanPage.tsx +│ │ │ ├── AddPage.tsx +│ │ │ └── SettingsPage.tsx +│ │ ├── scan/ +│ │ │ ├── reducer.ts # pure scan-flow state machine +│ │ │ └── ConfirmView.tsx # release preview + rip banner + match picker +│ │ ├── components/ +│ │ │ ├── Cover.tsx +│ │ │ ├── CandidateCard.tsx +│ │ │ └── Scanner.tsx # camera viewfinder component +│ │ └── hooks/ +│ │ └── useBarcodeScanner.ts +│ └── test/ +│ ├── setup.ts # cleanup between tests +│ ├── smoke.test.tsx +│ ├── auth.test.tsx +│ ├── router.test.tsx +│ ├── scanner.test.ts +│ ├── reducer.test.ts +│ ├── scanPage.test.tsx +│ ├── library.test.tsx +│ ├── item.test.tsx +│ ├── add.test.tsx +│ ├── settings.test.tsx +│ └── pwa.test.ts +``` + +## UI layout (from spec) + +Bottom tab bar on mobile: **Library · Scan · Add · Settings**. Desktop renders the same app wider (max-w-3xl centered, larger grid) — responsive Tailwind, no separate layout. + +- **Library** — cover grid (3 cols mobile, 5–6 desktop). Filter chips: format (All/Vinyl/CD/Cassette) and rip state (All/Ripped/Not ripped) showing counts; search box; artist jump-list (derived client-side). Tap cover → `/item/:id`. +- **Item** — big cover, metadata (label, cat#, year, formats, genres, tracklist), rip-status banner, manual rip toggle (ripped/not/auto), re-match section (search digital albums → link/unlink), barcode(s), Discogs link, remove. +- **Scan** — full-screen camera with viewfinder overlay; the flow: detect → candidates → confirm → added. +- **Add** — text search + format select → same candidate → confirm flow. +- **Settings** — account (logout), Discogs token, Subsonic config, library sync (status + trigger), users (admin). + +--- + +### Task 1: Web scaffold — Vite, React, Tailwind, Vitest (jsdom), dev script + +**Files:** +- Modify: `package.json`, `vitest.config.ts` +- Create: `web/index.html`, `web/tsconfig.json`, `web/vite.config.ts`, `web/src/main.tsx`, `web/src/App.tsx`, `web/src/styles.css`, `web/test/setup.ts`, `web/test/smoke.test.tsx` + +- [ ] **Step 1: Install web dependencies** + +```bash +npm install react react-dom react-router-dom +npm install -D @vitejs/plugin-react vite tailwindcss @tailwindcss/vite jsdom @testing-library/react @testing-library/user-event @testing-library/dom concurrently @types/react @types/react-dom +``` + +(Do NOT add @zxing/library yet — Task 4. `typescript`, `vitest`, `tsx` are already installed.) + +- [ ] **Step 2: Update `package.json` scripts** — replace the scripts block with: + +```json + "scripts": { + "dev:server": "tsx watch server/src/index.ts", + "dev:web": "vite", + "dev": "concurrently -k \"npm:dev:server\" \"npm:dev:web\"", + "test": "vitest run", + "test:watch": "vitest", + "build:server": "tsc -p server/tsconfig.build.json", + "build:web": "vite build", + "build": "npm run build:server && npm run build:web", + "start": "node server/dist/index.js", + "typecheck": "tsc -p server/tsconfig.json --noEmit && tsc -p web --noEmit" + }, +``` + +- [ ] **Step 3: Update `vitest.config.ts`** — replace the whole file with the projects split (server = node, web = jsdom): + +```ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + projects: [ + { + test: { + name: 'server', + include: ['server/test/**/*.test.ts'], + environment: 'node', + }, + }, + { + test: { + name: 'web', + include: ['web/test/**/*.test.tsx'], + environment: 'jsdom', + setupFiles: ['web/test/setup.ts'], + }, + }, + ], + }, +}) +``` + +- [ ] **Step 4: Create `web/tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true, + "isolatedModules": true, + "types": ["vite/client"] + }, + "include": ["src", "test", "vite.config.ts"] +} +``` + +- [ ] **Step 5: Create `web/vite.config.ts`** + +```ts +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [react(), tailwindcss()], + root: __dirname, + server: { + proxy: { + '/api': 'http://localhost:3000', + '/artwork': 'http://localhost:3000', + }, + }, + build: { + outDir: 'dist', + }, +}) +``` + +Note: `__dirname` is unavailable in an ESM-loaded config; Vite supports it via its config loader. If it errors, replace `root: __dirname` with `root: import.meta.dirname` (Node >= 20.11). + +- [ ] **Step 6: Create `web/index.html`** + +```html + + + + + + + record-shop + + +
+ + + +``` + +- [ ] **Step 7: Create `web/src/styles.css`** + +```css +@import 'tailwindcss'; +``` + +- [ ] **Step 8: Create `web/src/App.tsx`** (placeholder shell — replaced in Task 3) + +```tsx +export default function App() { + return ( +
+

record-shop

+
+ ) +} +``` + +- [ ] **Step 9: Create `web/src/main.tsx`** + +```tsx +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.js' +import './styles.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) +``` + +- [ ] **Step 10: Create `web/test/setup.ts`** + +```ts +import { afterEach } from 'vitest' +import { cleanup } from '@testing-library/react' + +afterEach(() => { + cleanup() +}) +``` + +- [ ] **Step 11: Write the failing smoke test `web/test/smoke.test.tsx`** + +```tsx +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import App from '../src/App.js' + +describe('App', () => { + it('renders the app title', () => { + render() + expect(screen.getByRole('heading', { name: 'record-shop' })).toBeTruthy() + }) +}) +``` + +- [ ] **Step 12: Run tests to verify they pass (this is the scaffold gate)** + +Run: `npm test && npm run typecheck` +Expected: ALL PASS — 82 server tests (untouched) + 1 new web smoke test. Typecheck clean for both projects. +If the web project fails to discover jsdom or JSX, check the vitest projects config and that `@testing-library/react` is installed. + +- [ ] **Step 13: Verify the dev server builds** + +Run: `npm run build:web` — expect `web/dist/` produced with `index.html`. + +- [ ] **Step 14: Commit** + +```bash +git add -A && git commit -m "feat: react/vite/tailwind web scaffold with jsdom tests" +``` + +--- + +### Task 2: API client, types, auth context + +**Files:** +- Create: `web/src/types.ts`, `web/src/api.ts`, `web/src/auth.tsx` +- Test: `web/test/auth.test.tsx` + +- [ ] **Step 1: Create `web/src/types.ts`** — copy the interface block from the "API contract" section above (User, SettingsView, Candidate, Release, ReleasePreview, Item, CollectionResponse, SyncState, DigitalAlbum) into: + +```ts +// Contract types — mirrors the Fastify API (plan 1). Do not rename fields. +export interface User { + id: number + username: string + isAdmin: boolean +} +export interface SettingsView { + hasDiscogsToken: boolean + discogsTokenMasked: string | null + subsonicUrl: string | null + subsonicUsername: string | null + hasSubsonicPassword: boolean +} +export interface Candidate { + id: number + artist: string + title: string + year: number | null + formats: string[] + labels: string[] + country: string | null + catno: string | null + thumbUrl: string | null +} +export interface Release extends Candidate { + genres: string[] + tracklist: { position: string; title: string }[] + coverUrl: string | null + barcodes: string[] +} +export interface ReleasePreview { + release: Release + duplicate: boolean + ripMatch: 'ripped' | 'not_ripped' | 'ambiguous' + matchCandidates: { id: number; title: string; artist: string }[] +} +export interface Item { + id: number + discogsReleaseId: number + title: string + artist: string + year: number | null + formats: string[] + genres: string[] + labels: string[] + tracklist: { position: string; title: string }[] + catno: string | null + country: string | null + artworkUrl: string | null + barcodes: string[] + dateAdded: string + ripOverride: boolean | null + ripStatus: 'ripped' | 'not_ripped' +} +export interface CollectionResponse { + items: Item[] + counts: { total: number; ripped: number; notRipped: number } +} +export interface SyncState { + status: 'idle' | 'running' | 'done' | 'error' + error: string | null + lastSyncedAt: string | null + albums: number +} +export interface DigitalAlbum { + id: number + subsonicId: string + title: string + artist: string +} +``` + +- [ ] **Step 2: Create `web/src/api.ts`** + +```ts +import type { + Candidate, + CollectionResponse, + DigitalAlbum, + Item, + ReleasePreview, + SettingsView, + SyncState, + User, +} from './types.js' + +export class ApiError extends Error { + constructor( + public status: number, + public code: string, + public detail?: string + ) { + super(detail ? `${code}: ${detail}` : code) + } +} + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + headers: { Accept: 'application/json' }, + ...init, + }) + if (!res.ok) { + let code = 'unknown_error' + let detail: string | undefined + try { + const body = (await res.json()) as { error?: string; detail?: string } + code = body.error ?? code + detail = body.detail + } catch { + // non-JSON error body + } + throw new ApiError(res.status, code, detail) + } + return (await res.json()) as T +} + +function post(path: string, payload?: unknown): Promise { + return request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload === undefined ? undefined : JSON.stringify(payload), + }) +} + +export const api = { + setupStatus: () => request<{ needed: boolean }>('/api/setup'), + setup: (username: string, password: string) => post<{ user: User }>('/api/setup', { username, password }), + login: (username: string, password: string) => post<{ user: User }>('/api/login', { username, password }), + logout: () => post<{ ok: boolean }>('/api/logout'), + me: () => request<{ user: User }>('/api/me'), + + listUsers: () => request<{ users: User[] }>('/api/users'), + createUser: (username: string, password: string) => post('/api/users', { username, password }), + deleteUser: (id: number) => request<{ ok: boolean }>(`/api/users/${id}`, { method: 'DELETE' }), + + getSettings: () => request('/api/settings'), + putSettings: (payload: Partial>) => + request('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }), + + lookupBarcode: (code: string) => request<{ candidates: Candidate[] }>(`/api/lookup/barcode/${encodeURIComponent(code)}`), + lookupSearch: (q: string, format?: string) => + request<{ candidates: Candidate[] }>( + `/api/lookup/search?q=${encodeURIComponent(q)}${format ? `&format=${encodeURIComponent(format)}` : ''}` + ), + getReleasePreview: (id: number) => request(`/api/lookup/release/${id}`), + + listCollection: (params: { format?: string; ripped?: string; q?: string } = {}) => { + const usp = new URLSearchParams() + if (params.format) usp.set('format', params.format) + if (params.ripped) usp.set('ripped', params.ripped) + if (params.q) usp.set('q', params.q) + const qs = usp.toString() + return request(`/api/collection${qs ? `?${qs}` : ''}`) + }, + addToCollection: (body: { releaseId: number; barcode?: string; matchAlbumId?: number }) => + post('/api/collection', body), + getItem: (id: number) => request(`/api/collection/${id}`), + deleteItem: (id: number) => request<{ ok: boolean }>(`/api/collection/${id}`, { method: 'DELETE' }), + setRip: (id: number, ripped: boolean | null) => + request(`/api/collection/${id}/rip`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ripped }), + }), + setMatch: (id: number, albumId: number | null) => + post(`/api/collection/${id}/match`, { albumId }), + + syncStatus: () => request('/api/library/sync'), + startSync: () => request('/api/library/sync', { method: 'POST' }), + searchAlbums: (q: string) => request<{ albums: DigitalAlbum[] }>(`/api/library/albums?q=${encodeURIComponent(q)}`), +} +``` + +- [ ] **Step 3: Write the failing test `web/test/auth.test.tsx`** + +```tsx +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { AuthProvider, useAuth } from '../src/auth.js' + +function Probe() { + const { status, user, setupNeeded } = useAuth() + return ( +
+
status:{status}
+ {setupNeeded &&
setup-needed
} + {user &&
user:{user.username}
} +
+ ) +} + +const fetchMock = vi.fn() + +beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) +}) + +function jsonOnce(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('AuthProvider', () => { + it('reports setupNeeded when the server has no users', async () => { + fetchMock.mockResolvedValueOnce(jsonOnce(200, { needed: true })) + render( + + + + ) + expect(screen.getByText('status:loading')).toBeTruthy() + await waitFor(() => expect(screen.getByText('status:setup')).toBeTruthy()) + expect(screen.getByText('setup-needed')).toBeTruthy() + expect(fetchMock).toHaveBeenCalledWith('/api/setup', expect.anything()) + }) + + it('exposes the user when /api/me succeeds', async () => { + fetchMock + .mockResolvedValueOnce(jsonOnce(200, { needed: false })) + .mockResolvedValueOnce(jsonOnce(200, { user: { id: 1, username: 'sam', isAdmin: true } })) + render( + + + + ) + await waitFor(() => expect(screen.getByText('status:authenticated')).toBeTruthy()) + expect(screen.getByText('user:sam')).toBeTruthy() + }) + + it('reports unauthenticated when /api/me is 401', async () => { + fetchMock + .mockResolvedValueOnce(jsonOnce(200, { needed: false })) + .mockResolvedValueOnce(jsonOnce(401, { error: 'unauthorized' })) + render( + + + + ) + await waitFor(() => expect(screen.getByText('status:unauthenticated')).toBeTruthy()) + }) +}) +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `npx vitest run web/test/auth.test.tsx` +Expected: FAIL — module `../src/auth.js` not found. + +- [ ] **Step 5: Create `web/src/auth.tsx`** + +```tsx +import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react' +import { api } from './api.js' +import type { User } from './types.js' + +export type AuthStatus = 'loading' | 'setup' | 'unauthenticated' | 'authenticated' + +interface AuthContextValue { + status: AuthStatus + user: User | null + setupNeeded: boolean + refresh: () => Promise + onSetupComplete: (user: User) => void + onLogin: (user: User) => void + onLogout: () => void +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [status, setStatus] = useState('loading') + const [user, setUser] = useState(null) + const [setupNeeded, setSetupNeeded] = useState(false) + + const refresh = useCallback(async () => { + try { + const { needed } = await api.setupStatus() + if (needed) { + setSetupNeeded(true) + setStatus('setup') + return + } + setSetupNeeded(false) + const { user: me } = await api.me() + setUser(me) + setStatus('authenticated') + } catch { + setStatus('unauthenticated') + } + }, []) + + useEffect(() => { + void refresh() + }, [refresh]) + + const onSetupComplete = useCallback((u: User) => { + setSetupNeeded(false) + setUser(u) + setStatus('authenticated') + }, []) + const onLogin = useCallback((u: User) => { + setUser(u) + setStatus('authenticated') + }, []) + const onLogout = useCallback(() => { + setUser(null) + setStatus('unauthenticated') + }, []) + + return ( + + {children} + + ) +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth outside AuthProvider') + return ctx +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npm test && npm run typecheck` +Expected: ALL PASS (83 total). + +- [ ] **Step 7: Commit** + +```bash +git add -A && git commit -m "feat: typed api client and auth context with setup gate" +``` + +--- + +### Task 3: Router, app shell, setup and login pages + +**Files:** +- Create: `web/src/shell.tsx`, `web/src/pages/SetupPage.tsx`, `web/src/pages/LoginPage.tsx`, `web/src/pages/LibraryPage.tsx` (stub), `web/src/pages/ScanPage.tsx` (stub), `web/src/pages/AddPage.tsx` (stub), `web/src/pages/SettingsPage.tsx` (stub) +- Modify: `web/src/main.tsx`, `web/src/App.tsx` +- Test: `web/test/router.test.tsx` + +- [ ] **Step 1: Write the failing test `web/test/router.test.tsx`** + +```tsx +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import App from '../src/App.js' + +const fetchMock = vi.fn() + +beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + window.history.replaceState(null, '', '/') +}) + +function json(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function loggedInServer() { + fetchMock.mockImplementation((url: string) => { + if (url === '/api/setup') return Promise.resolve(json(200, { needed: false })) + if (url === '/api/me') return Promise.resolve(json(200, { user: { id: 1, username: 'sam', isAdmin: true } })) + return Promise.resolve(json(404, { error: 'not_found' })) + }) +} + +describe('routing', () => { + it('shows the setup form when the server needs setup', async () => { + fetchMock.mockImplementation((url: string) => + url === '/api/setup' ? Promise.resolve(json(200, { needed: true })) : Promise.resolve(json(404, {})) + ) + render() + await waitFor(() => expect(screen.getByRole('heading', { name: /welcome/i })).toBeTruthy()) + }) + + it('shows the login form when unauthenticated', async () => { + fetchMock.mockImplementation((url: string) => { + if (url === '/api/setup') return Promise.resolve(json(200, { needed: false })) + return Promise.resolve(json(401, { error: 'unauthorized' })) + }) + render() + await waitFor(() => expect(screen.getByRole('heading', { name: /sign in/i })).toBeTruthy()) + }) + + it('lands on Library with the tab bar when authenticated', async () => { + loggedInServer() + render() + await waitFor(() => expect(screen.getByRole('tab', { name: /library/i })).toBeTruthy()) + for (const tab of ['Library', 'Scan', 'Add', 'Settings']) { + expect(screen.getByRole('tab', { name: new RegExp(tab, 'i') })).toBeTruthy() + } + }) + + it('login form authenticates and enters the app', async () => { + fetchMock.mockImplementation((url: string) => { + if (url === '/api/setup') return Promise.resolve(json(200, { needed: false })) + if (url === '/api/me') return Promise.resolve(json(401, { error: 'unauthorized' })) + if (url === '/api/login') { + return Promise.resolve(json(200, { user: { id: 1, username: 'sam', isAdmin: true } })) + } + return Promise.resolve(json(404, {})) + }) + render() + await waitFor(() => expect(screen.getByLabelText(/username/i)).toBeTruthy()) + await userEvent.type(screen.getByLabelText(/username/i), 'sam') + await userEvent.type(screen.getByLabelText(/password/i), 'password123') + await userEvent.click(screen.getByRole('button', { name: /sign in/i })) + await waitFor(() => expect(screen.getByRole('tab', { name: /library/i })).toBeTruthy()) + }) + + it('setup form creates the admin account and enters the app', async () => { + fetchMock.mockImplementation((url: string, init?: RequestInit) => { + if (url === '/api/setup' && (!init || !init.method || init.method === 'GET')) { + return Promise.resolve(json(200, { needed: true })) + } + if (url === '/api/setup' && init?.method === 'POST') { + return Promise.resolve(json(200, { user: { id: 1, username: 'boss', isAdmin: true } })) + } + return Promise.resolve(json(404, {})) + }) + render() + await waitFor(() => expect(screen.getByLabelText(/username/i)).toBeTruthy()) + await userEvent.type(screen.getByLabelText(/username/i), 'boss') + await userEvent.type(screen.getByLabelText(/password/i), 'password123') + await userEvent.click(screen.getByRole('button', { name: /create/i })) + await waitFor(() => expect(screen.getByRole('tab', { name: /library/i })).toBeTruthy()) + }) + + it('tab navigation switches pages', async () => { + loggedInServer() + render() + await waitFor(() => expect(screen.getByRole('tab', { name: /settings/i })).toBeTruthy()) + await userEvent.click(screen.getByRole('tab', { name: /settings/i })) + await waitFor(() => expect(screen.getByRole('heading', { name: /settings/i })).toBeTruthy()) + expect(screen.getByText(/account/i)).toBeTruthy() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run web/test/router.test.tsx` +Expected: FAIL — no routes/labels exist. + +- [ ] **Step 3: Create `web/src/shell.tsx`** (bottom tab bar layout) + +```tsx +import { NavLink, Outlet } from 'react-router-dom' +import type { ReactNode } from 'react' + +const TABS: { to: string; label: string; icon: ReactNode }[] = [ + { + to: '/library', + label: 'Library', + icon: ( + + + + + ), + }, + { + to: '/scan', + label: 'Scan', + icon: ( + + + + ), + }, + { + to: '/add', + label: 'Add', + icon: ( + + + + + ), + }, + { + to: '/settings', + label: 'Settings', + icon: ( + + + + + ), + }, +] + +export default function Shell({ title }: { title?: string }) { + return ( +
+
+

{title ?? 'record-shop'}

+
+
+ +
+ +
+ ) +} +``` + +- [ ] **Step 4: Create the three stub pages** — each is a full placeholder replaced by later tasks: + +`web/src/pages/LibraryPage.tsx`: +```tsx +export default function LibraryPage() { + return

Library goes here.

+} +``` + +`web/src/pages/ScanPage.tsx`: +```tsx +export default function ScanPage() { + return

Scanner goes here.

+} +``` + +`web/src/pages/AddPage.tsx`: +```tsx +export default function AddPage() { + return

Search goes here.

+} +``` + +`web/src/pages/SettingsPage.tsx`: +```tsx +import { useAuth } from '../auth.js' + +export default function SettingsPage() { + const { user, onLogout } = useAuth() + return ( +
+

Settings

+

Account

+

+ {user?.username} {user?.isAdmin ? '(admin)' : ''} +

+
+ ) +} +``` + +- [ ] **Step 5: Create `web/src/pages/SetupPage.tsx`** + +```tsx +import { useState, type FormEvent } from 'react' +import { api, ApiError } from '../api.js' +import { useAuth } from '../auth.js' + +export default function SetupPage() { + const { onSetupComplete } = useAuth() + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + async function submit(e: FormEvent) { + e.preventDefault() + setBusy(true) + setError(null) + try { + const { user } = await api.setup(username, password) + onSetupComplete(user) + } catch (err) { + setError(err instanceof ApiError ? err.detail ?? err.code : 'Something went wrong') + } finally { + setBusy(false) + } + } + + return ( +
+
+

Welcome to record-shop

+

Create the admin account to get started.

+
+ + setUsername(e.target.value)} + className="w-full rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2" + autoComplete="username" + required + minLength={3} + /> +
+
+ + setPassword(e.target.value)} + className="w-full rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2" + autoComplete="new-password" + required + minLength={8} + /> +
+ {error &&

{error}

} + +
+
+ ) +} +``` + +- [ ] **Step 6: Create `web/src/pages/LoginPage.tsx`** + +```tsx +import { useState, type FormEvent } from 'react' +import { api, ApiError } from '../api.js' +import { useAuth } from '../auth.js' + +export default function LoginPage() { + const { onLogin } = useAuth() + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + async function submit(e: FormEvent) { + e.preventDefault() + setBusy(true) + setError(null) + try { + const { user } = await api.login(username, password) + onLogin(user) + } catch (err) { + setError(err instanceof ApiError && err.status === 401 ? 'Wrong username or password' : 'Something went wrong') + } finally { + setBusy(false) + } + } + + return ( +
+
+

Sign in

+
+ + setUsername(e.target.value)} + className="w-full rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2" + autoComplete="username" + required + /> +
+
+ + setPassword(e.target.value)} + className="w-full rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2" + autoComplete="current-password" + required + /> +
+ {error &&

{error}

} + +
+
+ ) +} +``` + +- [ ] **Step 7: Modify `web/src/App.tsx`** — routes + auth gating. Replace the whole file: + +```tsx +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +import { AuthProvider, useAuth } from './auth.js' +import Shell from './shell.js' +import SetupPage from './pages/SetupPage.js' +import LoginPage from './pages/LoginPage.js' +import LibraryPage from './pages/LibraryPage.js' +import ScanPage from './pages/ScanPage.js' +import AddPage from './pages/AddPage.js' +import SettingsPage from './pages/SettingsPage.js' + +function Gate({ children }: { children: React.ReactNode }) { + const { status } = useAuth() + if (status === 'loading') { + return
+ } + if (status === 'setup') return + if (status === 'unauthenticated') return + return <>{children} +} + +export default function App() { + return ( + + + + } /> + } /> + + + + } + > + } /> + } /> + } /> + } /> + + } /> + + + + ) +} +``` + +- [ ] **Step 8: Modify `web/src/main.tsx`** — import path fix for bundler resolution: + +```tsx +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './styles.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) +``` + +(From here on, use extensionless relative imports in `web/src` — bundler moduleResolution handles them.) + +- [ ] **Step 9: Run tests to verify they pass** + +Run: `npm test && npm run typecheck` +Expected: ALL PASS (83 + 6 new = 89). + +- [ ] **Step 10: Commit** + +```bash +git add -A && git commit -m "feat: router, tab shell, setup and login pages" +``` + +--- + +### Task 4: Barcode scanner — hook + camera component + +**Files:** +- Install: `@zxing/library` +- Create: `web/src/hooks/useBarcodeScanner.ts`, `web/src/components/Scanner.tsx` +- Test: `web/test/scanner.test.ts` + +- [ ] **Step 1: Install the fallback decoder** + +```bash +npm install @zxing/library +``` + +- [ ] **Step 2: Write the failing test `web/test/scanner.test.ts`** + +```ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, waitFor, act } from '@testing-library/react' +import { useBarcodeScanner, shouldEmit } from '../src/hooks/useBarcodeScanner.js' + +function fakeStream(): MediaStream { + return { getTracks: () => [{ stop: vi.fn() } as unknown as MediaStreamTrack] } as unknown as MediaStream +} + +function stubMediaDevices(impl: () => Promise) { + vi.stubGlobal( + 'navigator', + Object.assign(Object.create(Object.getPrototypeOf(navigator)), navigator, { + mediaDevices: { getUserMedia: impl }, + }) + ) +} + +beforeEach(() => { + vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue() +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('shouldEmit (cooldown dedup)', () => { + it('emits a new code, suppresses a rapid repeat, emits after cooldown', () => { + expect(shouldEmit(null, '123', 0, 1500)).toBe(true) + expect(shouldEmit({ code: '123', at: 100 }, '123', 500, 1500)).toBe(false) + expect(shouldEmit({ code: '123', at: 100 }, '456', 500, 1500)).toBe(true) + expect(shouldEmit({ code: '123', at: 100 }, '123', 1700, 1500)).toBe(true) + }) +}) + +describe('useBarcodeScanner', () => { + it('detects a barcode via BarcodeDetector and calls onDetect', async () => { + const detections = [[], [{ rawValue: '5021592210629', format: 'ean_13' }]] + let call = 0 + class FakeDetector { + constructor(_opts: { formats: string[] }) { + expect(_opts.formats).toEqual(['ean_13', 'upc_a', 'ean_8']) + } + async detect() { + return detections[Math.min(call++, 1)]! + } + } + ;(window as unknown as { BarcodeDetector?: unknown }).BarcodeDetector = FakeDetector + stubMediaDevices(() => Promise.resolve(fakeStream())) + + const onDetect = vi.fn() + const videoRef = { current: document.createElement('video') } as React.RefObject + const { result } = renderHook(() => useBarcodeScanner(videoRef, onDetect, true)) + + await waitFor(() => expect(result.current.status).toBe('ready')) + await waitFor(() => expect(onDetect).toHaveBeenCalledWith('5021592210629')) + }) + + it('reports denied when camera permission is rejected', async () => { + stubMediaDevices(() => + Promise.reject(new DOMException('denied', 'NotAllowedError')) + ) + const videoRef = { current: document.createElement('video') } as React.RefObject + const { result } = renderHook(() => useBarcodeScanner(videoRef, () => {}, true)) + await waitFor(() => expect(result.current.status).toBe('denied')) + }) + + it('stops the stream when enabled goes false', async () => { + const stream = fakeStream() + const stopSpy = vi.spyOn(stream.getTracks()[0]!, 'stop') + stubMediaDevices(() => Promise.resolve(stream)) + ;(window as unknown as { BarcodeDetector?: unknown }).BarcodeDetector = class { + async detect() { + return [] + } + } + const videoRef = { current: document.createElement('video') } as React.RefObject + const { result, rerender } = renderHook( + ({ enabled }) => useBarcodeScanner(videoRef, () => {}, enabled), + { initialProps: { enabled: true } } + ) + await waitFor(() => expect(result.current.status).toBe('ready')) + rerender({ enabled: false }) + expect(stopSpy).toHaveBeenCalled() + expect(result.current.status).toBe('idle') + }) +}) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run web/test/scanner.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement `web/src/hooks/useBarcodeScanner.ts`** + +```ts +import { useEffect, useRef, useState } from 'react' + +export type ScannerStatus = 'idle' | 'starting' | 'ready' | 'denied' | 'error' + +export const BARCODE_FORMATS = ['ean_13', 'upc_a', 'ean_8'] as const + +interface DetectedCode { + rawValue: string + format: string +} +interface BarcodeDetectorLike { + detect(source: HTMLVideoElement): Promise +} +type BarcodeDetectorCtor = new (options: { formats: string[] }) => BarcodeDetectorLike + +export function getBarcodeDetectorCtor(): BarcodeDetectorCtor | null { + return (window as unknown as { BarcodeDetector?: BarcodeDetectorCtor }).BarcodeDetector ?? null +} + +/** Pure dedup rule: suppress a repeat of the same code within cooldownMs. */ +export function shouldEmit( + last: { code: string; at: number } | null, + code: string, + now: number, + cooldownMs: number +): boolean { + if (!last) return true + if (last.code !== code) return true + return now - last.at >= cooldownMs +} + +export function useBarcodeScanner( + videoRef: React.RefObject, + onDetect: (code: string) => void, + enabled: boolean +): { status: ScannerStatus; stop: () => void } { + const [status, setStatus] = useState('idle') + const onDetectRef = useRef(onDetect) + onDetectRef.current = onDetect + + useEffect(() => { + if (!enabled) { + setStatus('idle') + return + } + let stopped = false + let stream: MediaStream | null = null + let rafId = 0 + let zxingStop: (() => void) | null = null + let last: { code: string; at: number } | null = null + const emit = (code: string) => { + const now = Date.now() + if (!shouldEmit(last, code, now, 1500)) return + last = { code, at: now } + onDetectRef.current(code) + } + + async function start(): Promise { + setStatus('starting') + try { + stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }) + } catch (err) { + setStatus(err instanceof DOMException && err.name === 'NotAllowedError' ? 'denied' : 'error') + return + } + const video = videoRef.current + if (!video || stopped) { + stream.getTracks().forEach((t) => t.stop()) + return + } + video.srcObject = stream + await video.play().catch(() => {}) + + const Ctor = getBarcodeDetectorCtor() + if (Ctor) { + const detector = new Ctor({ formats: [...BARCODE_FORMATS] }) + const tick = async (): Promise => { + if (stopped) return + try { + const codes = await detector.detect(video) + const first = codes[0] + if (first) emit(first.rawValue) + } catch { + // undecodable frame — skip + } + rafId = requestAnimationFrame(() => void tick()) + } + void tick() + } else { + const { BrowserMultiFormatReader, BarcodeFormat, DecodeHintType } = await import('@zxing/library') + if (stopped) return + const hints = new Map() + hints.set(DecodeHintType.POSSIBLE_FORMATS, [BarcodeFormat.EAN_13, BarcodeFormat.UPC_A, BarcodeFormat.EAN_8]) + const reader = new BrowserMultiFormatReader(hints) + const controls = await reader.decodeFromStream(stream, video, (result) => { + if (result) emit(result.getText()) + }) + zxingStop = controls.stop + } + if (!stopped) setStatus('ready') + } + + void start() + + return () => { + stopped = true + cancelAnimationFrame(rafId) + zxingStop?.() + stream?.getTracks().forEach((t) => t.stop()) + setStatus('idle') + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, videoRef]) + + return { status, stop: () => undefined } +} +``` + +(If `decodeFromStream` has a different shape in the installed @zxing/library version, adapt minimally — the contract is: given our stream+video, call the callback per decode, and return a stop function. Keep everything else identical.) + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run web/test/scanner.test.ts && npm test && npm run typecheck` +Expected: ALL PASS (89 + 4 new = 93). + +- [ ] **Step 6: Create `web/src/components/Scanner.tsx`** + +```tsx +import { useRef } from 'react' +import { useBarcodeScanner } from '../hooks/useBarcodeScanner.js' + +export default function Scanner({ + enabled, + onDetect, +}: { + enabled: boolean + onDetect: (code: string) => void +}) { + const videoRef = useRef(null) + const { status } = useBarcodeScanner(videoRef, onDetect, enabled) + + return ( +
+