docs(03): create phase 3 plans — dashboard, intake UI, PWA (5 plans, 2 waves)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikkel Georgsen 2026-04-10 06:12:02 +00:00
parent 22c504247d
commit 1a9931f900
6 changed files with 3480 additions and 3 deletions

View file

@ -69,8 +69,14 @@ Plans:
3. User can view full item detail including photos, specs, test data, and audit history 3. User can view full item detail including photos, specs, test data, and audit history
4. Intake flow accepts 1-3 photo uploads, shows AI classification result inline, and allows correction before creating the NetBox record 4. Intake flow accepts 1-3 photo uploads, shows AI classification result inline, and allows correction before creating the NetBox record
5. App is installable as a PWA on Android and the camera-based QR scanner resolves items by HW ID 5. App is installable as a PWA on Android and the camera-based QR scanner resolves items by HW ID
**Plans**: TBD **Plans**: 5 plans
**UI hint**: yes
Plans:
- [ ] 03-01-PLAN.md — Frontend scaffold: Vite 5 + React 18 + TypeScript + Tailwind + shadcn/ui, ClickHouse design tokens, TanStack Router/Query, Zustand
- [ ] 03-02-PLAN.md — Backend inventory endpoints: GET /api/inventory + GET /api/inventory/:id handlers + router wiring
- [ ] 03-03-PLAN.md — Dashboard + item detail views: grid/list toggle, filters, ItemCard/ItemRow, item detail with photos and custom fields
- [ ] 03-04-PLAN.md — Intake flow UI: DropZone, photo preview, AI result review, confidence meter, wired to POST /api/intake
- [ ] 03-05-PLAN.md — PWA setup: manifest.json, service worker, icons, camera QR scanner at /scan
### Phase 4: USB Manager & Label Printing ### Phase 4: USB Manager & Label Printing
**Goal**: USB peripherals are managed by a goroutine-per-device subsystem and any cataloged item can have a QR-coded label printed by the PRT Qutie without operator intervention after intake **Goal**: USB peripherals are managed by a goroutine-per-device subsystem and any cataloged item can have a QR-coded label printed by the PRT Qutie without operator intervention after intake
@ -130,7 +136,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7
|-------|----------------|--------|-----------| |-------|----------------|--------|-----------|
| 1. Foundation | 5/5 | Complete | 2026-04-10 | | 1. Foundation | 5/5 | Complete | 2026-04-10 |
| 2. AI Pipeline | 4/4 | Complete | 2026-04-10 | | 2. AI Pipeline | 4/4 | Complete | 2026-04-10 |
| 3. Dashboard & Intake UI | 0/TBD | Not started | - | | 3. Dashboard & Intake UI | 0/5 | Not started | - |
| 4. USB Manager & Label Printing | 0/TBD | Not started | - | | 4. USB Manager & Label Printing | 0/TBD | Not started | - |
| 5. Cable Test Integration | 0/TBD | Not started | - | | 5. Cable Test Integration | 0/TBD | Not started | - |
| 6. Lab Advisor | 0/TBD | Not started | - | | 6. Lab Advisor | 0/TBD | Not started | - |

View file

