kammer/CLAUDE.md

920 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SolidHaus — Claude Code Implementation Guide
This document provides step-by-step implementation instructions for Claude Code.
## Overview
- **Framework**: SvelteKit 2 (Svelte 5 with runes) + Capacitor
- **Goal**: Local-first, multi-user household inventory app with barcode scanning
- **Key deps**: idb, nanoid, bwip-js, @capacitor-mlkit/barcode-scanning, automerge, @inrupt/solid-client
## Priority: Phase 1 — Core MVP
Build the app incrementally. Each task builds on the previous.
---
## Task 1: Project Scaffold
Create a SvelteKit project with Capacitor and Tailwind CSS.
```bash
npx sv create solidhaus
cd solidhaus
# Static adapter for Capacitor
npm install -D @sveltejs/adapter-static
# Capacitor
npm install @capacitor/core @capacitor/cli
npx cap init solidhaus so.toph.solidhaus --web-dir build
npm install @capacitor/android @capacitor/ios
npx cap add android
npx cap add ios
# Barcode scanner
npm install @capacitor-mlkit/barcode-scanning
# Core deps
npm install idb nanoid date-fns bwip-js jspdf
# Solid (Phase 3, but install now)
npm install @inrupt/solid-client @inrupt/solid-client-authn-browser @inrupt/vocab-common-rdf
# Automerge (Phase 2, but install now)
npm install @automerge/automerge @automerge/automerge-repo @automerge/automerge-repo-network-websocket @automerge/automerge-repo-storage-indexeddb
# Dev
npm install -D tailwindcss @tailwindcss/vite
```
### svelte.config.js:
```javascript
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
fallback: 'index.html' // SPA mode for Capacitor
})
}
};
```
### src/routes/+layout.ts:
```typescript
export const ssr = false;
export const prerender = false;
```
### vite.config.ts:
```typescript
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
tailwindcss(),
sveltekit(),
],
});
```
### capacitor.config.ts:
```typescript
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'so.toph.solidhaus',
appName: 'SolidHaus',
webDir: 'build',
server: {
androidScheme: 'https'
}
};
export default config;
```
### File structure:
Follow the project structure defined in PROJECT_SPECIFICATION.md §12.
---
## Task 2: Data Layer (IndexedDB)
Create `src/lib/data/db.ts` using the `idb` library.
### Database Schema:
```typescript
import { openDB, DBSchema } from 'idb';
interface SolidHausDB extends DBSchema {
items: {
key: string; // shortId
value: {
shortId: string; // 7-char nanoid
name: string;
description: string;
category: string;
brand: string;
serialNumber: string;
color: string;
purchaseDate: string | null;
// Item type
itemType: 'durable' | 'consumable' | 'disposable' | 'perishable'
| 'media' | 'clothing' | 'document' | 'container';
// Quantity (consumables/perishables)
currentQuantity: number | null;
originalQuantity: number | null;
quantityUnit: string | null;
lowThreshold: number | null;
expiryDate: string | null;
// Barcode
barcodeFormat: 'qr' | 'datamatrix' | 'code128';
barcodeUri: string;
photoIds: string[];
// Location tracking
lastSeenAt: string | null;
lastSeenTimestamp: string | null;
lastUsedAt: string | null;
supposedToBeAt: string | null;
locationConfidence: 'confirmed' | 'likely' | 'assumed' | 'unknown';
// Custody state
custodyState: 'checked-in' | 'checked-out';
checkedOutSince: string | null;
checkedOutReason: 'in-use' | 'in-transit' | 'lent' | 'in-repair' | 'temporary' | 'consumed' | null;
checkedOutFrom: string | null;
checkedOutTo: string | null;
checkedOutNote: string | null;
// Storage tier
storageTier: 'hot' | 'warm' | 'cold';
storageContainerId: string | null;
storageContainerLabel: string | null;
// Label
labelPrinted: boolean;
labelPrintedAt: string | null;
labelBatchId: string | null;
// Metadata
createdAt: string;
updatedAt: string;
tags: string[];
createdBy: string | null;
};
indexes: {
'by-name': string;
'by-category': string;
'by-location': string;
'by-type': string;
'by-custody': string;
};
};
locations: {
key: string;
value: {
id: string;
name: string;
description: string;
parentId: string | null;
locationType: 'house' | 'floor' | 'room' | 'furniture' | 'shelf' | 'drawer' | 'box' | 'wall' | 'outdoor';
defaultStorageTier: 'hot' | 'warm' | 'cold' | null;
sortOrder: number;
createdAt: string;
updatedAt: string;
};
indexes: {
'by-parent': string;
'by-name': string;
};
};
sightings: {
key: string;
value: {
id: string;
itemId: string;
locationId: string;
timestamp: string;
sightingType: 'scan' | 'manual' | 'camera-detect' | 'audit-verify';
confidence: 'confirmed' | 'inferred' | 'assumed';
notes: string;
createdBy: string | null;
};
indexes: {
'by-item': string;
'by-location': string;
'by-timestamp': string;
};
};
photos: {
key: string;
value: {
id: string;
itemId: string;
blob: Blob;
thumbnail: Blob | null;
createdAt: string;
};
indexes: {
'by-item': string;
};
};
preGeneratedIds: {
key: string;
value: {
id: string;
generatedAt: string;
assignedTo: string | null;
batchId: string;
};
indexes: {
'by-batch': string;
'by-assigned': string;
};
};
settings: {
key: string;
value: {
key: string;
value: unknown;
};
};
}
const DB_NAME = 'solidhaus';
const DB_VERSION = 1;
export async function getDB() {
return openDB<SolidHausDB>(DB_NAME, DB_VERSION, {
upgrade(db) {
// Items
const itemStore = db.createObjectStore('items', { keyPath: 'shortId' });
itemStore.createIndex('by-name', 'name');
itemStore.createIndex('by-category', 'category');
itemStore.createIndex('by-location', 'lastSeenAt');
itemStore.createIndex('by-type', 'itemType');
itemStore.createIndex('by-custody', 'custodyState');
// Locations
const locStore = db.createObjectStore('locations', { keyPath: 'id' });
locStore.createIndex('by-parent', 'parentId');
locStore.createIndex('by-name', 'name');
// Sightings
const sightStore = db.createObjectStore('sightings', { keyPath: 'id' });
sightStore.createIndex('by-item', 'itemId');
sightStore.createIndex('by-location', 'locationId');
sightStore.createIndex('by-timestamp', 'timestamp');
// Photos
const photoStore = db.createObjectStore('photos', { keyPath: 'id' });
photoStore.createIndex('by-item', 'itemId');
// Pre-generated IDs
const pregenStore = db.createObjectStore('preGeneratedIds', { keyPath: 'id' });
pregenStore.createIndex('by-batch', 'batchId');
pregenStore.createIndex('by-assigned', 'assignedTo');
// Settings
db.createObjectStore('settings', { keyPath: 'key' });
},
});
}
```
### CRUD Functions:
Create `src/lib/data/items.ts`, `locations.ts`, `sightings.ts`, `labels.ts` with standard CRUD:
- `getAll()`, `getById()`, `create()`, `update()`, `remove()`
- Each function gets the DB instance via `getDB()` and performs the operation.
### ID Generator:
```typescript
// src/lib/utils/id.ts
import { customAlphabet } from 'nanoid';
const ALPHABET = '23456789abcdefghjkmnpqrstuvwxyz';
export const generateItemId = customAlphabet(ALPHABET, 7);
export const generateSightingId = () => `s_${generateItemId()}`;
```
---
## Task 3: Barcode Scanner Component
Create `src/lib/components/Scanner.svelte`.
### Requirements:
- On native (Capacitor): use @capacitor-mlkit/barcode-scanning
- On web: use Barcode Detection API with ZXing fallback
- Detect QR Code, Code 128, DataMatrix, EAN-13
- Parse detected value through `parseItemId()`
- Haptic feedback on successful scan (Capacitor Haptics)
### Implementation approach:
```typescript
// src/lib/scanning/detector.ts
import { Capacitor } from '@capacitor/core';
import { BarcodeScanner, BarcodeFormat } from '@capacitor-mlkit/barcode-scanning';
import { parseItemId } from './parser';
export async function scanBarcode(): Promise<string | null> {
if (Capacitor.isNativePlatform()) {
// Native: use ML Kit
const { barcodes } = await BarcodeScanner.scan({
formats: [BarcodeFormat.QrCode, BarcodeFormat.Code128, BarcodeFormat.DataMatrix],
});
for (const barcode of barcodes) {
const id = parseItemId(barcode.rawValue);
if (id) return id;
}
return null;
} else {
// Web: use Barcode Detection API
return scanWithWebAPI();
}
}
async function scanWithWebAPI(): Promise<string | null> {
if (!('BarcodeDetector' in window)) {
// Load ZXing fallback
// Use html5-qrcode library
return null;
}
const detector = new BarcodeDetector({
formats: ['qr_code', 'code_128', 'data_matrix'],
});
// Get camera stream
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }
});
// Create video element, detect from frames
// ... (continuous detection loop at ~10fps)
return null;
}
```
### ID Parsing:
```typescript
// src/lib/scanning/parser.ts
const VALID_CHARS = /^[23456789a-hjkmnp-z]{7}$/;
export function parseItemId(rawValue: string): string | null {
// HTTPS URI
const httpsMatch = rawValue.match(/haus\.toph\.so\/([23456789a-hjkmnp-z]{7})$/);
if (httpsMatch) return httpsMatch[1];
// Custom scheme
const schemeMatch = rawValue.match(/^haus:\/\/([23456789a-hjkmnp-z]{7})$/);
if (schemeMatch) return schemeMatch[1];
// Raw ID
if (VALID_CHARS.test(rawValue)) return rawValue;
return null;
}
```
---
## Task 4: Item Management
### ItemForm Component (`src/lib/components/ItemForm.svelte`)
Fields: name, description, category, brand, serialNumber, color, purchaseDate, itemType, location, storageTier.
For consumable/perishable types, show additional fields: currentQuantity, originalQuantity, quantityUnit, lowThreshold, expiryDate.
Auto-generate shortId on create (or pre-fill if associating a pre-printed label).
### ItemDetail Component (`src/lib/components/ItemCard.svelte`)
Show: name, category, custody badge, location, confidence badge, storage tier, sighting history. For consumables: quantity bar with percentage.
Actions: Check In/Out (contextual), Edit, Print Label, View History.
### ItemList Component (`src/routes/items/+page.svelte`)
Filterable list: search, category filter, custody state filter, type filter. Sort by name, last seen, custody state.
---
## Task 5: Location Management
### LocationTree Component (`src/lib/components/LocationTree.svelte`)
Collapsible tree view using `parentId` relationships. Show item count per location. Tap to browse items in that location.
### LocationPicker Component (`src/lib/components/LocationPicker.svelte`)
Used in forms and scan flows. Shows tree with radio selection. Quick-access "recent locations" at top.
### Default Locations:
Pre-populate on first run:
```typescript
const DEFAULT_LOCATIONS = [
{ id: 'home', name: 'Home', parentId: null, type: 'house' },
{ id: 'eg', name: 'Erdgeschoss', parentId: 'home', type: 'floor' },
{ id: 'og', name: 'Obergeschoss', parentId: 'home', type: 'floor' },
{ id: 'keller', name: 'Keller', parentId: 'home', type: 'floor' },
{ id: 'kueche', name: 'Küche', parentId: 'eg', type: 'room' },
{ id: 'wohnzimmer', name: 'Wohnzimmer', parentId: 'eg', type: 'room' },
{ id: 'flur', name: 'Flur', parentId: 'eg', type: 'room' },
{ id: 'schlafzimmer', name: 'Schlafzimmer', parentId: 'og', type: 'room' },
{ id: 'buero', name: 'Büro', parentId: 'og', type: 'room' },
{ id: 'bad', name: 'Bad', parentId: 'og', type: 'room' },
{ id: 'werkstatt', name: 'Werkstatt', parentId: 'keller', type: 'room' },
];
```
---
## Task 6: Label Sheet Generation
### Label Sheet PDF (`src/lib/printing/labelSheet.ts`)
Generate A4 PDF of QR code stickers:
```typescript
import bwipjs from 'bwip-js';
import jsPDF from 'jspdf';
export async function generateLabelSheetPDF(ids: string[]): Promise<Blob> {
const doc = new jsPDF('p', 'mm', 'a4');
const COLS = 5;
const ROWS = 10;
const CELL_W = 38; // mm
const CELL_H = 27; // mm
const MARGIN_X = 8;
const MARGIN_Y = 10;
const QR_SIZE = 18; // mm
for (let i = 0; i < ids.length; i++) {
const col = i % COLS;
const row = Math.floor(i / COLS) % ROWS;
const page = Math.floor(i / (COLS * ROWS));
if (i > 0 && i % (COLS * ROWS) === 0) {
doc.addPage();
}
const x = MARGIN_X + col * CELL_W;
const y = MARGIN_Y + row * CELL_H;
// Generate QR code as PNG
const canvas = document.createElement('canvas');
bwipjs.toCanvas(canvas, {
bcid: 'qrcode',
text: `https://haus.toph.so/${ids[i]}`,
scale: 3,
includetext: false,
});
const qrDataUrl = canvas.toDataURL('image/png');
doc.addImage(qrDataUrl, 'PNG', x + 1, y + 1, QR_SIZE, QR_SIZE);
// ID text
doc.setFontSize(8);
doc.setFont('courier', 'normal');
doc.text(ids[i], x + QR_SIZE + 2, y + QR_SIZE / 2 + 1);
}
return doc.output('blob');
}
```
### Print Server Client (`src/lib/printing/printServer.ts`)
```typescript
export interface PrintServerConfig {
baseUrl: string; // e.g., "http://printer.local:3030"
}
export async function printLabels(
config: PrintServerConfig,
labels: { id: string; uri: string }[],
format: 'sheet' | 'strip' = 'sheet',
): Promise<void> {
const response = await fetch(`${config.baseUrl}/api/print`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ labels, format }),
});
if (!response.ok) {
throw new Error(`Print failed: ${response.statusText}`);
}
}
```
---
## Task 7: App Shell & Navigation
### Layout (`src/routes/+layout.svelte`)
```svelte
<script lang="ts">
import BottomNav from '$lib/components/BottomNav.svelte';
import { page } from '$app/stores';
let { children } = $props();
</script>
<div class="flex flex-col h-screen bg-slate-900 text-white">
<main class="flex-1 overflow-y-auto pb-16">
{@render children()}
</main>
<BottomNav currentPath={$page.url.pathname} />
</div>
```
### BottomNav (`src/lib/components/BottomNav.svelte`)
```svelte
<script lang="ts">
let { currentPath = '/' } = $props();
const tabs = [
{ path: '/scan', icon: '📷', label: 'Scan' },
{ path: '/items', icon: '📦', label: 'Items' },
{ path: '/locations', icon: '📍', label: 'Places' },
{ path: '/labels', icon: '🏷️', label: 'Labels' },
{ path: '/settings', icon: '⋯', label: 'More' },
];
</script>
<nav class="fixed bottom-0 left-0 right-0 bg-slate-800 border-t border-slate-700 flex justify-around py-2 z-50">
{#each tabs as tab}
<a
href={tab.path}
class="flex flex-col items-center px-3 py-1 text-xs
{currentPath.startsWith(tab.path) ? 'text-blue-400' : 'text-slate-400'}"
>
<span class="text-xl">{tab.icon}</span>
<span>{tab.label}</span>
</a>
{/each}
</nav>
```
---
## Task 8: State Management (Svelte 5 Runes)
Use Svelte 5 runes for reactive state. Create stores in `src/lib/stores/`:
```typescript
// src/lib/stores/inventory.svelte.ts
import { getDB } from '$lib/data/db';
class InventoryStore {
items = $state<Item[]>([]);
locations = $state<Location[]>([]);
loading = $state(true);
checkedOutItems = $derived(
this.items.filter(i => i.custodyState === 'checked-out')
);
overdueItems = $derived(
this.items.filter(i =>
i.custodyState === 'checked-out' &&
i.checkedOutSince &&
Date.now() - new Date(i.checkedOutSince).getTime() > 7 * 86400000
)
);
lowStockItems = $derived(
this.items.filter(i =>
i.currentQuantity != null &&
i.lowThreshold != null &&
i.currentQuantity <= i.lowThreshold
)
);
async loadAll() {
const db = await getDB();
this.items = await db.getAll('items');
this.locations = await db.getAll('locations');
this.loading = false;
}
async createItem(item: Item) {
const db = await getDB();
await db.put('items', item);
this.items = [...this.items, item];
}
async updateItem(shortId: string, updates: Partial<Item>) {
const db = await getDB();
const existing = await db.get('items', shortId);
if (!existing) return;
const updated = { ...existing, ...updates, updatedAt: new Date().toISOString() };
await db.put('items', updated);
this.items = this.items.map(i => i.shortId === shortId ? updated : i);
}
}
export const inventory = new InventoryStore();
```
---
## Design Guidelines
### Color Palette (Dark theme, slate-based):
- `bg-slate-900` — main background
- `bg-slate-800` — cards, surfaces
- `bg-slate-700` — hover, borders
- `text-blue-400` — primary actions
- `text-emerald-400` — confirmed, checked in
- `text-amber-400` — likely, warning
- `text-red-400` — unknown, danger
### Confidence Badges:
- Confirmed → `bg-emerald-500/20 text-emerald-400` + ✓
- Likely → `bg-amber-500/20 text-amber-400` + ◷
- Assumed → `bg-slate-500/20 text-slate-400` + ?
- Unknown → `bg-red-500/20 text-red-400` + !
### Mobile Considerations:
- Touch targets: min 44×44px
- Bottom sheet modals for scan results
- Haptic feedback on scan
- Skeleton loading states
- Monospace font for IDs: `font-mono text-lg tracking-wider`
---
## Testing Approach
### Unit Tests:
- ID generation (uniqueness, format validation)
- ID parsing (HTTPS, haus://, raw)
- Confidence decay logic
- Quantity formatting
### Integration Tests:
- IndexedDB CRUD operations
- Check-in/check-out state transitions
- Pre-generated ID → item association
### E2E Tests (Playwright):
- Create item flow
- Scan → check out → check in cycle
- Generate label sheet
- Search and filter
---
## Non-Functional Requirements
- First paint < 1s on mobile
- Scanner frame processing ≥ 10fps
- IndexedDB operations < 50ms
- Full offline capability (all features work without network)
- Multi-device sync via Automerge (eventual consistency)
- Accessible (ARIA labels, screen reader support)
---
## Key Algorithms
### Confidence Decay
```typescript
export function getConfidence(lastSeenTimestamp: string | null): ConfidenceLevel {
if (!lastSeenTimestamp) return 'unknown';
const daysSince = differenceInDays(new Date(), new Date(lastSeenTimestamp));
if (daysSince <= 30) return 'confirmed';
if (daysSince <= 90) return 'likely';
if (daysSince <= 180) return 'assumed';
return 'unknown';
}
```
### Sighting Processing
```typescript
async function processSighting(itemId: string, locationId: string, type: SightingMethod) {
const now = new Date().toISOString();
await createSighting({ id: generateSightingId(), itemId, locationId, timestamp: now, sightingType: type, confidence: 'confirmed', notes: '' });
await updateItem(itemId, { lastSeenAt: locationId, lastSeenTimestamp: now, locationConfidence: 'confirmed', updatedAt: now });
}
```
### Check-Out Processing
```typescript
async function processCheckOut(itemId: string, reason: CheckOutReason, fromLocationId: string, toLocationId: string | null, note = '') {
const now = new Date().toISOString();
await updateItem(itemId, {
custodyState: 'checked-out',
checkedOutSince: now,
checkedOutReason: reason,
checkedOutFrom: fromLocationId,
checkedOutTo: toLocationId,
checkedOutNote: note,
lastSeenAt: toLocationId ?? fromLocationId,
lastSeenTimestamp: now,
locationConfidence: 'confirmed',
});
}
```
### Check-In Processing
```typescript
async function processCheckIn(itemId: string, returnToLocationId: string) {
const now = new Date().toISOString();
await updateItem(itemId, {
custodyState: 'checked-in',
checkedOutSince: null,
checkedOutReason: null,
checkedOutFrom: null,
checkedOutTo: null,
checkedOutNote: null,
lastSeenAt: returnToLocationId,
lastSeenTimestamp: now,
locationConfidence: 'confirmed',
supposedToBeAt: returnToLocationId,
});
}
```
### Dashboard Queries
```typescript
function getCheckedOutItems(): Item[] {
return allItems.filter(i => i.custodyState === 'checked-out');
}
function getOverdueItems(maxDays = 7): Item[] {
const cutoff = subDays(new Date(), maxDays);
return allItems.filter(i => i.custodyState === 'checked-out' && i.checkedOutSince && new Date(i.checkedOutSince) < cutoff);
}
function getLentItems(): Item[] {
return allItems.filter(i => i.custodyState === 'checked-out' && i.checkedOutReason === 'lent');
}
function getItemsInContainer(containerId: string): Item[] {
return allItems.filter(i => i.storageContainerId === containerId);
}
```
---
## Forgejo PR Workflow
Every substantive session is tracked in `.claude/request-log.jsonl` and backed by a Forgejo PR.
### When to create a branch + PR
Create a branch and PR for:
- New features (`feat/`)
- Bug fixes (`fix/`)
- Refactors (`refactor/`)
- Dependency or config changes (`chore/`)
Skip branching/PR for:
- Questions, explanations, code reading
- Trivial one-liner fixes that are best committed directly to the current branch
### Branch naming
```
feat/YYYYMMDD-short-kebab-description
fix/YYYYMMDD-short-kebab-description
chore/YYYYMMDD-short-kebab-description
refactor/YYYYMMDD-short-kebab-description
```
Check the current branch first:
```bash
git rev-parse --abbrev-ref HEAD
```
If already on a feature branch from this session, continue on it. Only create a new branch for a new distinct task.
### Workflow for each task
1. **Create branch** (if needed):
```bash
git checkout -b feat/$(date +%Y%m%d)-short-description
```
2. **Do the work** — write code, run tests, iterate.
3. **Commit** with conventional commit messages:
```bash
git add <files>
git commit -m "feat: short description"
```
4. **Push**:
```bash
git push -u origin HEAD
```
5. **Create PR**:
```bash
bash .claude/scripts/create-pr.sh \
"feat: short description" \
"$(cat <<'EOF'
## Summary
- What was done and why
## Test plan
- [ ] Manual test steps
🤖 Generated with Claude Code
EOF
)"
```
This also updates `.claude/request-log.jsonl` with the PR number.
6. **Commit the updated request log** (as part of the PR or separately):
```bash
git add .claude/request-log.jsonl
git commit -m "chore: log request #<req-id>"
```
### Show history
When the user asks for history, recent work, or what's been done:
```bash
bash .claude/scripts/show-history.sh
bash .claude/scripts/show-history.sh --filter open
bash .claude/scripts/show-history.sh --last 10
```
### tea CLI quick reference
```bash
tea pr list # list open PRs
tea pr view <number> # view a PR
tea pr merge <number> # merge a PR
tea issue create # create an issue
tea repo info # show repo details
tea login list # show configured Forgejo instances
```
### Request log format
`.claude/request-log.jsonl` — one JSON object per line, git-tracked:
```json
{
"id": "req_20260226_143012_a3f9b",
"timestamp": "2026-02-26T14:30:12Z",
"prompt": "Add dark mode support",
"session": "abc123",
"branch": "feat/20260226-dark-mode",
"pr_number": 42,
"pr_url": "https://forge.example.com/user/repo/pulls/42",
"status": "open"
}
```
Possible statuses: `pending` (no PR yet), `open`, `merged`, `closed`.