kammer/CLAUDE.md

24 KiB
Raw Blame History

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.

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:

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:

export const ssr = false;
export const prerender = false;

vite.config.ts:

import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    tailwindcss(),
    sveltekit(),
  ],
});

capacitor.config.ts:

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:

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:

// 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:

// 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:

// 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:

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:

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)

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)

<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)

<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/:

// 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

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

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

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

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

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:

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):

    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:

    git add <files>
    git commit -m "feat: short description"
    
  4. Push:

    git push -u origin HEAD
    
  5. Create PR:

    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):

    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 .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

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:

{
  "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.