920 lines
24 KiB
Markdown
920 lines
24 KiB
Markdown
# 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`.
|