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
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
**Plans**: TBD
**UI hint**: yes
**Plans**: 5 plans
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
**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 |
| 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 | - |
| 5. Cable Test Integration | 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>