--- phase: 03-dashboard-intake-ui plan: "05" type: execute wave: 2 depends_on: ["03-01"] files_modified: - web/public/manifest.json - web/public/sw.js - web/public/icons/icon-192.png - web/public/icons/icon-512.png - web/src/hooks/usePWA.ts - web/src/pages/ScanPage.tsx - web/src/router.tsx - web/index.html autonomous: true requirements: [PWA-01, PWA-02, PWA-03] must_haves: truths: - "Chrome on Android shows the 'Add to Home Screen' banner for the app" - "The installed PWA launches in standalone mode (no browser chrome)" - "Visiting /scan activates the device rear camera and scans QR codes in real-time" - "A scanned HW-XXXXX QR code navigates the user to /item/:id for that item" - "Service worker caches the app shell so the UI loads offline (no network required for the shell)" artifacts: - path: "web/public/manifest.json" provides: "PWA web app manifest (name, icons, display=standalone, theme_color)" - path: "web/public/sw.js" provides: "Service worker — app shell cache strategy for offline shell" - path: "web/src/pages/ScanPage.tsx" provides: "/scan route — camera QR scanner using @zxing/browser" - path: "web/src/hooks/usePWA.ts" provides: "Service worker registration hook" key_links: - from: "web/index.html" to: "web/public/manifest.json" via: "" - from: "web/src/hooks/usePWA.ts" to: "web/public/sw.js" via: "navigator.serviceWorker.register('/sw.js')" - from: "web/src/pages/ScanPage.tsx" to: "http://localhost:8080/api/inventory" via: "Decoded QR text parsed as HW-XXXXX → navigate to /item/:id" --- Set up the PWA manifest, service worker, and camera-based QR scanner so HWLab is installable on Android and items can be looked up by scanning their printed QR labels. Purpose: PWA installability and QR scanning close the hardware inventory loop — print a label on intake, scan it later to pull up the item record instantly. Output: Installable PWA with app shell offline caching and a /scan camera QR scanner that resolves HW IDs. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md @.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md ```typescript import { BrowserQRCodeReader, IScannerControls } from '@zxing/browser' const reader = new BrowserQRCodeReader() // Get list of cameras: const devices = await BrowserQRCodeReader.listVideoInputDevices() // Start scanning — calls callback on each decoded result: const controls: IScannerControls = await reader.decodeFromVideoDevice( deviceId, // undefined = default (rear) camera videoElement, // HTMLVideoElement ref (result, _err, controls) => { if (result) { const text = result.getText() // text will be the QR code content controls.stop() } } ) // Stop: controls.stop() ``` // QR codes encode: http://mac-mini.mg:8080/hw/XXXXX // Or just the HW ID: HW-00001 // Parse: extract HW ID from URL path or direct match // Lookup: GET /api/inventory returns hw_id field; match by hw_id to get numeric id // Navigate: router.navigate({ to: '/item/$id', params: { id: String(numericId) } }) // import { router } from '@/router' // router.navigate({ to: '/item/$id', params: { id: String(numericId) } }) // bg-canvas, text-volt, bg-near-black, border-charcoal/80 // Button variant="forest" for primary actions Task 1: PWA manifest, service worker, icons, and registration hook web/public/manifest.json, web/public/sw.js, web/public/icons/icon-192.png, web/public/icons/icon-512.png, web/src/hooks/usePWA.ts, web/index.html Read first: `web/index.html` (current state from Plan 01). **1. Create web/public/ directory structure:** ``` web/public/ manifest.json sw.js icons/ icon-192.png (placeholder — generate programmatically) icon-512.png (placeholder — generate programmatically) ``` **2. Create web/public/manifest.json** (PWA-01: installable on Android): ```json { "name": "HWLab", "short_name": "HWLab", "description": "Self-hosted hardware inventory with AI photo intake", "start_url": "/", "display": "standalone", "background_color": "#000000", "theme_color": "#faff69", "orientation": "portrait-primary", "icons": [ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } ], "categories": ["utilities", "productivity"], "screenshots": [] } ``` **3. Generate placeholder icons using Node.js (no ImageMagick required):** Run this script to create minimal valid PNG icons programmatically. Create `web/scripts/gen-icons.cjs`: ```js #!/usr/bin/env node // Generates minimal PNG icons for the PWA manifest. // Uses pure Node.js — no canvas or image library needed. // Output: a valid 1x1 black PNG with correct PNG header and IDAT chunk. // For production, replace with properly designed icons. const fs = require('fs') const path = require('path') const zlib = require('zlib') function makePNG(size, bgHex, fgHex) { // PNG signature const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]) // IHDR chunk const ihdrData = Buffer.alloc(13) ihdrData.writeUInt32BE(size, 0) ihdrData.writeUInt32BE(size, 4) ihdrData[8] = 8 // bit depth ihdrData[9] = 2 // color type RGB ihdrData[10] = 0 // compression ihdrData[11] = 0 // filter ihdrData[12] = 0 // interlace const ihdr = makeChunk('IHDR', ihdrData) // IDAT chunk — raw RGB pixel data, filtered (filter byte 0 = none per row) const bg = hexToRgb(bgHex) const fg = hexToRgb(fgHex) const rawRows = [] const margin = Math.floor(size * 0.2) for (let y = 0; y < size; y++) { const row = [0] // filter byte for (let x = 0; x < size; x++) { // Simple "H" monogram in the center const inMargin = x >= margin && x < size - margin && y >= margin && y < size - margin const isStroke = inMargin && ((x < margin + Math.floor(size * 0.08) || x > size - margin - Math.floor(size * 0.08)) || (Math.abs(y - size / 2) < Math.floor(size * 0.06))) const c = isStroke ? fg : bg row.push(c[0], c[1], c[2]) } rawRows.push(Buffer.from(row)) } const rawData = Buffer.concat(rawRows) const compressed = zlib.deflateSync(rawData) const idat = makeChunk('IDAT', compressed) // IEND const iend = makeChunk('IEND', Buffer.alloc(0)) return Buffer.concat([sig, ihdr, idat, iend]) } function makeChunk(type, data) { const len = Buffer.alloc(4) len.writeUInt32BE(data.length) const typeBytes = Buffer.from(type) const crc = crc32(Buffer.concat([typeBytes, data])) const crcBuf = Buffer.alloc(4) crcBuf.writeInt32BE(crc) return Buffer.concat([len, typeBytes, data, crcBuf]) } function hexToRgb(hex) { const n = parseInt(hex.replace('#', ''), 16) return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff] } function crc32(buf) { let crc = 0xffffffff const table = makeCRCTable() for (const b of buf) crc = (crc >>> 8) ^ table[(crc ^ b) & 0xff] return (crc ^ 0xffffffff) | 0 } function makeCRCTable() { const t = new Int32Array(256) for (let i = 0; i < 256; i++) { let c = i for (let j = 0; j < 8; j++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 t[i] = c } return t } const iconsDir = path.join(__dirname, '..', 'public', 'icons') fs.mkdirSync(iconsDir, { recursive: true }) fs.writeFileSync(path.join(iconsDir, 'icon-192.png'), makePNG(192, '#000000', '#faff69')) fs.writeFileSync(path.join(iconsDir, 'icon-512.png'), makePNG(512, '#000000', '#faff69')) console.log('Icons generated: icon-192.png, icon-512.png') ``` Run: `node web/scripts/gen-icons.cjs` **4. Create web/public/sw.js** — app shell cache strategy: ```javascript const CACHE_NAME = 'hwlab-shell-v1' // App shell assets to cache on install const SHELL_ASSETS = [ '/', '/index.html', ] self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_ASSETS)) ) self.skipWaiting() }) self.addEventListener('activate', (event) => { // Remove old caches event.waitUntil( caches.keys().then((keys) => Promise.all( keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)) ) ) ) self.clients.claim() }) self.addEventListener('fetch', (event) => { const url = new URL(event.request.url) // API requests: network only — never cache if (url.pathname.startsWith('/api/')) { return } // Navigation requests: serve from cache, fall back to network (app shell) if (event.request.mode === 'navigate') { event.respondWith( caches.match('/').then((cached) => cached ?? fetch(event.request)) ) return } // Static assets: cache-first event.respondWith( caches.match(event.request).then( (cached) => cached ?? fetch(event.request).then((response) => { if (response.ok && event.request.method === 'GET') { const clone = response.clone() caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)) } return response }) ) ) }) ``` **5. Create web/src/hooks/usePWA.ts** — service worker registration: ```typescript import { useEffect } from 'react' export function usePWA() { useEffect(() => { if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker .register('/sw.js') .then((reg) => { console.log('[PWA] Service worker registered:', reg.scope) }) .catch((err) => { console.warn('[PWA] Service worker registration failed:', err) }) }) } }, []) } ``` **6. Update web/src/App.tsx** — call usePWA() hook: Read current `web/src/App.tsx`, then add: ```typescript import { usePWA } from '@/hooks/usePWA' export function App() { usePWA() // Register service worker // ... rest unchanged } ``` **7. Verify web/index.html already has the manifest link** (it was added in Plan 01): `grep "manifest" web/index.html` — if missing, add `` inside ``. Add `` inside `` if not already present. Run `npm run build`. cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -5 && ls public/icons/ && node -e "const b = require('fs').readFileSync('public/icons/icon-192.png'); console.log('PNG sig:', b.slice(0,4).toString('hex') === '89504e47' ? 'VALID' : 'INVALID')" `npm run build` exits 0. `web/public/icons/icon-192.png` and `icon-512.png` exist as valid PNG files (PNG signature 89504E47). `web/public/manifest.json` exists with `"display": "standalone"` and `"theme_color": "#faff69"`. `web/public/sw.js` exists. `web/src/hooks/usePWA.ts` exports `usePWA`. Task 2: QR scanner page with @zxing/browser and router wiring web/src/pages/ScanPage.tsx, web/src/router.tsx Read first: `web/src/router.tsx` (current state — has stub scanRoute), `web/src/lib/api.ts` (fetchInventory for HW ID lookup). **Create web/src/pages/ScanPage.tsx** — camera QR code scanner: Flow: 1. Request camera access via `BrowserQRCodeReader.listVideoInputDevices()` 2. Stream rear camera to `