@ -0,0 +1,819 @@
---
phase: 03-dashboard-intake-ui
plan: "01"
type: execute
wave: 1
depends_on: []
files_modified:
- web/package.json
- web/tsconfig.json
- web/tsconfig.node.json
- web/vite.config.ts
- web/tailwind.config.ts
- web/postcss.config.cjs
- web/index.html
- web/src/main.tsx
- web/src/App.tsx
- web/src/router.tsx
- web/src/lib/queryClient.ts
- web/src/store/ui.ts
- web/src/styles/globals.css
- web/components.json
- web/src/components/ui/button.tsx
- web/src/components/ui/card.tsx
- web/src/components/ui/badge.tsx
- Makefile
autonomous: true
requirements: [UI-06]
must_haves:
truths:
- "Running `npm run dev` inside web/ serves the SPA at http://localhost:5173 with /api proxied to :8080"
- "Running `npm run build` inside web/ produces web/dist/index.html and web/dist/assets/ that Go can embed"
- "The page background is #000000 with neon volt (#faff69) as the accent color"
- "Inter font weight 900 is loaded and applied to display headings"
- "shadcn/ui Button, Card, and Badge components are available with ClickHouse tokens"
- "TanStack Router renders a /health-check route returning a plain text component"
- "TanStack Query QueryClientProvider wraps the app"
- "Zustand uiStore is defined and importable"
artifacts:
- path: "web/package.json"
provides: "npm project manifest with all dependencies pinned"
- path: "web/tailwind.config.ts"
provides: "ClickHouse design tokens as Tailwind CSS custom colors/fonts"
- path: "web/src/main.tsx"
provides: "React 18 entry point with Router + QueryClient providers"
- path: "web/src/router.tsx"
provides: "TanStack Router route tree with placeholder routes"
- path: "web/src/store/ui.ts"
provides: "Zustand UI store (view mode toggle, scanner state)"
- path: "web/components.json"
provides: "shadcn/ui CLI config"
- path: "web/src/components/ui/button.tsx"
provides: "shadcn/ui Button primitive"
- path: "web/src/components/ui/card.tsx"
provides: "shadcn/ui Card primitive"
- path: "web/src/components/ui/badge.tsx"
provides: "shadcn/ui Badge primitive"
key_links:
- from: "web/src/main.tsx"
to: "web/src/router.tsx"
via: "RouterProvider from @tanstack/react-router"
- from: "web/tailwind.config.ts"
to: "web/src/styles/globals.css"
via: "@tailwind directives + CSS custom properties"
- from: "web/vite.config.ts"
to: "http://localhost:8080"
via: "proxy: { '/api': 'http://localhost:8080' }"
---
<objective>
Bootstrap the React+TypeScript frontend with the full toolchain and ClickHouse design system.
Purpose: Every subsequent UI plan depends on this scaffold existing. The design tokens, component library, routing shell, and build pipeline must all be correct before feature development begins.
Output: A buildable SPA in web/ with Vite 5, React 18, TypeScript 5, Tailwind 3, shadcn/ui, TanStack Router v1, TanStack Query v5, Zustand v4, and the ClickHouse visual identity.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md
@assets.go
@web/dist/index.html
<interfaces>
<!-- Existing Go embed setup — web/dist is already embedded by assets.go at project root -->
<!-- web/dist/index.html is currently a stub placeholder — will be replaced by Vite build output -->
<!-- The Go server uses chi router and serves web/dist via go:embed -->
From assets.go:
```go
//go:embed web/dist
var StaticFiles embed.FS
```
From internal/api/router.go (SPA fallback handler):
```go
// Serves static files from embedded FS; falls back to index.html for client-side routing
r.Handle("/*", spaHandler{staticFS: staticFiles})
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Scaffold Vite+React project and apply ClickHouse design tokens</name>
<files>
web/package.json,
web/tsconfig.json,
web/tsconfig.node.json,
web/vite.config.ts,
web/tailwind.config.ts,
web/postcss.config.cjs,
web/index.html,
web/src/styles/globals.css,
web/components.json
</files>
<action>
Read first: `web/dist/index.html` (current stub), `assets.go`, `internal/api/router.go`.
**1. Create web/package.json** with these exact dependencies:
```json
{
"name": "hwlab",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@radix-ui/react-slot": "^1.1.0",
"@tanstack/react-query": "^5.51.0",
"@tanstack/react-router": "^1.48.0",
"@tanstack/router-devtools": "^1.48.0",
"@zxing/browser": "^0.1.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.417.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.2.3",
"react-hot-toast": "^2.4.1",
"tailwind-merge": "^2.5.2",
"zustand": "^4.5.4"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.15.0",
"@typescript-eslint/parser": "^7.15.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"eslint": "^9.7.0",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.3",
"vite": "^5.3.5"
}
}
```
**2. Create web/vite.config.ts:**
```typescript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})
```
**3. Create web/tsconfig.json** (strict mode, path aliases):
```json
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
```
Create web/tsconfig.app.json:
```json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
```
Create web/tsconfig.node.json:
```json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}
```
**4. Create web/postcss.config.cjs:**
```js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
```
**5. Create web/tailwind.config.ts** with ClickHouse design tokens:
```typescript
import type { Config } from 'tailwindcss'
const config: Config = {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
// ClickHouse design system tokens
'volt': '#faff69', // neon yellow-green — primary CTA accent
'volt-pale': '#f4f692', // active/pressed state text
'forest': '#166534', // primary action button (Get Started)
'forest-dark':'#14572f', // forest border variant
'olive-dark': '#161600', // subtle brand text
'border-olive':'#4f5100', // ghost button borders
// Surfaces
'canvas': '#000000', // pure black background
'near-black': '#141414', // button bg, elevated surfaces
'hover-gray': '#3a3a3a', // button hover bg
'charcoal': '#414141', // primary border (used at 80% opacity in CSS)
'deep-charcoal':'#343434',// subtle dividers
},
fontFamily: {
display: ['Inter', 'system-ui', 'sans-serif'],
body: ['Inter', 'system-ui', 'sans-serif'],
code: ['Inconsolata', 'monospace'],
},
fontWeight: {
black: '900',
},
borderRadius: {
sharp: '4px',
card: '8px',
},
letterSpacing: {
widest: '1.4px',
},
},
},
plugins: [],
}
export default config
```
**6. Create web/index.html** (replace the stub — this is the Vite entry point, not the final dist):
```html
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="manifest" href="/manifest.json" />
<title>HWLab</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
<link href="https://fonts.googleapis.com/css2?family=Inconsolata:wght@600&family=Inter:wght@400;500;600;700;900&display=swap" rel="stylesheet" />
</head>
<body class="bg-canvas text-white antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
```
**7. Create web/src/styles/globals.css** with CSS custom properties and Tailwind directives:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 0%;
--foreground: 0 0% 100%;
--card: 0 0% 8%;
--card-foreground: 0 0% 100%;
--border: 0 0% 25%;
--ring: 64 100% 71%;
--radius: 0.5rem;
}
* {
border-color: rgba(65, 65, 65, 0.8);
}
body {
background-color: #000000;
color: #ffffff;
}
/* Charcoal border utility */
.border-charcoal-80 {
border-color: rgba(65, 65, 65, 0.8);
}
/* ClickHouse card elevation */
.card-elevated {
box-shadow:
rgba(0,0,0,0.1) 0px 10px 15px -3px,
rgba(0,0,0,0.1) 0px 4px 6px -4px;
}
/* Neon accent glow (use sparingly) */
.ring-volt {
box-shadow: 0 0 0 2px #faff69;
}
/* Uppercase tracked label */
.label-upper {
text-transform: uppercase;
letter-spacing: 1.4px;
font-weight: 600;
font-size: 0.75rem;
}
}
```
**8. Create web/components.json** (shadcn/ui CLI config — dark mode, no rsc, Tailwind aliases):
```json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": false,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
```
After creating config files: `cd web && npm install` to generate package-lock.json.
</action>
<verify>
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -5</automated>
</verify>
<done>
`npm run build` inside web/ completes without errors and produces web/dist/index.html and web/dist/assets/. The tailwind.config.ts contains all ClickHouse color tokens (volt, forest, canvas, charcoal, near-black). The vite.config.ts proxy routes /api to http://localhost:8080.
</done>
</task>
<task type="auto">
<name>Task 2: Wire React app entry point with Router, QueryClient, design primitives, and shadcn/ui components</name>
<files>
web/src/main.tsx,
web/src/App.tsx,
web/src/router.tsx,
web/src/lib/utils.ts,
web/src/lib/queryClient.ts,
web/src/store/ui.ts,
web/src/components/ui/button.tsx,
web/src/components/ui/card.tsx,
web/src/components/ui/badge.tsx
</files>
<action>
Read first: `web/src/styles/globals.css`, `web/tailwind.config.ts` (just created in Task 1).
**1. Create web/src/lib/utils.ts** (shadcn/ui cn helper):
```typescript
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
```
**2. Create web/src/lib/queryClient.ts:**
```typescript
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30s — inventory data doesn't change rapidly
retry: 2,
refetchOnWindowFocus: false,
},
},
})
```
**3. Create web/src/store/ui.ts** (Zustand — UI state only, not server data):
```typescript
import { create } from 'zustand'
type ViewMode = 'grid' | 'list'
interface UIStore {
viewMode: ViewMode
setViewMode: (mode: ViewMode) => void
scannerActive: boolean
setScannerActive: (active: boolean) => void
intakeStep: number
setIntakeStep: (step: number) => void
}
export const useUIStore = create<UIStore>((set) => ({
viewMode: 'grid',
setViewMode: (mode) => set({ viewMode: mode }),
scannerActive: false,
setScannerActive: (active) => set({ scannerActive: active }),
intakeStep: 0,
setIntakeStep: (step) => set({ intakeStep: step }),
}))
```
**4. Create web/src/router.tsx** — TanStack Router v1 route tree with placeholder routes for all Phase 3 views:
```typescript
import { createRouter, createRoute, createRootRoute, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
// Root layout — wraps all routes with the app shell
const rootRoute = createRootRoute({
component: () => (
<>
<Outlet />
{import.meta.env.DEV && <TanStackRouterDevtools />}
</>
),
})
// Routes — components are lazy-imported in Plan 03 and 04; stubs for now
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<div className="min-h-screen bg-canvas flex items-center justify-center">
<p className="text-volt font-display font-black text-2xl">HWLab — Dashboard loading…</p>
</div>
),
})
const itemRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/item/$id',
component: () => (
<div className="min-h-screen bg-canvas flex items-center justify-center">
<p className="text-volt font-display font-black text-2xl">Item detail loading…</p>
</div>
),
})
const intakeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/intake',
component: () => (
<div className="min-h-screen bg-canvas flex items-center justify-center">
<p className="text-volt font-display font-black text-2xl">Intake wizard loading…</p>
</div>
),
})
const scanRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/scan',
component: () => (
<div className="min-h-screen bg-canvas flex items-center justify-center">
<p className="text-volt font-display font-black text-2xl">QR Scanner loading…</p>
</div>
),
})
const routeTree = rootRoute.addChildren([indexRoute, itemRoute, intakeRoute, scanRoute])
export const router = createRouter({
routeTree,
defaultPreload: 'intent',
})
// TypeScript type augmentation for TanStack Router
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
```
**5. Create web/src/App.tsx:**
```typescript
import { QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from '@tanstack/react-router'
import { Toaster } from 'react-hot-toast'
import { queryClient } from '@/lib/queryClient'
import { router } from '@/router'
export function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster
position="bottom-right"
toastOptions={{
style: {
background: '#141414',
color: '#ffffff',
border: '1px solid rgba(65,65,65,0.8)',
},
success: {
iconTheme: { primary: '#faff69', secondary: '#000000' },
},
}}
/>
</QueryClientProvider>
)
}
```
**6. Create web/src/main.tsx:**
```typescript
import React from 'react'
import ReactDOM from 'react-dom/client'
import './styles/globals.css'
import { App } from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
```
**7. Create shadcn/ui Button component at web/src/components/ui/button.tsx** (ClickHouse-themed variants):
```typescript
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-volt disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
// Neon primary — #faff69 background, near-black text
default: 'bg-volt text-near-black rounded-sharp border border-volt hover:bg-near-black hover:text-white active:text-volt-pale',
// Forest green — primary action
forest: 'bg-forest text-white rounded-sharp border border-near-black hover:bg-near-black active:text-volt-pale',
// Dark solid
secondary: 'bg-near-black text-white rounded-sharp border border-near-black hover:bg-hover-gray active:text-volt-pale',
// Ghost / outlined
outline: 'bg-transparent text-white rounded-sharp border border-border-olive hover:bg-near-black active:text-volt-pale',
// Destructive
destructive: 'bg-red-900 text-white rounded-sharp hover:bg-red-800',
// Invisible — just text
ghost: 'bg-transparent text-white hover:text-volt',
link: 'text-volt underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 px-3 text-xs',
lg: 'h-11 px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = 'Button'
export { Button, buttonVariants }
```
**8. Create web/src/components/ui/card.tsx** (ClickHouse charcoal card):
```typescript
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-card border border-charcoal/80 bg-near-black text-white shadow-sm transition-colors',
className,
)}
{...props}
/>
),
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-4', className)} {...props} />
),
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
),
)
CardTitle.displayName = 'CardTitle'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('p-4 pt-0', className)} {...props} />,
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-4 pt-0', className)} {...props} />
),
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardTitle, CardContent, CardFooter }
```
**9. Create web/src/components/ui/badge.tsx** (status badges with ClickHouse tokens):
```typescript
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
{
variants: {
variant: {
default: 'border-volt/40 bg-volt/10 text-volt',
indexed: 'border-green-500/40 bg-green-500/10 text-green-400',
draft: 'border-charcoal/80 bg-charcoal/20 text-gray-400',
needs_research: 'border-yellow-500/40 bg-yellow-500/10 text-yellow-400',
researched: 'border-blue-500/40 bg-blue-500/10 text-blue-400',
complete: 'border-forest/40 bg-forest/20 text-green-300',
destructive: 'border-red-500/40 bg-red-500/10 text-red-400',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }
```
**10. Update Makefile** to add frontend build and dev targets:
- Read current Makefile first.
- Add these targets after existing ones:
```makefile
frontend:
cd web && npm run build
dev-frontend:
cd web && npm run dev
dev:
air -c .air.toml
```
(Note: `dev` already exists — if it does, keep the existing `dev` and add only `frontend` and `dev-frontend`.)
Finally run `cd web && npm run build` to verify the build succeeds and web/dist gets populated.
</action>
<verify>
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10 && ls dist/assets/*.js 2>/dev/null | head -3</automated>
</verify>
<done>
`npm run build` succeeds (exit 0). `web/dist/assets/` contains at least one .js file. `web/src/store/ui.ts` exports `useUIStore` with `viewMode`, `scannerActive`, `intakeStep`. `web/src/router.tsx` exports `router` with routes for `/`, `/item/$id`, `/intake`, `/scan`. All shadcn/ui primitives (Button, Card, Badge) have ClickHouse variants.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → Go /api | All API requests cross this boundary; frontend must not forge hw_id or bypass intake |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-01 | Tampering | vite.config.ts proxy | accept | Dev proxy is localhost-only; no external exposure; production uses embedded SPA with no proxy |
| T-03-02 | Info Disclosure | google fonts CDN | accept | Only font metrics exposed; no auth tokens; acceptable for homelab context |
| T-03-03 | Spoofing | TanStack Router client-side routes | accept | No auth in Phase 3 — all routes are public; server enforces data access via NetBox token |
</threat_model>
<verification>
From project root:
1. `cd web && npm run build` — exits 0, produces web/dist/index.html
2. `ls web/dist/assets/*.js` — at least one JS bundle present
3. `go build ./...` — Go binary still compiles (embed path unchanged)
4. `grep -r "volt" web/tailwind.config.ts` — confirms ClickHouse tokens present
5. `grep "proxy" web/vite.config.ts` — confirms /api proxy to :8080
</verification>
<success_criteria>
- Vite 5 + React 18 + TypeScript 5 + Tailwind 3 project exists in web/
- `npm run build` produces web/dist/ ready for Go embed
- ClickHouse design tokens (volt, forest, canvas, charcoal, near-black) available as Tailwind classes
- Inter 900 weight loaded from Google Fonts
- TanStack Router renders placeholder routes for /, /item/$id, /intake, /scan
- TanStack Query QueryClientProvider wraps the app
- Zustand uiStore tracks viewMode, scannerActive, intakeStep
- shadcn/ui Button (neon/forest/secondary/outline variants), Card, Badge primitives exist
- Makefile has `frontend` and `dev-frontend` targets
</success_criteria>
<output>
After completion, create `.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md` following the summary template.
</output>

View file

@ -0,0 +1,346 @@
---
phase: 03-dashboard-intake-ui
plan: "02"
type: execute
wave: 1
depends_on: []
files_modified:
- internal/api/handlers/inventory.go
- internal/api/handlers/inventory_test.go
- internal/api/router.go
autonomous: true
requirements: [UI-01, UI-04]
must_haves:
truths:
- "GET /api/inventory returns a JSON array of inventory items with hw_id, name, catalog_status, and specs"
- "GET /api/inventory/:id returns a single item by NetBox device ID including all custom fields"
- "Both endpoints return proper HTTP status codes (200 on success, 404 for missing item, 502 on NetBox error)"
- "Unit tests pass without a live NetBox connection"
artifacts:
- path: "internal/api/handlers/inventory.go"
provides: "InventoryHandler with ListInventory and GetInventoryItem methods"
exports: ["InventoryHandler", "NewInventoryHandler", "InventoryNetBoxClient"]
- path: "internal/api/handlers/inventory_test.go"
provides: "Unit tests for list and detail endpoints with mock NetBox client"
- path: "internal/api/router.go"
provides: "GET /api/inventory and GET /api/inventory/:id routes registered"
key_links:
- from: "internal/api/handlers/inventory.go"
to: "internal/netbox.Client"
via: "InventoryNetBoxClient interface (ListDevices + GetDevice)"
- from: "internal/api/router.go"
to: "internal/api/handlers.InventoryHandler"
via: "r.Get('/inventory', ...) and r.Get('/inventory/{id}', ...)"
---
<objective>
Add GET /api/inventory and GET /api/inventory/:id endpoints to the Go backend so the React dashboard can fetch real inventory data.
Purpose: The dashboard view (Plan 03) depends on these endpoints. They must exist and be testable before the frontend wires up TanStack Query hooks.
Output: Two handler methods behind an interface, registered on the chi router, with unit tests.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md
@.planning/phases/02-ai-pipeline/02-03-SUMMARY.md
@internal/api/router.go
@internal/netbox/client.go
@internal/api/handlers/intake.go
<interfaces>
<!-- From internal/netbox/client.go — methods the inventory handler will use -->
```go
// ListDevices returns up to limit devices from NetBox.
func (c *Client) ListDevices(ctx context.Context, limit int) ([]Device, error)
// GetDevice retrieves a single device by its NetBox internal ID.
func (c *Client) GetDevice(ctx context.Context, id int) (*Device, error)
```
<!-- From internal/netbox/types.go — Device shape the handler returns as JSON -->
```go
type Device struct {
ID int
Name string
AssetTag string // HW-XXXXX id
CustomFields CustomFields
}
type CustomFields struct {
HWID string
CatalogStatus string
ProductURL string
FirmwareVersion string
TestDate string
TestData string
AINotes string
PhotoURLs []string
}
```
<!-- From internal/api/router.go — current NewRouter signature to extend -->
```go
func NewRouter(staticFiles fs.FS, intakeHandler http.Handler) http.Handler
```
<!-- Established pattern from intake.go: interface-for-testability -->
// Define a narrow interface (InventoryNetBoxClient) rather than taking *netbox.Client directly.
// This allows complete unit testing without a real NetBox.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: InventoryHandler — list and detail endpoints with interface + unit tests</name>
<files>
internal/api/handlers/inventory.go,
internal/api/handlers/inventory_test.go
</files>
<behavior>
- TestListInventoryEmpty: mock returns [], handler returns 200 with JSON `[]`
- TestListInventoryItems: mock returns 2 devices, handler returns 200 with JSON array of 2 items containing hw_id, name, catalog_status, asset_tag, photo_urls
- TestListInventoryNetBoxError: mock returns error, handler returns 502 with {"error": "..."}
- TestGetInventoryItemFound: mock returns device with id=42, GET /api/inventory/42 returns 200 with device JSON
- TestGetInventoryItemNotFound: mock returns nil device + error containing "404", handler returns 404
- TestGetInventoryItemInvalidID: GET /api/inventory/abc returns 400
- TestGetInventoryItemNetBoxError: mock returns server error, handler returns 502
</behavior>
<action>
Read first: `internal/api/handlers/intake.go` (interface-for-testability pattern), `internal/netbox/types.go`, `internal/netbox/client.go`.
**RED phase — write tests first:**
Create `internal/api/handlers/inventory_test.go` with a `mockInventoryNetBoxClient` struct implementing `InventoryNetBoxClient`. Write all 7 tests listed in `<behavior>`. Run `go test ./internal/api/handlers/... -run TestInventory` — tests must FAIL (handler does not exist yet).
**GREEN phase — implement to pass tests:**
Create `internal/api/handlers/inventory.go`:
```go
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"git.georgsen.dk/hwlab/internal/netbox"
)
// InventoryNetBoxClient is the narrow interface the inventory handler needs.
// *netbox.Client satisfies this interface.
type InventoryNetBoxClient interface {
ListDevices(ctx context.Context, limit int) ([]netbox.Device, error)
GetDevice(ctx context.Context, id int) (*netbox.Device, error)
}
// InventoryHandler handles GET /api/inventory and GET /api/inventory/{id}.
type InventoryHandler struct {
nb InventoryNetBoxClient
}
// NewInventoryHandler creates an InventoryHandler.
func NewInventoryHandler(nb InventoryNetBoxClient) *InventoryHandler {
return &InventoryHandler{nb: nb}
}
// InventoryItemResponse is the JSON shape the frontend consumes.
type InventoryItemResponse struct {
ID int `json:"id"`
Name string `json:"name"`
AssetTag string `json:"asset_tag"`
HWID string `json:"hw_id"`
CatalogStatus string `json:"catalog_status"`
ProductURL string `json:"product_url,omitempty"`
Firmware string `json:"firmware_version,omitempty"`
TestDate string `json:"test_date,omitempty"`
TestData string `json:"test_data,omitempty"`
AINotes string `json:"ai_notes,omitempty"`
PhotoURLs []string `json:"photo_urls"`
}
func deviceToResponse(d netbox.Device) InventoryItemResponse {
urls := d.CustomFields.PhotoURLs
if urls == nil {
urls = []string{}
}
return InventoryItemResponse{
ID: d.ID,
Name: d.Name,
AssetTag: d.AssetTag,
HWID: d.CustomFields.HWID,
CatalogStatus: d.CustomFields.CatalogStatus,
ProductURL: d.CustomFields.ProductURL,
Firmware: d.CustomFields.FirmwareVersion,
TestDate: d.CustomFields.TestDate,
TestData: d.CustomFields.TestData,
AINotes: d.CustomFields.AINotes,
PhotoURLs: urls,
}
}
// ListInventory handles GET /api/inventory — returns up to 200 items.
func (h *InventoryHandler) ListInventory(w http.ResponseWriter, r *http.Request) {
devices, err := h.nb.ListDevices(r.Context(), 200)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("netbox unavailable: %s", err)})
return
}
items := make([]InventoryItemResponse, 0, len(devices))
for _, d := range devices {
items = append(items, deviceToResponse(d))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(items)
}
// GetInventoryItem handles GET /api/inventory/{id}.
func (h *InventoryHandler) GetInventoryItem(w http.ResponseWriter, r *http.Request) {
rawID := chi.URLParam(r, "id")
id, err := strconv.Atoi(rawID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "id must be an integer"})
return
}
device, err := h.nb.GetDevice(r.Context(), id)
if err != nil {
w.Header().Set("Content-Type", "application/json")
if strings.Contains(err.Error(), "404") || strings.Contains(strings.ToLower(err.Error()), "not found") {
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "item not found"})
return
}
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("netbox unavailable: %s", err)})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(deviceToResponse(*device))
}
```
Run `go test ./internal/api/handlers/... -run TestInventory` — all 7 tests must PASS.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -run TestInventory -v 2>&1 | tail -20</automated>
</verify>
<done>
All 7 TestInventory* tests pass. `internal/api/handlers/inventory.go` exports `InventoryHandler`, `NewInventoryHandler`, `InventoryNetBoxClient`, `InventoryItemResponse`. `go build ./...` succeeds.
</done>
</task>
<task type="auto">
<name>Task 2: Wire inventory routes into chi router and main.go</name>
<files>
internal/api/router.go,
cmd/hwlab/main.go
</files>
<action>
Read first: `internal/api/router.go`, `cmd/hwlab/main.go`.
**1. Update internal/api/router.go** — extend `NewRouter` to accept an inventory handler:
Change signature from:
```go
func NewRouter(staticFiles fs.FS, intakeHandler http.Handler) http.Handler
```
To:
```go
func NewRouter(staticFiles fs.FS, intakeHandler http.Handler, inventoryHandler *handlers.InventoryHandler) http.Handler
```
Inside the `/api` route group, add:
```go
r.Get("/inventory", inventoryHandler.ListInventory)
r.Get("/inventory/{id}", inventoryHandler.GetInventoryItem)
```
Full updated `/api` block:
```go
r.Route("/api", func(r chi.Router) {
r.Get("/health", handlers.Health)
r.Post("/intake", intakeHandler.ServeHTTP)
r.Get("/inventory", inventoryHandler.ListInventory)
r.Get("/inventory/{id}", inventoryHandler.GetInventoryItem)
})
```
**2. Update cmd/hwlab/main.go** — construct InventoryHandler and pass to NewRouter:
After the existing netbox client construction (search for `netbox.NewClient`), add:
```go
inventoryHandler := handlers.NewInventoryHandler(nbClient)
```
Update the `api.NewRouter(...)` call to include `inventoryHandler`:
```go
router := api.NewRouter(webFS, intakeHandler, inventoryHandler)
```
Run `go build ./...` and `go test ./...` to confirm no regressions.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go build ./... 2>&1 && go test ./... 2>&1 | tail -15</automated>
</verify>
<done>
`go build ./...` exits 0. `go test ./...` — all existing tests pass (integration tests skip gracefully). `grep "inventory" internal/api/router.go` shows both GET routes. `grep "inventoryHandler" cmd/hwlab/main.go` confirms construction and wiring.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → GET /api/inventory | Untrusted caller requests inventory list — no auth in Phase 3 |
| Browser → GET /api/inventory/:id | URL parameter id is untrusted input |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-04 | Tampering | GET /api/inventory/{id} URL param | mitigate | `strconv.Atoi` rejects non-integer IDs; returns 400 — no raw string reaches NetBox API call |
| T-03-05 | Info Disclosure | GET /api/inventory error messages | accept | Error messages describe NetBox connectivity issues only; no credentials or internal paths exposed |
| T-03-06 | DoS | GET /api/inventory limit | mitigate | Hard-coded limit=200 in ListDevices call; caller cannot request unbounded result sets |
| T-03-07 | Elevation of Privilege | Inventory endpoints unauthenticated | accept | Phase 3 is homelab single-operator; no PII or critical data; Phase 4+ can add auth middleware if needed |
</threat_model>
<verification>
From project root:
1. `go build ./...` — exits 0
2. `go test ./internal/api/handlers/... -run TestInventory -v` — 7 tests pass
3. `go test ./...` — all packages pass (integration tests skip)
4. `grep -n "inventory" internal/api/router.go` — shows both GET /inventory and GET /inventory/{id}
5. `grep "NewInventoryHandler" cmd/hwlab/main.go` — confirms wiring
</verification>
<success_criteria>
- GET /api/inventory returns JSON array using real NetBox ListDevices (integration test skips gracefully without live NetBox)
- GET /api/inventory/:id returns single item or 404/502 with proper status codes
- ID validation rejects non-integer inputs with 400
- 7 unit tests pass without live NetBox
- go build ./... succeeds — no regressions to Phase 1/2 code
</success_criteria>
<output>
After completion, create `.planning/phases/03-dashboard-intake-ui/03-02-SUMMARY.md` following the summary template.
</output>

View file

@ -0,0 +1,884 @@
---
phase: 03-dashboard-intake-ui
plan: "03"
type: execute
wave: 2
depends_on: ["03-01", "03-02"]
files_modified:
- web/src/lib/api.ts
- web/src/hooks/useInventory.ts
- web/src/components/layout/AppShell.tsx
- web/src/components/layout/TopBar.tsx
- web/src/components/inventory/ItemCard.tsx
- web/src/components/inventory/ItemRow.tsx
- web/src/components/inventory/FilterBar.tsx
- web/src/components/inventory/StatusBadge.tsx
- web/src/pages/DashboardPage.tsx
- web/src/pages/ItemDetailPage.tsx
- web/src/router.tsx
autonomous: false
requirements: [UI-01, UI-02, UI-04, UI-05, PWA-02]
must_haves:
truths:
- "Visiting / shows the inventory dashboard with a grid of item cards on desktop"
- "Each card shows: photo (or placeholder icon), HW ID, item name, catalog_status badge, and key spec line from ai_notes"
- "Grid/list toggle switches the layout without a page reload"
- "Filter dropdowns for category, catalog_status, and location narrow the displayed items client-side"
- "Clicking a card navigates to /item/:id and shows the item detail page"
- "Item detail shows all custom fields, photo URLs as images, ai_notes, test_data (raw JSON)"
- "Quick actions on dashboard cards: 'View in NetBox' opens new tab to NetBox device URL"
- "Item detail page is readable on a 390px-wide mobile screen (PWA-02)"
artifacts:
- path: "web/src/lib/api.ts"
provides: "typed fetch wrappers for GET /api/inventory and GET /api/inventory/:id"
exports: ["InventoryItem", "fetchInventory", "fetchInventoryItem"]
- path: "web/src/hooks/useInventory.ts"
provides: "TanStack Query hooks wrapping api.ts"
exports: ["useInventory", "useInventoryItem"]
- path: "web/src/components/inventory/ItemCard.tsx"
provides: "Grid card component (photo, HW ID, name, status badge, quick actions)"
- path: "web/src/components/inventory/FilterBar.tsx"
provides: "Client-side filter controls (catalog_status, search by name)"
- path: "web/src/pages/DashboardPage.tsx"
provides: "/ route — inventory grid/list with filters"
- path: "web/src/pages/ItemDetailPage.tsx"
provides: "/item/$id route — full detail view, mobile responsive"
key_links:
- from: "web/src/pages/DashboardPage.tsx"
to: "web/src/hooks/useInventory.ts"
via: "useInventory() hook → TanStack Query → GET /api/inventory"
- from: "web/src/pages/ItemDetailPage.tsx"
to: "web/src/hooks/useInventory.ts"
via: "useInventoryItem(id) hook → TanStack Query → GET /api/inventory/:id"
- from: "web/src/router.tsx"
to: "web/src/pages/DashboardPage.tsx"
via: "indexRoute component replaced with lazy(() => import('./pages/DashboardPage'))"
---
<objective>
Build the inventory dashboard and item detail views — the primary UI for browsing the homelab inventory.
Purpose: Users need to see, filter, and navigate their cataloged hardware. This is the core browsing experience wired to the real backend.
Output: Dashboard page with grid/list toggle and filters; item detail page with all custom fields; mobile-responsive layout following ClickHouse design system.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md
@.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md
@.planning/phases/03-dashboard-intake-ui/03-02-SUMMARY.md
<interfaces>
<!-- From Plan 02: InventoryItemResponse — the JSON shape from GET /api/inventory -->
```typescript
// TypeScript equivalent of internal/api/handlers.InventoryItemResponse
interface InventoryItem {
id: number
name: string
asset_tag: string
hw_id: string
catalog_status: string // draft | indexed | needs_research | researched | complete
product_url?: string
firmware_version?: string
test_date?: string
test_data?: string // raw JSON string
ai_notes?: string
photo_urls: string[]
}
```
<!-- From Plan 01: Zustand store for UI state -->
```typescript
import { useUIStore } from '@/store/ui'
const { viewMode, setViewMode } = useUIStore()
// viewMode: 'grid' | 'list'
```
<!-- From Plan 01: TanStack Router — route params -->
// /item/$id route param access:
// import { useParams } from '@tanstack/react-router'
// const { id } = useParams({ from: '/item/$id' })
<!-- From Plan 01: ClickHouse design tokens available as Tailwind classes -->
// bg-canvas (#000000), bg-near-black (#141414), text-volt (#faff69)
// bg-forest (#166534), border-charcoal/80 (rgba(65,65,65,0.8))
// rounded-card (8px), rounded-sharp (4px)
// font-display font-black (Inter 900)
// label-upper (uppercase tracked label)
<!-- From Plan 01: shadcn/ui components -->
// import { Button } from '@/components/ui/button'
// import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card'
// import { Badge } from '@/components/ui/badge'
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: API client, TanStack Query hooks, AppShell layout, and ItemCard/ItemRow components</name>
<files>
web/src/lib/api.ts,
web/src/hooks/useInventory.ts,
web/src/components/layout/AppShell.tsx,
web/src/components/layout/TopBar.tsx,
web/src/components/inventory/ItemCard.tsx,
web/src/components/inventory/ItemRow.tsx,
web/src/components/inventory/StatusBadge.tsx
</files>
<action>
Read first: `web/src/store/ui.ts`, `web/src/components/ui/card.tsx`, `web/src/components/ui/badge.tsx`, `web/src/components/ui/button.tsx`.
**1. Create web/src/lib/api.ts** — typed fetch wrappers:
```typescript
export interface InventoryItem {
id: number
name: string
asset_tag: string
hw_id: string
catalog_status: string
product_url?: string
firmware_version?: string
test_date?: string
test_data?: string
ai_notes?: string
photo_urls: string[]
}
const BASE = '/api'
async function fetchJSON<T>(url: string): Promise<T> {
const res = await fetch(url)
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(body.error ?? `HTTP ${res.status}`)
}
return res.json() as Promise<T>
}
export const fetchInventory = (): Promise<InventoryItem[]> =>
fetchJSON<InventoryItem[]>(`${BASE}/inventory`)
export const fetchInventoryItem = (id: number): Promise<InventoryItem> =>
fetchJSON<InventoryItem>(`${BASE}/inventory/${id}`)
```
**2. Create web/src/hooks/useInventory.ts:**
```typescript
import { useQuery } from '@tanstack/react-query'
import { fetchInventory, fetchInventoryItem } from '@/lib/api'
export function useInventory() {
return useQuery({
queryKey: ['inventory'],
queryFn: fetchInventory,
})
}
export function useInventoryItem(id: number) {
return useQuery({
queryKey: ['inventory', id],
queryFn: () => fetchInventoryItem(id),
enabled: id > 0,
})
}
```
**3. Create web/src/components/inventory/StatusBadge.tsx** — maps catalog_status to Badge variant:
```typescript
import { Badge } from '@/components/ui/badge'
const STATUS_LABELS: Record<string, string> = {
draft: 'Draft',
indexed: 'Indexed',
needs_research: 'Needs Research',
researched: 'Researched',
complete: 'Complete',
}
type BadgeVariant = 'default' | 'indexed' | 'draft' | 'needs_research' | 'researched' | 'complete' | 'destructive'
export function StatusBadge({ status }: { status: string }) {
const variant = (status in STATUS_LABELS ? status : 'draft') as BadgeVariant
return <Badge variant={variant}>{STATUS_LABELS[status] ?? status}</Badge>
}
```
**4. Create web/src/components/inventory/ItemCard.tsx** — grid card (ClickHouse style):
The card shows: photo or placeholder icon, HW ID (volt text, font-code), item name (white, font-semibold), StatusBadge, ai_notes preview (silver, truncated to 2 lines), quick action buttons (View in NetBox).
```typescript
import { ExternalLink, Package } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { StatusBadge } from './StatusBadge'
import type { InventoryItem } from '@/lib/api'
import { cn } from '@/lib/utils'
interface ItemCardProps {
item: InventoryItem
className?: string
}
export function ItemCard({ item, className }: ItemCardProps) {
const netboxUrl = `http://netbox.local/dcim/devices/${item.id}/`
return (
<Link to="/item/$id" params={{ id: String(item.id) }} className="block">
<Card
className={cn(
'hover:border-volt/60 transition-colors cursor-pointer h-full flex flex-col',
className,
)}
>
{/* Photo */}
<div className="aspect-video bg-near-black rounded-t-card overflow-hidden flex items-center justify-center border-b border-charcoal/80">
{item.photo_urls.length > 0 ? (
<img
src={item.photo_urls[0]}
alt={item.name}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<Package className="w-12 h-12 text-charcoal" />
)}
</div>
<CardHeader className="pb-2">
{/* HW ID */}
<p className="font-code text-volt text-xs label-upper">{item.hw_id || item.asset_tag}</p>
<CardTitle className="text-white text-sm leading-tight line-clamp-2">{item.name}</CardTitle>
</CardHeader>
<CardContent className="pb-2 flex-1">
<StatusBadge status={item.catalog_status} />
{item.ai_notes && (
<p className="mt-2 text-xs text-[#a0a0a0] line-clamp-2">{item.ai_notes}</p>
)}
</CardContent>
<CardFooter className="pt-2">
<Button
variant="ghost"
size="sm"
className="text-xs text-[#a0a0a0] hover:text-volt p-0 h-auto"
asChild
onClick={(e) => e.stopPropagation()}
>
<a href={netboxUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-3 h-3 mr-1" />
NetBox
</a>
</Button>
</CardFooter>
</Card>
</Link>
)
}
```
**5. Create web/src/components/inventory/ItemRow.tsx** — list-mode row:
Horizontal layout: status indicator bar (left, 4px, volt for indexed/complete, yellow for needs_research, gray for draft), HW ID, name, status badge, ai_notes preview, quick action. Uses `<tr>` or a flex div.
```typescript
import { ExternalLink } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { Button } from '@/components/ui/button'
import { StatusBadge } from './StatusBadge'
import type { InventoryItem } from '@/lib/api'
const STATUS_COLOR: Record<string, string> = {
indexed: 'bg-green-500',
complete: 'bg-volt',
researched: 'bg-blue-500',
needs_research: 'bg-yellow-500',
draft: 'bg-charcoal',
}
export function ItemRow({ item }: { item: InventoryItem }) {
const netboxUrl = `http://netbox.local/dcim/devices/${item.id}/`
const dot = STATUS_COLOR[item.catalog_status] ?? 'bg-charcoal'
return (
<Link
to="/item/$id"
params={{ id: String(item.id) }}
className="flex items-center gap-3 px-4 py-3 border-b border-charcoal/40 hover:bg-near-black/60 transition-colors group"
>
{/* Status indicator */}
<div className={`w-1 h-8 rounded-full flex-shrink-0 ${dot}`} />
{/* HW ID */}
<span className="font-code text-volt text-xs w-24 flex-shrink-0">{item.hw_id || item.asset_tag}</span>
{/* Name */}
<span className="text-white text-sm font-medium flex-1 truncate">{item.name}</span>
{/* Status */}
<div className="flex-shrink-0">
<StatusBadge status={item.catalog_status} />
</div>
{/* Notes preview */}
{item.ai_notes && (
<span className="text-xs text-[#a0a0a0] truncate max-w-xs hidden lg:block">{item.ai_notes}</span>
)}
{/* Quick action */}
<Button
variant="ghost"
size="sm"
className="text-xs text-[#a0a0a0] hover:text-volt p-1 h-auto opacity-0 group-hover:opacity-100 transition-opacity"
asChild
onClick={(e) => e.stopPropagation()}
>
<a href={netboxUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-3.5 h-3.5" />
</a>
</Button>
</Link>
)
}
```
**6. Create web/src/components/layout/TopBar.tsx** — app-wide navigation bar:
Left: "HWLab" in Inter Black volt text. Right: intake button (forest green), scan QR button (outline).
```typescript
import { Plus, QrCode } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { Button } from '@/components/ui/button'
export function TopBar() {
return (
<header className="sticky top-0 z-50 flex items-center justify-between px-4 py-3 border-b border-charcoal/80 bg-canvas/95 backdrop-blur-sm">
<Link to="/" className="font-display font-black text-xl text-volt tracking-tight">
HWLab
</Link>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<Link to="/scan">
<QrCode className="w-4 h-4 mr-1.5" />
<span className="hidden sm:inline">Scan</span>
</Link>
</Button>
<Button variant="forest" size="sm" asChild>
<Link to="/intake">
<Plus className="w-4 h-4 mr-1.5" />
<span className="hidden sm:inline">Add Item</span>
</Link>
</Button>
</div>
</header>
)
}
```
**7. Create web/src/components/layout/AppShell.tsx** — wraps TopBar + main content area:
```typescript
import { TopBar } from './TopBar'
export function AppShell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-canvas flex flex-col">
<TopBar />
<main className="flex-1 px-4 py-6 max-w-7xl mx-auto w-full">
{children}
</main>
</div>
)
}
```
</action>
<verify>
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10</automated>
</verify>
<done>
`npm run build` succeeds. `web/src/lib/api.ts` exports `InventoryItem`, `fetchInventory`, `fetchInventoryItem`. `web/src/hooks/useInventory.ts` exports `useInventory`, `useInventoryItem`. All five component files exist with no TypeScript errors.
</done>
</task>
<task type="auto">
<name>Task 2: DashboardPage, FilterBar, ItemDetailPage, and router wiring</name>
<files>
web/src/components/inventory/FilterBar.tsx,
web/src/pages/DashboardPage.tsx,
web/src/pages/ItemDetailPage.tsx,
web/src/router.tsx
</files>
<action>
Read first: `web/src/router.tsx` (current stub routes), `web/src/store/ui.ts` (viewMode), `web/src/hooks/useInventory.ts`.
**1. Create web/src/components/inventory/FilterBar.tsx** — client-side filter controls:
Filter state is local component state (not Zustand — ephemeral UI). Filters: text search (name/hw_id), catalog_status select.
```typescript
import { Search, LayoutGrid, List } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useUIStore } from '@/store/ui'
interface FilterBarProps {
search: string
onSearchChange: (v: string) => void
statusFilter: string
onStatusChange: (v: string) => void
totalCount: number
}
const STATUSES = ['', 'draft', 'indexed', 'needs_research', 'researched', 'complete']
const STATUS_LABELS: Record<string, string> = {
'': 'All Statuses',
draft: 'Draft',
indexed: 'Indexed',
needs_research: 'Needs Research',
researched: 'Researched',
complete: 'Complete',
}
export function FilterBar({ search, onSearchChange, statusFilter, onStatusChange, totalCount }: FilterBarProps) {
const { viewMode, setViewMode } = useUIStore()
return (
<div className="flex flex-wrap items-center gap-3 mb-6">
{/* Search */}
<div className="relative flex-1 min-w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#a0a0a0]" />
<input
type="text"
placeholder="Search items…"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full pl-9 pr-3 py-2 bg-near-black border border-charcoal/80 rounded-sharp text-sm text-white placeholder-[#585858] focus:outline-none focus:border-volt/60 focus:ring-1 focus:ring-volt/30"
/>
</div>
{/* Status filter */}
<select
value={statusFilter}
onChange={(e) => onStatusChange(e.target.value)}
className="px-3 py-2 bg-near-black border border-charcoal/80 rounded-sharp text-sm text-white focus:outline-none focus:border-volt/60"
>
{STATUSES.map((s) => (
<option key={s} value={s} className="bg-near-black">
{STATUS_LABELS[s]}
</option>
))}
</select>
{/* Item count */}
<span className="text-xs text-[#a0a0a0] label-upper mr-auto">
{totalCount} items
</span>
{/* View toggle */}
<div className="flex rounded-sharp border border-charcoal/80 overflow-hidden">
<Button
variant={viewMode === 'grid' ? 'default' : 'secondary'}
size="icon"
className="rounded-none h-9 w-9"
onClick={() => setViewMode('grid')}
title="Grid view"
>
<LayoutGrid className="w-4 h-4" />
</Button>
<Button
variant={viewMode === 'list' ? 'default' : 'secondary'}
size="icon"
className="rounded-none h-9 w-9 border-l border-charcoal/80"
onClick={() => setViewMode('list')}
title="List view"
>
<List className="w-4 h-4" />
</Button>
</div>
</div>
)
}
```
**2. Create web/src/pages/DashboardPage.tsx** — the main inventory view:
```typescript
import { useState, useMemo } from 'react'
import { AppShell } from '@/components/layout/AppShell'
import { FilterBar } from '@/components/inventory/FilterBar'
import { ItemCard } from '@/components/inventory/ItemCard'
import { ItemRow } from '@/components/inventory/ItemRow'
import { useInventory } from '@/hooks/useInventory'
import { useUIStore } from '@/store/ui'
import { Loader2, AlertCircle } from 'lucide-react'
export function DashboardPage() {
const { data: items, isLoading, error } = useInventory()
const { viewMode } = useUIStore()
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const filtered = useMemo(() => {
if (!items) return []
return items.filter((item) => {
const matchSearch =
!search ||
item.name.toLowerCase().includes(search.toLowerCase()) ||
(item.hw_id?.toLowerCase() ?? '').includes(search.toLowerCase()) ||
(item.asset_tag?.toLowerCase() ?? '').includes(search.toLowerCase())
const matchStatus = !statusFilter || item.catalog_status === statusFilter
return matchSearch && matchStatus
})
}, [items, search, statusFilter])
return (
<AppShell>
{/* Page header */}
<div className="mb-6">
<h1 className="font-display font-black text-3xl text-white mb-1">Inventory</h1>
<p className="text-sm text-[#a0a0a0]">All cataloged hardware in your homelab</p>
</div>
<FilterBar
search={search}
onSearchChange={setSearch}
statusFilter={statusFilter}
onStatusChange={setStatusFilter}
totalCount={filtered.length}
/>
{/* Loading */}
{isLoading && (
<div className="flex items-center justify-center py-24 text-[#a0a0a0]">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Loading inventory…
</div>
)}
{/* Error */}
{error && (
<div className="flex items-center gap-3 p-4 border border-red-500/40 rounded-card bg-red-500/10 text-red-400">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">{(error as Error).message}</span>
</div>
)}
{/* Empty state */}
{!isLoading && !error && filtered.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-center">
<p className="font-display font-black text-4xl text-volt mb-2">0</p>
<p className="text-[#a0a0a0] text-sm">
{items && items.length > 0 ? 'No items match your filters' : 'No items cataloged yet — add your first item'}
</p>
</div>
)}
{/* Grid view */}
{!isLoading && !error && filtered.length > 0 && viewMode === 'grid' && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{filtered.map((item) => (
<ItemCard key={item.id} item={item} />
))}
</div>
)}
{/* List view */}
{!isLoading && !error && filtered.length > 0 && viewMode === 'list' && (
<div className="border border-charcoal/80 rounded-card overflow-hidden">
{filtered.map((item) => (
<ItemRow key={item.id} item={item} />
))}
</div>
)}
</AppShell>
)
}
```
**3. Create web/src/pages/ItemDetailPage.tsx** — full item detail, mobile-responsive:
Layout: TopBar via AppShell, back button, two-column on desktop (photos left, fields right), single column on mobile. Shows all custom fields, photos, raw test_data in a code block.
```typescript
import { useParams, Link } from '@tanstack/react-router'
import { ArrowLeft, ExternalLink, Package } from 'lucide-react'
import { AppShell } from '@/components/layout/AppShell'
import { StatusBadge } from '@/components/inventory/StatusBadge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { useInventoryItem } from '@/hooks/useInventory'
import { Loader2, AlertCircle } from 'lucide-react'
function FieldRow({ label, value }: { label: string; value?: string }) {
if (!value) return null
return (
<div className="flex gap-3 py-2 border-b border-charcoal/40 last:border-0">
<span className="label-upper text-[#a0a0a0] w-36 flex-shrink-0">{label}</span>
<span className="text-white text-sm flex-1 break-all">{value}</span>
</div>
)
}
export function ItemDetailPage() {
const { id } = useParams({ from: '/item/$id' })
const numericId = parseInt(id, 10)
const { data: item, isLoading, error } = useInventoryItem(numericId)
if (isLoading) {
return (
<AppShell>
<div className="flex items-center justify-center py-24 text-[#a0a0a0]">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Loading item…
</div>
</AppShell>
)
}
if (error || !item) {
return (
<AppShell>
<div className="flex items-center gap-3 p-4 border border-red-500/40 rounded-card bg-red-500/10 text-red-400">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">{(error as Error)?.message ?? 'Item not found'}</span>
</div>
</AppShell>
)
}
const netboxUrl = `http://netbox.local/dcim/devices/${item.id}/`
return (
<AppShell>
{/* Back nav */}
<div className="mb-4">
<Button variant="ghost" size="sm" asChild>
<Link to="/">
<ArrowLeft className="w-4 h-4 mr-1.5" />
Inventory
</Link>
</Button>
</div>
{/* Header */}
<div className="flex flex-wrap items-start gap-4 mb-6">
<div className="flex-1 min-w-0">
<p className="font-code text-volt text-sm label-upper mb-1">{item.hw_id || item.asset_tag}</p>
<h1 className="font-display font-black text-2xl text-white leading-tight">{item.name}</h1>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<StatusBadge status={item.catalog_status} />
<Button variant="outline" size="sm" asChild>
<a href={netboxUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
NetBox
</a>
</Button>
</div>
</div>
{/* Two-column layout: photos (left on lg+) / fields (right or below) */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Photos */}
<Card>
<CardHeader>
<CardTitle className="text-sm label-upper text-[#a0a0a0]">Photos</CardTitle>
</CardHeader>
<CardContent>
{item.photo_urls.length > 0 ? (
<div className="grid grid-cols-2 gap-2">
{item.photo_urls.map((url, i) => (
<a key={i} href={url} target="_blank" rel="noopener noreferrer">
<img
src={url}
alt={`${item.name} photo ${i + 1}`}
className="w-full rounded-sharp object-cover aspect-square hover:opacity-80 transition-opacity"
loading="lazy"
/>
</a>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-[#585858]">
<Package className="w-12 h-12 mb-2" />
<p className="text-sm">No photos</p>
</div>
)}
</CardContent>
</Card>
{/* Fields */}
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-sm label-upper text-[#a0a0a0]">Details</CardTitle>
</CardHeader>
<CardContent>
<FieldRow label="HW ID" value={item.hw_id || item.asset_tag} />
<FieldRow label="Status" value={item.catalog_status} />
<FieldRow label="Firmware" value={item.firmware_version} />
<FieldRow label="Test Date" value={item.test_date} />
<FieldRow label="Product URL" value={item.product_url} />
</CardContent>
</Card>
{item.ai_notes && (
<Card>
<CardHeader>
<CardTitle className="text-sm label-upper text-[#a0a0a0]">AI Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-white whitespace-pre-wrap">{item.ai_notes}</p>
</CardContent>
</Card>
)}
{item.test_data && (
<Card>
<CardHeader>
<CardTitle className="text-sm label-upper text-[#a0a0a0]">Test Data</CardTitle>
</CardHeader>
<CardContent>
<pre className="font-code text-xs text-[#a0a0a0] bg-near-black p-3 rounded-sharp overflow-x-auto whitespace-pre-wrap break-words">
{(() => {
try { return JSON.stringify(JSON.parse(item.test_data!), null, 2) }
catch { return item.test_data }
})()}
</pre>
</CardContent>
</Card>
)}
</div>
</div>
</AppShell>
)
}
```
**4. Update web/src/router.tsx** — replace stub components with real pages:
Read current `web/src/router.tsx`, then update `indexRoute` and `itemRoute` to use the real page components. Use lazy imports to keep bundle splitting:
```typescript
import { lazy, Suspense } from 'react'
// ... existing imports ...
const DashboardPage = lazy(() => import('./pages/DashboardPage').then(m => ({ default: m.DashboardPage })))
const ItemDetailPage = lazy(() => import('./pages/ItemDetailPage').then(m => ({ default: m.ItemDetailPage })))
const Spinner = () => (
<div className="min-h-screen bg-canvas flex items-center justify-center">
<div className="w-6 h-6 border-2 border-volt border-t-transparent rounded-full animate-spin" />
</div>
)
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<Suspense fallback={<Spinner />}>
<DashboardPage />
</Suspense>
),
})
const itemRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/item/$id',
component: () => (
<Suspense fallback={<Spinner />}>
<ItemDetailPage />
</Suspense>
),
})
```
Keep intakeRoute and scanRoute as stubs (Plan 04 and 05 replace them).
Run `npm run build` to confirm TypeScript compiles with no errors.
</action>
<verify>
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -15</automated>
</verify>
<done>
`npm run build` exits 0 with no TypeScript errors. `web/src/pages/DashboardPage.tsx` renders FilterBar (search + status dropdown + view toggle), grid/list conditional rendering, loading/error/empty states. `web/src/pages/ItemDetailPage.tsx` shows two-column layout with photo grid and field rows. `web/src/router.tsx` lazy-loads DashboardPage and ItemDetailPage.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Dashboard and Item detail views wired to real backend inventory API.
- `/` — inventory grid (5-col on xl, responsive) with search, status filter, view toggle
- `/item/:id` — full detail with photos, custom fields, ai_notes, test_data, NetBox link
- ClickHouse design: black canvas, volt accents, charcoal card borders, Inter 900 display
- Grid/list toggle persists in Zustand (survives navigation within session)
- StatusBadge color-coded for all 5 catalog statuses
</what-built>
<how-to-verify>
1. Start Go backend: `cd /home/mikkel/homelabby && go run ./cmd/hwlab/...`
2. Start Vite dev server: `cd web && npm run dev`
3. Open http://localhost:5173
Check:
- [ ] Background is pure black (#000000)
- [ ] "HWLab" in the TopBar is volt (#faff69) Inter Black
- [ ] "Add Item" button is forest green, "Scan" is ghost/outlined
- [ ] With no NetBox items: empty state shows "0" in volt color, descriptive text
- [ ] With NetBox items (if live NetBox available): cards render with HW ID, name, status badge
- [ ] Grid/list toggle switches layout
- [ ] Search filters items client-side as you type
- [ ] Status dropdown filters correctly
- [ ] Clicking a card navigates to /item/:id
- [ ] Item detail shows two columns on desktop, single column on mobile (resize to 390px)
- [ ] "NetBox" link on detail page opens new tab
If NetBox is unavailable: verify the error state renders (red border, alert icon, error message) rather than crashing.
</how-to-verify>
<resume-signal>Type "approved" or describe visual/functional issues to fix</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| React → GET /api/inventory | Frontend fetches inventory; all data comes from backend, not directly from NetBox |
| External links → NetBox | "View in NetBox" links open user-provided URLs — these use device IDs only |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-08 | Info Disclosure | photo_urls displayed as &lt;img&gt; | accept | URLs come from NetBox custom fields (trusted data store); homelab-internal only; no external URL injection path |
| T-03-09 | Tampering | test_data rendered in &lt;pre&gt; | mitigate | React escapes HTML in JSX; test_data is inside a &lt;pre&gt; tag as text content, not dangerouslySetInnerHTML — no XSS risk |
| T-03-10 | Info Disclosure | ai_notes rendered as text | accept | whitespace-pre-wrap rendering does not execute scripts; React's JSX rendering is safe |
</threat_model>
<verification>
1. `cd web && npm run build` — exits 0, no TypeScript errors
2. `go build ./...` — Go still compiles
3. Visual verification via checkpoint task
4. `grep "DashboardPage" web/src/router.tsx` — lazy import present
5. `grep "useInventory" web/src/pages/DashboardPage.tsx` — TanStack Query hook wired
</verification>
<success_criteria>
- Dashboard renders grid/list of inventory items fetched from GET /api/inventory
- Filtering by name/hw_id and catalog_status works client-side without page reload
- Grid/list toggle works and persists in Zustand
- Item detail shows all custom fields, photos, and test data
- Mobile responsive: single column at 390px width (PWA-02)
- Quick action "View in NetBox" opens correct URL in new tab (UI-05)
- ClickHouse design system: black canvas, volt text, charcoal borders, Inter 900 display
- Human verification checkpoint passed
</success_criteria>
<output>
After completion, create `.planning/phases/03-dashboard-intake-ui/03-03-SUMMARY.md` following the summary template.
</output>

View file

@ -0,0 +1,732 @@
---
phase: 03-dashboard-intake-ui
plan: "04"
type: execute
wave: 2
depends_on: ["03-01"]
files_modified:
- web/src/lib/api.ts
- web/src/store/intake.ts
- web/src/components/intake/DropZone.tsx
- web/src/components/intake/PhotoPreview.tsx
- web/src/components/intake/AIResultReview.tsx
- web/src/components/intake/ConfirmForm.tsx
- web/src/pages/IntakePage.tsx
- web/src/router.tsx
autonomous: false
requirements: [UI-04, UI-05]
must_haves:
truths:
- "User can drag-and-drop or click to upload 1-3 photos on the intake screen"
- "After upload, a loading state is shown while POST /api/intake is in flight"
- "AI result is displayed inline: model, manufacturer, category, suggested tags, ai_notes, confidence"
- "User can edit the name field before confirming"
- "Confirming on a high-confidence result (quick_add=true flag in request) creates the NetBox record"
- "After successful intake, a toast notification shows the assigned HW ID"
- "User is returned to dashboard after successful intake"
- "Errors from the backend are shown inline (not just console)"
artifacts:
- path: "web/src/store/intake.ts"
provides: "Zustand intake store — step tracking, photos, AI result, edit state"
exports: ["useIntakeStore", "IntakeResult"]
- path: "web/src/components/intake/DropZone.tsx"
provides: "react-dropzone file upload area, photo count indicator"
- path: "web/src/components/intake/AIResultReview.tsx"
provides: "AI result display with confidence meter and editable name field"
- path: "web/src/components/intake/ConfirmForm.tsx"
provides: "Final confirm/cancel actions"
- path: "web/src/pages/IntakePage.tsx"
provides: "/intake route — multi-step wizard orchestrator"
key_links:
- from: "web/src/pages/IntakePage.tsx"
to: "POST /api/intake"
via: "fetch in submitPhotos using FormData multipart upload"
- from: "web/src/components/intake/AIResultReview.tsx"
to: "web/src/store/intake.ts"
via: "useIntakeStore() — aiResult, editedName, step"
- from: "web/src/router.tsx"
to: "web/src/pages/IntakePage.tsx"
via: "intakeRoute component updated from stub to lazy IntakePage"
---
<objective>
Build the intake flow UI — photo upload, AI result review, and confirm-to-create workflow — wired to the POST /api/intake endpoint from Phase 2.
Purpose: Photo intake is the core value proposition of HWLab. The UI must make it effortless: drag photos, review AI output, correct if needed, confirm.
Output: A three-step wizard (Upload → Review → Done) using react-dropzone, Zustand state, and TanStack Query mutations.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md
@.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md
@.planning/phases/02-ai-pipeline/02-03-SUMMARY.md
<interfaces>
<!-- POST /api/intake — from Phase 2 Plan 03 -->
<!-- Request: multipart/form-data -->
<!-- photos[]: File (1-3 images) -->
<!-- quick_add: "true" | "false" (optional, string in FormData) -->
<!-- Response (201 success): -->
```typescript
interface IntakeResponse {
hw_id: string
model: string
manufacturer: string
category: string
specs: Record<string, string>
suggested_tags: string[]
ai_notes: string
confidence: number // 0.0 1.0
catalog_status: string // indexed | needs_research
netbox_id: number
queued: boolean // true if WAQ-enqueued (202)
}
```
<!-- Response (202): queued=true, hw_id assigned but NetBox unavailable -->
<!-- Response (400): {"error": "..."} — wrong photo count or bad request -->
<!-- Response (503): {"error": "..."} — WAQ also unavailable -->
<!-- From Plan 01: Zustand pattern -->
// Create a separate store for intake (not in ui.ts — different concern)
// useIntakeStore with step, photos, aiResult, editedName, isSubmitting, error
<!-- From Plan 01: design tokens -->
// bg-canvas, bg-near-black, text-volt, border-charcoal/80, rounded-card, rounded-sharp
// Button variants: default (volt), forest, secondary, outline, ghost
<!-- react-dropzone usage: -->
// import { useDropzone } from 'react-dropzone'
// const { getRootProps, getInputProps, isDragActive } = useDropzone({
// accept: { 'image/*': [] },
// maxFiles: 3,
// onDrop: acceptedFiles => { ... }
// })
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Intake store, DropZone component, and API mutation</name>
<files>
web/src/store/intake.ts,
web/src/lib/api.ts,
web/src/components/intake/DropZone.tsx,
web/src/components/intake/PhotoPreview.tsx
</files>
<action>
Read first: `web/src/store/ui.ts` (Zustand pattern), `web/src/lib/api.ts` (current state), `web/src/components/ui/button.tsx`.
**1. Create web/src/store/intake.ts** — Zustand intake wizard state:
```typescript
import { create } from 'zustand'
export interface IntakeResult {
hw_id: string
model: string
manufacturer: string
category: string
specs: Record<string, string>
suggested_tags: string[]
ai_notes: string
confidence: number
catalog_status: string
netbox_id: number
queued: boolean
}
type IntakeStep = 'upload' | 'submitting' | 'review' | 'done' | 'error'
interface IntakeStore {
step: IntakeStep
photos: File[]
aiResult: IntakeResult | null
editedName: string
error: string | null
addPhoto: (file: File) => void
removePhoto: (index: number) => void
setStep: (step: IntakeStep) => void
setAIResult: (result: IntakeResult) => void
setEditedName: (name: string) => void
setError: (err: string | null) => void
reset: () => void
}
const initialState = {
step: 'upload' as IntakeStep,
photos: [] as File[],
aiResult: null,
editedName: '',
error: null,
}
export const useIntakeStore = create<IntakeStore>((set) => ({
...initialState,
addPhoto: (file) =>
set((s) => ({
photos: s.photos.length < 3 ? [...s.photos, file] : s.photos,
})),
removePhoto: (index) =>
set((s) => ({ photos: s.photos.filter((_, i) => i !== index) })),
setStep: (step) => set({ step }),
setAIResult: (result) =>
set({ aiResult: result, editedName: result.model || result.manufacturer }),
setEditedName: (editedName) => set({ editedName }),
setError: (error) => set({ error }),
reset: () => set(initialState),
}))
```
**2. Update web/src/lib/api.ts** — add intake mutation function:
Read the current file first, then add below the existing exports:
```typescript
export interface IntakeResponse {
hw_id: string
model: string
manufacturer: string
category: string
specs: Record<string, string>
suggested_tags: string[]
ai_notes: string
confidence: number
catalog_status: string
netbox_id: number
queued: boolean
}
export async function submitIntake(photos: File[], quickAdd = false): Promise<IntakeResponse> {
const form = new FormData()
for (const photo of photos) {
form.append('photos[]', photo)
}
form.append('quick_add', String(quickAdd))
const res = await fetch('/api/intake', { method: 'POST', body: form })
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(body.error ?? `HTTP ${res.status}`)
}
return res.json() as Promise<IntakeResponse>
}
```
**3. Create web/src/components/intake/PhotoPreview.tsx** — thumbnail grid with remove button:
```typescript
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface PhotoPreviewProps {
photos: File[]
onRemove: (index: number) => void
}
export function PhotoPreview({ photos, onRemove }: PhotoPreviewProps) {
if (photos.length === 0) return null
return (
<div className="flex gap-3 flex-wrap mt-4">
{photos.map((file, i) => {
const url = URL.createObjectURL(file)
return (
<div key={i} className="relative w-24 h-24 rounded-sharp overflow-hidden border border-charcoal/80 group">
<img src={url} alt={`Photo ${i + 1}`} className="w-full h-full object-cover" />
<Button
variant="destructive"
size="icon"
className="absolute top-1 right-1 h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => onRemove(i)}
>
<X className="w-3 h-3" />
</Button>
</div>
)
})}
</div>
)
}
```
**4. Create web/src/components/intake/DropZone.tsx** — drag-drop upload area:
```typescript
import { useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, Camera } from 'lucide-react'
import { cn } from '@/lib/utils'
interface DropZoneProps {
photoCount: number
onDrop: (files: File[]) => void
}
const MAX = 3
export function DropZone({ photoCount, onDrop }: DropZoneProps) {
const remaining = MAX - photoCount
const disabled = remaining === 0
const handleDrop = useCallback(
(accepted: File[]) => {
onDrop(accepted.slice(0, remaining))
},
[onDrop, remaining],
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: { 'image/*': [] },
maxFiles: remaining,
disabled,
onDrop: handleDrop,
})
return (
<div
{...getRootProps()}
className={cn(
'relative flex flex-col items-center justify-center gap-3 rounded-card border-2 border-dashed p-12 text-center transition-colors',
isDragActive
? 'border-volt bg-volt/5 text-volt'
: disabled
? 'border-charcoal/40 text-charcoal cursor-not-allowed opacity-50'
: 'border-charcoal/80 text-[#a0a0a0] hover:border-volt/60 hover:text-white cursor-pointer',
)}
>
<input {...getInputProps()} capture="environment" />
{isDragActive ? (
<Upload className="w-10 h-10 text-volt" />
) : (
<Camera className="w-10 h-10" />
)}
<div>
<p className="font-semibold text-sm">
{isDragActive
? 'Drop photos here'
: disabled
? 'Maximum 3 photos reached'
: 'Drag photos or tap to shoot'}
</p>
<p className="text-xs mt-1 opacity-70">
{disabled ? '' : `${remaining} of ${MAX} slots remaining · JPEG, PNG, WebP`}
</p>
</div>
</div>
)
}
```
Run `npm run build` to confirm TypeScript compiles.
</action>
<verify>
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10</automated>
</verify>
<done>
`npm run build` exits 0. `web/src/store/intake.ts` exports `useIntakeStore` and `IntakeResult`. `web/src/lib/api.ts` exports `submitIntake` and `IntakeResponse`. DropZone and PhotoPreview components exist with no TypeScript errors.
</done>
</task>
<task type="auto">
<name>Task 2: AIResultReview, ConfirmForm, IntakePage, and router wiring</name>
<files>
web/src/components/intake/AIResultReview.tsx,
web/src/components/intake/ConfirmForm.tsx,
web/src/pages/IntakePage.tsx,
web/src/router.tsx
</files>
<action>
Read first: `web/src/router.tsx` (current state — has stub intakeRoute), `web/src/store/intake.ts`, `web/src/components/ui/card.tsx`.
**1. Create web/src/components/intake/AIResultReview.tsx** — display AI extraction results with confidence meter and editable name:
```typescript
import { AlertTriangle, CheckCircle, Edit3 } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import type { IntakeResult } from '@/store/intake'
interface AIResultReviewProps {
result: IntakeResult
editedName: string
onNameChange: (name: string) => void
}
function ConfidenceMeter({ value }: { value: number }) {
const pct = Math.round(value * 100)
const color =
pct >= 85 ? 'bg-green-500' :
pct >= 60 ? 'bg-yellow-500' :
'bg-red-500'
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-near-black rounded-full overflow-hidden">
<div className={`h-full rounded-full transition-all ${color}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs font-mono text-[#a0a0a0]">{pct}%</span>
{pct >= 85 ? (
<CheckCircle className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />
) : (
<AlertTriangle className="w-3.5 h-3.5 text-yellow-400 flex-shrink-0" />
)}
</div>
)
}
export function AIResultReview({ result, editedName, onNameChange }: AIResultReviewProps) {
return (
<div className="space-y-4">
{/* Confidence */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm label-upper text-[#a0a0a0]">AI Confidence</CardTitle>
</CardHeader>
<CardContent>
<ConfidenceMeter value={result.confidence} />
{result.confidence < 0.6 && (
<p className="mt-2 text-xs text-yellow-400">
Low confidence — item will be flagged for research
</p>
)}
</CardContent>
</Card>
{/* Editable name */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm label-upper text-[#a0a0a0] flex items-center gap-2">
Item Name
<Edit3 className="w-3 h-3" />
</CardTitle>
</CardHeader>
<CardContent>
<input
type="text"
value={editedName}
onChange={(e) => onNameChange(e.target.value)}
className="w-full px-3 py-2 bg-near-black border border-charcoal/80 rounded-sharp text-white text-sm focus:outline-none focus:border-volt/60 focus:ring-1 focus:ring-volt/30"
placeholder="Item name"
/>
</CardContent>
</Card>
{/* Classification */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm label-upper text-[#a0a0a0]">Classification</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex gap-3">
<span className="text-[#a0a0a0] w-28 flex-shrink-0">Manufacturer</span>
<span className="text-white">{result.manufacturer || '—'}</span>
</div>
<div className="flex gap-3">
<span className="text-[#a0a0a0] w-28 flex-shrink-0">Model</span>
<span className="text-white">{result.model || '—'}</span>
</div>
<div className="flex gap-3">
<span className="text-[#a0a0a0] w-28 flex-shrink-0">Category</span>
<span className="text-white">{result.category || '—'}</span>
</div>
{result.suggested_tags.length > 0 && (
<div className="flex gap-3 flex-wrap items-start pt-1">
<span className="text-[#a0a0a0] w-28 flex-shrink-0">Tags</span>
<div className="flex gap-1.5 flex-wrap">
{result.suggested_tags.map((tag) => (
<Badge key={tag} variant="default" className="text-xs">{tag}</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* AI notes */}
{result.ai_notes && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm label-upper text-[#a0a0a0]">AI Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-white whitespace-pre-wrap">{result.ai_notes}</p>
</CardContent>
</Card>
)}
</div>
)
}
```
**2. Create web/src/components/intake/ConfirmForm.tsx** — final action buttons:
```typescript
import { CheckCircle, RotateCcw, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface ConfirmFormProps {
isSubmitting: boolean
onConfirm: () => void
onReset: () => void
}
export function ConfirmForm({ isSubmitting, onConfirm, onReset }: ConfirmFormProps) {
return (
<div className="flex gap-3 pt-4">
<Button
variant="forest"
className="flex-1"
onClick={onConfirm}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating record…
</>
) : (
<>
<CheckCircle className="w-4 h-4 mr-2" />
Confirm &amp; Create Record
</>
)}
</Button>
<Button variant="secondary" onClick={onReset} disabled={isSubmitting}>
<RotateCcw className="w-4 h-4 mr-1.5" />
Start Over
</Button>
</div>
)
}
```
**3. Create web/src/pages/IntakePage.tsx** — the intake wizard orchestrator:
Three steps:
- `upload`: Show DropZone + PhotoPreview + "Analyze with AI" button (forest green)
- `submitting`: Uploading spinner overlay
- `review`: AIResultReview + ConfirmForm
- `done`: Success card with HW ID (volt) + "Add Another" / "Go to Dashboard" buttons
- `error`: Error card with retry
The page submits photos to POST /api/intake with `quick_add=false` initially (user reviews). The review ConfirmForm button calls `submitIntake` again with `quick_add=true` to trigger actual NetBox record creation (or relies on the already-created record from the initial call — see INTAKE-04 decision: handler always creates immediately). Check the Phase 2 decision: the backend creates the record on the first POST; there is no separate "confirm" POST. The UI review step is purely a display concern. The flow is: POST photos → show result → user edits name (display only, name was set by AI in NetBox already) → navigate to dashboard.
Adjust: since the backend creates on the first POST (INTAKE-04 decision from Phase 2 summary), the intake flow is:
1. Upload + submit (POST /api/intake) → NetBox record created, AI result returned
2. Review result (readonly display, name already in NetBox)
3. Done → toast with HW ID → navigate to dashboard
```typescript
import { useNavigate, Link } from '@tanstack/react-router'
import toast from 'react-hot-toast'
import { Loader2, CheckCircle2 } from 'lucide-react'
import { AppShell } from '@/components/layout/AppShell'
import { DropZone } from '@/components/intake/DropZone'
import { PhotoPreview } from '@/components/intake/PhotoPreview'
import { AIResultReview } from '@/components/intake/AIResultReview'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { useIntakeStore } from '@/store/intake'
import { submitIntake } from '@/lib/api'
export function IntakePage() {
const navigate = useNavigate()
const {
step, photos, aiResult, editedName,
addPhoto, removePhoto, setStep, setAIResult, setEditedName, setError, reset,
} = useIntakeStore()
async function handleAnalyze() {
if (photos.length === 0) return
setStep('submitting')
setError(null)
try {
const result = await submitIntake(photos, false)
setAIResult(result)
setStep('review')
toast.success(`HW ID assigned: ${result.hw_id}`)
} catch (e) {
setError((e as Error).message)
setStep('error')
}
}
function handleDone() {
const hwid = aiResult?.hw_id
reset()
if (hwid) {
toast.success(`${hwid} added to inventory`)
}
navigate({ to: '/' })
}
return (
<AppShell>
{/* Header */}
<div className="mb-6">
<h1 className="font-display font-black text-3xl text-white mb-1">Add Item</h1>
<p className="text-sm text-[#a0a0a0]">Upload 13 photos — AI extracts specs automatically</p>
</div>
{/* Step: upload */}
{(step === 'upload' || step === 'error') && (
<div className="max-w-xl space-y-4">
<DropZone photoCount={photos.length} onDrop={(files) => files.forEach(addPhoto)} />
<PhotoPreview photos={photos} onRemove={removePhoto} />
{step === 'error' && (
<Card className="border-red-500/40 bg-red-500/10">
<CardContent className="p-4 text-sm text-red-400">
Analysis failed — check that the Go backend is running and try again.
</CardContent>
</Card>
)}
<Button
variant="forest"
className="w-full"
onClick={handleAnalyze}
disabled={photos.length === 0}
>
Analyze with AI
</Button>
</div>
)}
{/* Step: submitting */}
{step === 'submitting' && (
<div className="flex flex-col items-center justify-center py-24 gap-4">
<Loader2 className="w-10 h-10 animate-spin text-volt" />
<p className="text-white font-semibold">Analyzing photos…</p>
<p className="text-sm text-[#a0a0a0]">Gemma 4 is extracting specs</p>
</div>
)}
{/* Step: review */}
{step === 'review' && aiResult && (
<div className="max-w-xl space-y-4">
<AIResultReview
result={aiResult}
editedName={editedName}
onNameChange={setEditedName}
/>
<div className="flex gap-3 pt-2">
<Button variant="forest" className="flex-1" onClick={handleDone}>
<CheckCircle2 className="w-4 h-4 mr-2" />
Done — Go to Dashboard
</Button>
<Button variant="secondary" onClick={reset}>
Add Another
</Button>
</div>
{aiResult.queued && (
<p className="text-xs text-yellow-400 text-center">
NetBox was unavailable — item is queued for sync
</p>
)}
</div>
)}
</AppShell>
)
}
```
**4. Update web/src/router.tsx** — replace stub intakeRoute with lazy IntakePage:
Read current router.tsx, then update `intakeRoute` component:
```typescript
const IntakePage = lazy(() => import('./pages/IntakePage').then(m => ({ default: m.IntakePage })))
const intakeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/intake',
component: () => (
<Suspense fallback={<Spinner />}>
<IntakePage />
</Suspense>
),
})
```
Run `npm run build`.
</action>
<verify>
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10</automated>
</verify>
<done>
`npm run build` exits 0. `web/src/pages/IntakePage.tsx` exports `IntakePage`. `web/src/router.tsx` lazy-loads IntakePage for /intake route. All intake components exist with no TypeScript errors.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Intake flow UI at /intake:
- DropZone with drag-drop, camera capture (capture="environment"), max 3 photos
- PhotoPreview thumbnails with remove button per photo
- "Analyze with AI" forest-green button (disabled until ≥1 photo added)
- Spinner while POST /api/intake is in flight
- AI result review: confidence meter (green ≥85%, yellow 60-84%, red &lt;60%), manufacturer/model/category/tags, editable name, ai_notes
- "Done — Go to Dashboard" routes back to / with toast showing HW ID
- "Add Another" resets wizard state
- Error state on backend failure (red card, retry possible)
- Toast on success: "HW-XXXXX added to inventory"
</what-built>
<how-to-verify>
1. Start Go backend: `cd /home/mikkel/homelabby && go run ./cmd/hwlab/...`
2. Start Vite dev server: `cd web && npm run dev`
3. Open http://localhost:5173/intake
Check:
- [ ] DropZone renders with camera icon, "Drag photos or tap to shoot" text
- [ ] DropZone has volt border glow when dragging a file over it
- [ ] "Analyze with AI" button is forest green and disabled with no photos
- [ ] After dropping/selecting a photo: thumbnail appears with X remove button
- [ ] Maximum 3 photos: 4th drop is silently ignored, DropZone shows "Maximum 3 photos reached"
- [ ] Clicking "Analyze with AI" with photos shows spinner state
- [ ] With live Go backend + oMLX: AI result appears with confidence meter, tags, notes
- [ ] Without live AI: error state shows (red card), no crash
- [ ] "Done — Go to Dashboard" navigates to / and shows toast
- [ ] On mobile (390px): single column, DropZone usable, camera capture works
</how-to-verify>
<resume-signal>Type "approved" or describe issues to fix</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → POST /api/intake | Multipart file upload crosses here — file content is untrusted |
| AI result → display | AI-extracted strings are displayed in UI |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-11 | Tampering | submitIntake FormData | accept | File bytes sent directly; no filename manipulation; backend validates count and MIME; server-side hw_id assignment (T-02-13 already mitigated in Phase 2) |
| T-03-12 | Info Disclosure | AIResultReview renders ai_notes | accept | React JSX text rendering is safe; no dangerouslySetInnerHTML used; ai_notes is plain text |
| T-03-13 | DoS | Multiple rapid intake submits | mitigate | "Analyze" button is disabled during `step === 'submitting'` — prevents duplicate concurrent POSTs from the same session |
</threat_model>
<verification>
1. `cd web && npm run build` — exits 0
2. `grep "IntakePage" web/src/router.tsx` — lazy import present
3. `grep "submitIntake" web/src/pages/IntakePage.tsx` — confirms API wiring
4. `grep "capture" web/src/components/intake/DropZone.tsx` — confirms camera capture attribute
5. Visual verification via checkpoint task
</verification>
<success_criteria>
- Photo upload via drag-drop or file picker works with 1-3 photos
- Camera capture attribute present for mobile PWA use
- POST /api/intake called with correct multipart FormData
- AI result displays confidence, category, tags, ai_notes, manufacturer, model
- Success toast shows HW ID; user navigates to dashboard
- Error state renders on backend failure (no crash)
- Spinner shown during network request
- "Add Another" resets wizard to upload step
- Human verification checkpoint passed
</success_criteria>
<output>
After completion, create `.planning/phases/03-dashboard-intake-ui/03-04-SUMMARY.md` following the summary template.
</output>

View file

@ -0,0 +1,690 @@
---
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: false
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: "<link rel='manifest' href='/manifest.json'>"
- 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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md
@.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md
<interfaces>
<!-- @zxing/browser usage (already in package.json from Plan 01) -->
```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 code URL format (from Phase 1 PRD and LBL-01): -->
// 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) } })
<!-- From Plan 01: router export -->
// import { router } from '@/router'
// router.navigate({ to: '/item/$id', params: { id: String(numericId) } })
<!-- From Plan 01: design tokens -->
// bg-canvas, text-volt, bg-near-black, border-charcoal/80
// Button variant="forest" for primary actions
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: PWA manifest, service worker, icons, and registration hook</name>
<files>
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
</files>
<action>
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 `<link rel="manifest" href="/manifest.json" />` inside `<head>`.
Add `<meta name="theme-color" content="#faff69" />` inside `<head>` if not already present.
Run `npm run build`.
</action>
<verify>
<automated>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')"</automated>
</verify>
<done>
`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`.
</done>
</task>
<task type="auto">
<name>Task 2: QR scanner page with @zxing/browser and router wiring</name>
<files>
web/src/pages/ScanPage.tsx,
web/src/router.tsx
</files>
<action>
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 `<video>` element
3. On QR decode: parse text to extract HW ID or item URL
4. Lookup item ID from inventory: `GET /api/inventory`, find by `hw_id` match
5. Navigate to `/item/:id`
QR text parsing:
- If text matches `/hw/(HW-\d+)/i` or just `HW-\d+` → extract HW ID
- Look up numeric NetBox ID by matching hw_id in the inventory list
- Navigate to `/item/$id`
```typescript
import { useEffect, useRef, useState, useCallback } from 'react'
import { BrowserQRCodeReader, type IScannerControls } from '@zxing/browser'
import { useNavigate } from '@tanstack/react-router'
import { AppShell } from '@/components/layout/AppShell'
import { Button } from '@/components/ui/button'
import { Camera, Loader2, QrCode, AlertCircle } from 'lucide-react'
import { fetchInventory } from '@/lib/api'
import { useUIStore } from '@/store/ui'
import toast from 'react-hot-toast'
function extractHWID(text: string): string | null {
// Match full URL: http://mac-mini.mg:8080/hw/HW-00001
const urlMatch = text.match(/\/hw\/(HW-\d{5,})/i)
if (urlMatch) return urlMatch[1].toUpperCase()
// Match bare HW ID: HW-00001
const bareMatch = text.match(/^(HW-\d{5,})$/i)
if (bareMatch) return bareMatch[1].toUpperCase()
return null
}
type ScanState = 'idle' | 'requesting' | 'scanning' | 'resolving' | 'error'
export function ScanPage() {
const navigate = useNavigate()
const videoRef = useRef<HTMLVideoElement>(null)
const controlsRef = useRef<IScannerControls | null>(null)
const readerRef = useRef<BrowserQRCodeReader | null>(null)
const { setScannerActive } = useUIStore()
const [scanState, setScanState] = useState<ScanState>('idle')
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [lastScanned, setLastScanned] = useState<string | null>(null)
const stopScanner = useCallback(() => {
controlsRef.current?.stop()
controlsRef.current = null
setScannerActive(false)
}, [setScannerActive])
// Cleanup on unmount
useEffect(() => () => stopScanner(), [stopScanner])
const startScanner = useCallback(async () => {
setScanState('requesting')
setErrorMsg(null)
try {
const reader = new BrowserQRCodeReader()
readerRef.current = reader
const devices = await BrowserQRCodeReader.listVideoInputDevices()
if (devices.length === 0) {
throw new Error('No camera found on this device')
}
// Prefer rear camera (contains "back" or "environment" in label)
const rear = devices.find((d) =>
/back|rear|environment/i.test(d.label)
)
const deviceId = rear?.deviceId ?? devices[devices.length - 1].deviceId
setScanState('scanning')
setScannerActive(true)
const controls = await reader.decodeFromVideoDevice(
deviceId,
videoRef.current!,
async (result, _err) => {
if (!result) return
const text = result.getText()
const hwid = extractHWID(text)
if (!hwid) {
// Not an HWLab QR code — ignore
return
}
// Debounce: skip if same code scanned recently
if (hwid === lastScanned) return
setLastScanned(hwid)
controls.stop()
setScanState('resolving')
try {
const items = await fetchInventory()
const item = items.find(
(i) => i.hw_id?.toUpperCase() === hwid || i.asset_tag?.toUpperCase() === hwid
)
if (!item) {
toast.error(`${hwid} not found in inventory`)
setScanState('scanning')
// Restart scanner
startScanner()
return
}
stopScanner()
navigate({ to: '/item/$id', params: { id: String(item.id) } })
} catch (e) {
toast.error('Failed to look up item')
setScanState('scanning')
startScanner()
}
}
)
controlsRef.current = controls
} catch (e) {
setErrorMsg((e as Error).message)
setScanState('error')
setScannerActive(false)
}
}, [lastScanned, navigate, setScannerActive, stopScanner])
return (
<AppShell>
<div className="mb-6">
<h1 className="font-display font-black text-3xl text-white mb-1">Scan QR Code</h1>
<p className="text-sm text-[#a0a0a0]">Point camera at an HWLab label to open the item</p>
</div>
<div className="max-w-sm mx-auto space-y-4">
{/* Camera viewfinder */}
<div className="relative aspect-square bg-near-black rounded-card overflow-hidden border border-charcoal/80">
<video
ref={videoRef}
className="w-full h-full object-cover"
playsInline
muted
/>
{/* Scan reticle overlay */}
{scanState === 'scanning' && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-48 h-48 border-2 border-volt rounded-sharp opacity-80">
{/* Corner brackets */}
<div className="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-volt" />
<div className="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-volt" />
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-volt" />
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-volt" />
</div>
</div>
)}
{/* Idle state overlay */}
{scanState === 'idle' && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-[#a0a0a0]">
<QrCode className="w-16 h-16" />
<p className="text-sm">Camera not started</p>
</div>
)}
{/* Loading */}
{(scanState === 'requesting' || scanState === 'resolving') && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-canvas/80">
<Loader2 className="w-10 h-10 animate-spin text-volt" />
<p className="text-sm text-white">
{scanState === 'requesting' ? 'Starting camera…' : 'Looking up item…'}
</p>
</div>
)}
{/* Error */}
{scanState === 'error' && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
<AlertCircle className="w-10 h-10 text-red-400" />
<p className="text-sm text-red-400">{errorMsg}</p>
</div>
)}
</div>
{/* Actions */}
{scanState === 'idle' || scanState === 'error' ? (
<Button variant="forest" className="w-full" onClick={startScanner}>
<Camera className="w-4 h-4 mr-2" />
{scanState === 'error' ? 'Try Again' : 'Start Camera'}
</Button>
) : scanState === 'scanning' ? (
<Button variant="secondary" className="w-full" onClick={stopScanner}>
Stop Scanner
</Button>
) : null}
<p className="text-xs text-center text-[#585858]">
Scans QR codes printed on HWLab labels (HW-XXXXX format)
</p>
</div>
</AppShell>
)
}
```
**Update web/src/router.tsx** — replace stub scanRoute with lazy ScanPage:
Read current `web/src/router.tsx`, then update `scanRoute`:
```typescript
const ScanPage = lazy(() => import('./pages/ScanPage').then(m => ({ default: m.ScanPage })))
const scanRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/scan',
component: () => (
<Suspense fallback={<Spinner />}>
<ScanPage />
</Suspense>
),
})
```
Run `npm run build`.
</action>
<verify>
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10</automated>
</verify>
<done>
`npm run build` exits 0 with no TypeScript errors. `web/src/pages/ScanPage.tsx` exports `ScanPage`. `web/src/router.tsx` lazy-loads ScanPage. `extractHWID` function handles both URL and bare HW-ID formats.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
PWA setup and QR scanner:
- web/public/manifest.json — installable PWA config (name, theme_color=#faff69, display=standalone, icons)
- web/public/sw.js — app shell cache (API calls always network-only, navigation uses cache-first)
- web/public/icons/ — 192px and 512px PNG icons (black canvas, volt "H" monogram)
- web/src/pages/ScanPage.tsx — /scan route with live camera, volt reticle overlay, HW ID parsing
- Service worker registered on app load via usePWA() hook
</what-built>
<how-to-verify>
**PWA installability (Chrome on Android or Chrome DevTools > Application):**
1. Build: `cd web && npm run build`
2. Start Go backend (which serves the built SPA): `go run ./cmd/hwlab/...`
3. Open http://mac-mini.mg:8080 in Chrome
4. Open DevTools > Application > Manifest
- [ ] Manifest loads with correct name, theme_color #faff69
- [ ] Icons show as 192px and 512px (may be the simple monogram placeholder)
- [ ] No manifest errors in DevTools
5. DevTools > Application > Service Workers
- [ ] sw.js registered and status is "Activated and is running"
6. On Android Chrome: address bar shows install banner or + icon to "Add to Home screen"
**QR scanner (http://localhost:5173/scan or installed PWA):**
1. Navigate to /scan
- [ ] "Scan QR Code" heading, gray QR code icon, "Start Camera" forest green button
2. Click "Start Camera"
- [ ] Camera permission dialog appears
- [ ] After permission: live video feed shows in viewfinder, volt reticle overlay appears
3. Point camera at any QR code (or print test): `http://mac-mini.mg:8080/hw/HW-00001`
- [ ] Toast appears on match if item exists in NetBox, navigates to /item/:id
- [ ] Toast "HW-00001 not found in inventory" if item doesn't exist yet
4. Test "Stop Scanner" button stops camera and returns to idle state
**Mobile layout (390px):**
- [ ] /scan viewfinder is square and fills the screen width
- [ ] "Start Camera" button is full-width, tap-friendly
</how-to-verify>
<resume-signal>Type "approved" or describe issues to fix</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Camera → QR decode | Raw camera frames decoded by @zxing — output is untrusted text |
| QR text → navigation | Decoded QR text is parsed before any navigation or API call |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-14 | Tampering | QR text → extractHWID | mitigate | `extractHWID` regex only matches `HW-\d{5,}` pattern; arbitrary text cannot trigger navigation; lookup uses numeric NetBox ID from server-returned data, not from QR content directly |
| T-03-15 | Info Disclosure | Camera stream | accept | Camera stream is local to the device; no frames are sent to the server; @zxing decodes frames locally |
| T-03-16 | Tampering | Service worker cache | accept | SW caches only app shell (static assets); API responses are explicitly excluded from cache (network-only path); no risk of stale auth or inventory data |
| T-03-17 | DoS | Rapid QR scan triggers | mitigate | `lastScanned` state debounces repeated scans of the same QR code; scanner stops before resolving item |
</threat_model>
<verification>
1. `cd web && npm run build` — exits 0
2. `ls web/public/manifest.json web/public/sw.js web/public/icons/icon-192.png web/public/icons/icon-512.png` — all exist
3. `node -e "require('fs').readFileSync('web/public/manifest.json'); console.log('manifest valid JSON')"` — parses without error
4. `grep "ScanPage" web/src/router.tsx` — lazy import present
5. `grep "extractHWID" web/src/pages/ScanPage.tsx` — HW ID parsing function present
6. Visual/functional verification via checkpoint task
</verification>
<success_criteria>
- manifest.json loads in Chrome DevTools with no errors, display=standalone, theme_color=#faff69
- Service worker registered and active (DevTools > Application > Service Workers)
- PWA "Add to Home Screen" installable on Android Chrome (PWA-01)
- /scan activates rear camera and scans QR codes in real-time
- Decoded HW-XXXXX QR navigates to /item/:id (PWA-03)
- QR text parsing handles both URL format (http://.../hw/HW-00001) and bare HW-ID
- Camera permission denied handled gracefully (error state, not crash)
- Human verification checkpoint passed
</success_criteria>
<output>
After completion, create `.planning/phases/03-dashboard-intake-ui/03-05-SUMMARY.md` following the summary template.
</output>