Ka-Note/plans/vision-inventory.md

869 lines
40 KiB
Markdown
Raw 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.

# Vision / Inventory Feature
## Übersicht
Hausinventar verwalten: Gegenstände per Kamera oder Barcode erfassen,
KI erkennt Titel + Kategorie, Raum-Zuordnung, vollständige Asset-Pflege.
---
## Datenmodell
### `rooms`
```
id, userId, name, groupType ('living'|'functional'|'outdoor'),
icon (string, lucide icon name, z.B. 'sofa'|'monitor'|'bath'|...),
sortOrder, updatedAt, deletedAt, purgedAt, version
```
### `assets`
```
id, userId, roomId, title, category,
status ('draft'|'complete'),
condition ('new'|'good'|'fair'|'poor'|null),
brand, model, serialNumber,
purchasePrice (real|null), purchaseYear (integer|null),
notes (markdown|null), coverImageId (→ imageBlobs.id|null),
updatedAt, deletedAt, purgedAt, version
```
`coverImageId` = Primärbild (erstes aufgenommenes Foto). Weitere Bilder in `assetImages`.
Cover kann auf Detailseite durch ein `assetImages`-Bild ersetzt werden (→ `updateAsset({ coverImageId })`).
### `assetImages`
```
id, userId, assetId, imageId (→ imageBlobs.id),
sortOrder, updatedAt, deletedAt, purgedAt, version
```
Bilder werden **nicht** lokal gecacht — `imageId` referenziert Server-seitigen Blob.
### `assetPersons` — Zuordnung Asset ↔ Person
```
id, userId, assetId, personId (→ contexts.id, wo type='person'),
updatedAt, deletedAt, purgedAt, version
```
Personen des Typs `family` (PersonSubType aus `contexts.meta.personSubType`).
Mehrere Personen pro Asset möglich (z.B. "gehört Mama und Papa").
`imageBlobs` — bereits vorhanden, wird wiederverwendet.
### `user_settings` (für Vision-API-Keys)
```
userId, key, value (JSON), updatedAt
PRIMARY KEY (userId, key)
```
### `vision_usage` (Rate Limiting)
```
userId, date (YYYY-MM-DD), count
PRIMARY KEY (userId, date)
```
---
## Erfassungs-Modi
### Foto-Modus (Loop)
```
[📷 Foto-Modus] → Raum wählen (einmalig)
→ Loop: Foto → KI-Erkennung → LabelConfirm → createAsset(draft) → "Nächstes"
→ [Fertig] → Dashboard
```
### Barcode-Modus (Loop)
```
[🔍 Barcode-Modus] → Raum wählen (einmalig)
→ Loop: Barcode scannen → Open Food Facts → LabelConfirm → createAsset(draft) → "Nächster"
→ [Fertig] → Dashboard
```
### Manuell (Einzel)
```
[+ Manuell] → Raum wählen → Titel eingeben → createAsset(draft) → Detailseite
```
---
## UI-Struktur
```
/inventory Dashboard (Stats + Quick Actions)
/inventory/rooms Raum-Übersicht (3-Spalten-Grid, gruppiert)
/inventory/items Alle Gegenstände (gefiltert, Grid/Liste)
/inventory/capture/photo?roomId= Foto-Loop
/inventory/capture/barcode?roomId= Barcode-Loop
/inventory/[id] Asset-Detailseite
```
### Asset-Liste `/inventory/items` (Mockup)
```
┌──────────────────────────────────────────────────────┐
│ Gegenstände [📷 Fotografieren][+ Manuell]│
│ 1 Gegenstand erfasst │
├──────────────────────────────────────────────────────┤
│ [🔍 Suchen nach Bezeichnung, Marke...] │
│ [📍 Alle Räume ▾] [🏷 Alle Kategorien ▾] [⊙ Alle Status ▾] [⊞][☰]│
├──────────────────────────────────────────────────────┤
│ ▼ 🟢 Ankleidezimmer · Eltern (1) │ ← collapsible Section
│ ┌─────────────┐ │
│ │ [Foto] │ │
│ │ [🛡] 17,00€│ ← Status-Icon links, Preis rechts │
│ └─────────────┘ │
│ Flauschige Decke │
│ 📍 Ankleidezimmer · Eltern │
└──────────────────────────────────────────────────────┘
```
- **Suchfeld**: filtert nach Titel, Marke, Modell (client-seitig, Dexie)
- **Filter-Dropdowns**: Raum, Kategorie, Status (draft/complete) — kombinierbar
- **Grid/Liste-Toggle**: Grid = Foto-Cards; Liste = kompakte Zeilen
- **Sections**: ein collapsible Block pro Raum (leer = ausgeblendet wenn gefiltert)
- **Card**: Cover-Bild, Preis-Badge unten rechts, Status-Icon (Schild) unten links
- **Status-Icon**: 🟢 complete, 🟡 draft (Badge-Farbe an Section-Header)
- Kein eigener Raum → Section "Ohne Raum"
### Räume-Übersicht `/inventory/rooms` (Mockup)
```
┌──────────────────────────────────────────┐
│ Räume [+ Raum anlegen]│
│ Verwalte deine Räume │
├──────────────────────────────────────────┤
│ Wohnbereiche │ ← Gruppen-Header
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │[👕] Ankl.│ │[🖥] Arb. │ │[🍴] Ess. │ │ ← 3-Spalten-Grid
│ │1 Gegenst.│ │0 Gegenst.│ │0 Gegenst.│ │
│ └────── ···┘ └────── ···┘ └────── ···┘ │ ← ··· Context-Menu
├──────────────────────────────────────────┤
│ Funktionsräume │
│ ... │
└──────────────────────────────────────────┘
```
- Click auf Karte → `/inventory/rooms/[roomId]` (Asset-Liste)
- `···` Menu: Umbenennen, Löschen (Soft-Delete)
- Icons: raumspezifisch (lucide), hardcodierte Mapping-Tabelle Name→Icon
- Asset-Zähler: liveQuery auf `assets.roomId`, nicht-deleted
### Dashboard-Layout (Mockup)
```
┌─────────────────────────────────────┐
│ Willkommen! │
│ Übersicht deines Hausinventars │
├─────────────────────────────────────┤
│ 📷 Einfach fotografieren │ ← Primär-CTA (Vollbreite, dunkel)
├──────────────────┬──────────────────┤
│ + Gegenstand │ + Raum │ ← Sekundär (je halbe Breite)
├──────────────────┴──────────────────┤
│ Gegenstände [📦] │ Räume [🏠] │ ← Stats-Row (2-spaltig)
│ 1 │ 24 │
├─────────────────────────────────────┤
│ Gesamtwert [€] │ ← Breite Stats-Card
│ 17,00 € │
│ Der erfasste Gesamtwert gibt dir │
│ eine gute Orientierung für deine │
│ Versicherungssumme. │
├─────────────────────────────────────┤
│ → Räume & Gegenstände anzeigen │ ← Link zu /inventory/rooms
└─────────────────────────────────────┘
```
- "Einfach fotografieren" → RoomSelectSheet → Foto-Loop
- "+ Gegenstand" → RoomSelectSheet → manuell anlegen → Detailseite
- "+ Raum" → inline Modal (Name + Gruppe)
- Stats: `liveQuery` auf `assets` + `rooms` (nicht-deleted, Summe `purchasePrice`)
- Gesamtwert: Summe aller `assets.purchasePrice` (null = nicht eingerechnet)
---
## Architektur-Entscheidung: Sync vs. Online-only
**Metadaten** (`rooms`, `assets`, `assetPersons`, `assetImages`-Referenzen):
→ normaler Push/Pull, lokal in Dexie. Datenmenge trivial (~500 KB bei 500 Gegenständen).
→ Offline-Lesezugriff sinnvoll (Liste/Suche ohne WLAN).
**Bilder — gewählter Ansatz: Server-Upload, kein lokaler Blob**
`storeImage()` schreibt immer in Dexie (kein Bypass). 500 × 5 MB = 2,5 GB lokal — nicht akzeptabel.
→ Neues `uploadInventoryImage(blob): Promise<string>`:
1. Bild client-seitig komprimieren: `canvas.toBlob('image/jpeg', 0.75)` + max-width 1920px → ~300 KB
2. Direkt POST zu `/api/inventory/images` (neuer Endpoint, kein Sync-Push)
3. Server speichert in `imageBlobs`, gibt `imageId` zurück
4. Client speichert nur `imageId` im Asset — kein Dexie-Blob
5. `getImageUrl(id)` fetcht via `/api/sync/blob/{id}` — immer online
**Folge**: Kamera-Erfassung erfordert Online-Verbindung (akzeptabel für PWA mit WLAN).
`storeImage()` bleibt unverändert für alle anderen Bild-Contexts (Editor-Paste etc.).
→ Neuer Server-Endpoint: `POST /api/inventory/images` (multipart, authMiddleware)
**Kein Online-only-Controller für Metadaten nötig** — bisherige Architektur skaliert gut.
---
## Schlüssel-Entscheidungen
- **API-Key pro User** — in Settings-Seite eingeben, AES-256-GCM verschlüsselt in `user_settings`
- **`SETTINGS_ENCRYPTION_KEY`** in `.env` (plain, self-hosted)
- **Rate Limit** — 100 Vision-Calls/Tag pro User, DB-basiert via `vision_usage`
- **Provider** — OpenAI `gpt-4o-mini` (detail:low, ~$0.0003/Bild) oder Gemini `gemini-1.5-flash`
- **Kategorien** — hardcoded Liste, kein eigenes Table:
`null (Keine Kategorie), Elektronik, Möbel, Kleidung, Schmuck, Kunstwerke,
Sportgeräte, Haushaltsgeräte, Haustechnik, Werkzeuge, Musikinstrumente,
Bücher & Medien, Spielzeug, Sammlerstücke, Gartengeräte, Fahrzeugzubehör, Sonstiges`
- **Räume** — eigene Tabelle (kein Kontext-Typ), Soft-Delete, Default-Seed beim ersten Start
- **Status** — `draft` nach Schnellerfassung, `complete` nach Nachbearbeitung
- **Löschung** — Soft-Delete überall, wie Wiki-Pages
- **Personen** — nur `family`-Subtype auswählbar (filter: `contexts.type='person'` + `meta.personSubType='family'`); Zuordnung via `assetPersons`-Tabelle (n:m)
---
## Phasen
### Dependency-Graph
```
Phase 1 (Datenfundament)
├─→ Phase 2 (Vision-Backend) [nur Server, unabhängig von Client-Schema]
├─→ Phase 5 (Settings UI) [benötigt Phase 2 + Phase 1 Client]
└─→ Phase 4 (Inventar-UI) [benötigt Phase 1 vollständig]
└─→ Phase 6 (CommandBar) [benötigt Phase 4 Repositories]
Phase 2 + Phase 5 → Phase 3 (Erfassungs-Flow) [braucht API + UI-Key-Config]
Phase 4 → Phase 7 (Docs) [nach stabilem UI]
```
**Parallelisierung:**
- Phase 2 Schritte 2.12.4: vollständig parallel (je eigene neue Datei)
- Phase 5 + Phase 2.5: parallel nach Phase 2.12.4
- Phase 4 Schritte 4.14.4: parallel (je eigene neue Datei)
- Phase 3 + Phase 4 können nach Phase 1 parallel beginnen (3 benötigt noch Phase 2+5)
---
### Phase 1 — Datenfundament
**Requires:** nichts (Startpunkt)
**Produces:**
- `shared/src/types.ts``Room`, `Asset`, `AssetImage`, `AssetPerson` Types exportiert
- `shared/src/sync.ts``SyncChanges` enthält 4 neue Tabellen
- `server/drizzle/0009_inventory.sql` — Migration ausführbar
- `client/src/lib/db/schema.ts` v15 — Dexie öffnet ohne Fehler
- `client/src/lib/db/repositories.ts` — alle CRUD-Funktionen + `seedDefaultRoomsIfNeeded` exportiert
- Server `sync-service.ts` + `routes/sync.ts` — Push/Pull für 4 neue Entitäten
**Ziel:** Shared Types, Server- und Client-Schema, Repositories, Sync.
#### Schritt 1.1 — Shared Types
- Datei: `shared/src/types.ts`
- `+ Room extends SyncEntity`
- `+ Asset extends SyncEntity` (alle Felder inkl. `status`, `condition`, etc.)
- `+ AssetImage extends SyncEntity`
- `+ AssetPerson extends SyncEntity` (`assetId`, `personId`)
- Datei: `shared/src/sync.ts`
- `SyncChanges`: `+ rooms, assets, assetImages, assetPersons`
- `SYNC_PULL_TABLE_ORDER`: `[..., 'rooms', 'assets', 'assetImages', 'assetPersons']`
- `SYNC_PUSH_TABLE_ORDER`: gleich
#### Schritt 1.2 — Server DB-Schema + Migration
- Datei: `server/src/db/schema.ts`
- + `rooms`, `assets`, `assetImages`, `assetPersons`, `user_settings`, `vision_usage`
- `assetPersons`: FK `(assetId, userId)` → assets + FK `(personId, userId)` → contexts
- Muster: Composite PK `(id, userId)`, Drizzle `sqliteTable`
- Datei: `server/drizzle/0009_inventory.sql` (auto-generiert via `drizzle-kit generate`)
#### Schritt 1.3 — Client Dexie Schema v15
- Datei: `client/src/lib/db/schema.ts`
- Version 15: + `rooms: '&id, groupType, deletedAt'`
- + `assets: '&id, roomId, status, deletedAt'`
- + `assetImages: '&id, assetId, deletedAt'`
- + `assetPersons: '&id, assetId, personId, deletedAt'`
#### Schritt 1.4 — Client Repositories
- Datei: `client/src/lib/db/repositories.ts`
- Room: `createRoom`, `getRooms`, `updateRoom`, `softDeleteRoom`
- Asset: `createAsset`, `getAsset`, `getAssetsByRoom`, `getAssetsAll`, `getAssetsByStatus`, `updateAsset`, `softDeleteAsset`
- `getAssetsAll()` — alle nicht-deleted Assets (für CommandBar + `/inventory/items`)
- `getAssetsByStatus('draft'|'complete')` — für Status-Filter
- AssetImage: `addAssetImage`, `getAssetImages`, `softDeleteAssetImage`
- AssetPerson: `addAssetPerson`, `getAssetPersons`, `removeAssetPerson` (Soft-Delete)
- Helper: `getFamilyPersons()` — filtert `db.contexts` nach `type='person'` + `meta.personSubType='family'`
- Seed-Funktion: `seedDefaultRoomsIfNeeded(userId)` — prüft `settings`-Flag `'inventory.seeded'`
#### Schritt 1.5 — Sync-Erweiterung
- Datei: `server/src/lib/sync-service.ts`
- + `mapRoom`, `mapAsset`, `mapAssetImage`, `mapAssetPerson`
- `pushChanges` + `pullChanges` um 4 Entitäten erweitern
- Datei: `server/src/routes/sync.ts`
- `VALID_TABLES` + `'rooms'`, `'assets'`, `'assetImages'`, `'assetPersons'`
- Datei: `client/src/lib/sync/syncService.ts`
- Push/Pull-Mapping für 4 neue Entitäten
**Verifikation 1:**
- `npm run dev` startet ohne Fehler
- Browser-Console: kein Dexie-Schema-Fehler beim Öffnen der App
- `GET /api/sync/pull` mit gültigem Token → Response enthält Keys `rooms`, `assets`, `assetImages`, `assetPersons` (leere Arrays OK)
- `POST /api/sync/push` mit `{ rooms: [{ id, name, groupType, ... }] }` → HTTP 200
- `db.rooms.count()` in Browser-Console → 0 (kein Fehler)
---
### Phase 2 — Vision-Backend
**Requires:** Phase 1 abgeschlossen (Server-Schema mit `user_settings`, `vision_usage` vorhanden)
**Produces:**
- `server/src/lib/user-settings-service.ts` — exportiert `getSetting`, `setSetting`, `getVisionConfig`
- `server/src/lib/rate-limiter.ts` — exportiert `checkAndIncrement`
- `server/src/lib/vision-service.ts` — exportiert `getProvider(name, apiKey): VisionProvider`
- `server/src/lib/barcode-service.ts` — exportiert `lookupBarcode(ean)`
- `server/src/routes/vision.ts` — Router mit 4 Endpoints
- `server/src/routes/inventory.ts` — Router mit `POST /api/inventory/images`
- `server/.env.example``SETTINGS_ENCRYPTION_KEY` + `VISION_RATE_LIMIT` dokumentiert
**Ziel:** API-Key-Verwaltung, Rate Limiting, Vision + Barcode-Proxy auf dem Server.
#### Schritt 2.1 — User Settings Service [parallel]
- Datei: `server/src/lib/user-settings-service.ts` (NEU)
- `getSetting(userId, key): string | null`
- `setSetting(userId, key, value): void`
- `getVisionConfig(userId): { provider, apiKey } | null`
- AES-256-GCM Verschlüsselung mit `SETTINGS_ENCRYPTION_KEY` aus `.env`
- Datei: `server/.env.example`: + `SETTINGS_ENCRYPTION_KEY=`, `VISION_RATE_LIMIT=100`
#### Schritt 2.2 — Rate Limiter [parallel]
- Datei: `server/src/lib/rate-limiter.ts` (NEU)
- `checkAndIncrement(userId, limit): { allowed, remaining }`
- `INSERT OR IGNORE` + `UPDATE` auf `vision_usage` (date = heute)
#### Schritt 2.3 — Vision Service [parallel]
- Datei: `server/src/lib/vision-service.ts` (NEU)
```
interface VisionProvider { recognize(base64, mime): Promise<{label, category, candidates}> }
class OpenAIVisionProvider // gpt-4o-mini, detail:low
class GeminiVisionProvider // gemini-1.5-flash
export function getProvider(name, apiKey): VisionProvider
```
Prompt (beide Provider):
```
Identify the main object. Return JSON:
{ "label": "<German, max 60 chars>",
"category": "<Elektronik|Möbel|Kleidung|Schmuck|Kunstwerke|Sportgeräte|
Haushaltsgeräte|Haustechnik|Werkzeuge|Musikinstrumente|
Bücher & Medien|Spielzeug|Sammlerstücke|Gartengeräte|
Fahrzeugzubehör|Sonstiges>",
"candidates": ["<alt1>", "<alt2>"] }
```
#### Schritt 2.4 — Barcode Service [parallel]
- Datei: `server/src/lib/barcode-service.ts` (NEU)
- `lookupBarcode(ean): Promise<{label, category?} | null>`
- **Primär**: UPCitemdb `https://api.upcitemdb.com/prod/trial/lookup?upc={ean}` (breite Produktdatenbank, 100/Tag frei)
- **Fallback**: Open Food Facts `https://world.openfoodfacts.org/api/v0/product/{ean}.json` (nur Lebensmittel)
- **Letzter Fallback**: `{ label: ean }` (roher EAN-String)
- Kategorie-Mapping: UPCitemdb `category` → Inventar-Kategorieliste
#### Schritt 2.5 — Vision Route + Image-Upload Endpoint [nach 2.12.4]
- Datei: `server/src/routes/vision.ts` (NEU)
```
POST /api/vision/recognize — multipart { image: File }
POST /api/vision/barcode — JSON { ean: string }
GET /api/vision/settings — { provider, apiKeySet, dailyUsage, dailyLimit }
PUT /api/vision/settings — { provider?, apiKey? }
```
- Datei: `server/src/routes/inventory.ts` (NEU, separater Router)
```
POST /api/inventory/images — multipart { image: File } → speichert in imageBlobs, gibt { id } zurück
```
Alle Routen: `authMiddleware`. `/api/vision/*` zusätzlich Rate-Limit-Check.
- Datei: `server/src/index.ts`: + 4 Zeilen (beide Routes registrieren)
**Verifikation 2:**
- `PUT /api/vision/settings` mit gültigem `{ provider: 'openai', apiKey: '...' }` → HTTP 200
- `GET /api/vision/settings``{ provider: 'openai', apiKeySet: true, dailyUsage: 0, dailyLimit: 100 }`
- `POST /api/vision/recognize` mit JPEG-Testbild → HTTP 200, Body enthält `label` (string), `category` (string aus Kategorienliste), `candidates` (Array)
- `POST /api/vision/barcode` mit `{ ean: '4006381333931' }` (Stabilo-Stift) → HTTP 200, `label` nicht leer
- `POST /api/inventory/images` mit JPEG multipart → HTTP 200, `{ id: '...' }`
- Zweiter Recognize-Call → `dailyUsage: 1` in GET settings
- 101. Call (simuliert via DB-Insert) → HTTP 429
---
### Phase 3 — Erfassungs-Flow (Client)
**Requires:** Phase 1 + Phase 2 + Phase 5 abgeschlossen (API-Key konfigurierbar, Endpoints live)
**Produces:**
- `client/src/lib/services/visionService.ts``compressImage`, `uploadInventoryImage`, `recognizePhoto`, `recognizeBarcode` exportiert
- `client/src/lib/services/visionSettingsService.ts``getVisionSettings`, `saveVisionSettings` exportiert
- `client/src/lib/components/LabelConfirm.svelte` — rendert ohne Fehler
- `client/src/lib/components/PhotoCapture.svelte` — rendert ohne Fehler
- `client/src/lib/components/BarcodeScan.svelte` — rendert ohne Fehler
- `client/src/lib/components/RoomSelectSheet.svelte` — rendert ohne Fehler
- Zwei neue Routen: `/inventory/capture/photo` + `/inventory/capture/barcode`
**Ziel:** Kamera-Flow, Barcode-Scan, Raum-Auswahl, Loop-Seiten.
#### Schritt 3.1 — Vision Service Client
- Datei: `client/src/lib/services/visionService.ts` (NEU)
- `compressImage(blob): Promise<Blob>` — HEIC→JPEG + max-width 1920px + quality 0.75
- `uploadInventoryImage(blob): Promise<string>` — POST `/api/inventory/images`, gibt imageId zurück
- `recognizePhoto(blob): Promise<RecognizeResult>` — POST `/api/vision/recognize`
- `recognizeBarcode(ean: string): Promise<RecognizeResult>` — POST `/api/vision/barcode`
- **Kein ZXing client-seitig** — Barcode-Erkennung läuft server-seitig (via UPCitemdb-Lookup)
- Barcode-Modus: User gibt EAN manuell ein ODER fotografiert Barcode → Server erkennt via KI+Lookup
- Datei: `client/src/lib/services/visionSettingsService.ts` (NEU)
- `getVisionSettings()`, `saveVisionSettings()`
- `@zxing/browser` **entfällt** — kein client-seitiger Barcode-Scan
#### Schritt 3.2 — LabelConfirm Komponente (geteilt)
- Datei: `client/src/lib/components/LabelConfirm.svelte` (NEU)
- Props: `label, category, candidates, onconfirm, oncancel`
- Label-Input + Kategorie-Dropdown + Kandidaten-Chips
#### Schritt 3.3 — RoomSelectSheet
- Datei: `client/src/lib/components/RoomSelectSheet.svelte` (NEU)
- Räume gruppiert nach `groupType` anzeigen
- "Neuen Raum anlegen" inline
- Props: `onselect(roomId), oncancel`
#### Schritt 3.4 — PhotoCapture Komponente
- Datei: `client/src/lib/components/PhotoCapture.svelte` (NEU)
- States: `idle → analyzing → confirm | error`
- `<input type="file" accept="image/*" capture="environment">`
- HEIC→JPEG Normalisierung vor API-Call
- Props: `onconfirm(label, category, imageId), oncancel`
#### Schritt 3.5 — BarcodeScan Komponente
- Datei: `client/src/lib/components/BarcodeScan.svelte` (NEU)
- **Zwei Eingabepfade:**
1. EAN manuell eingeben (Nummernpad, für Nutzer mit Barcode-Scanner-Gerät)
2. Foto aufnehmen → Server-Lookup (wie Foto-Modus, aber `/api/vision/barcode` mit Bild)
- States: `idle → looking_up → confirm | error`
- Props: `onconfirm(label, category, ean?), oncancel`
- **Kein ZXing** — Barcode-Erkennung im Foto via Server (KI-Modell kann Barcodes lesen)
#### Schritt 3.6 — Loop-Seiten
- Datei: `client/src/routes/inventory/capture/photo/+page.svelte` (NEU)
- Query: `?roomId=`
- Loop: PhotoCapture → createAsset(draft) → Counter "X erfasst" → weiter / fertig
- Datei: `client/src/routes/inventory/capture/barcode/+page.svelte` (NEU)
- Query: `?roomId=`
- Loop: BarcodeScan → createAsset(draft) → Counter → weiter / fertig
**Verifikation 3:**
- `/inventory/capture/photo?roomId=X` lädt ohne JS-Fehler
- Foto aufnehmen → `recognizePhoto` liefert Label → LabelConfirm zeigt es an
- Bestätigen → `db.assets.count()` steigt um 1, neues Asset hat `status='draft'`, `roomId=X`, `coverImageId` nicht null
- 3 Fotos in Serie → Counter zeigt "3 erfasst"
- "Fertig" → Redirect auf `/inventory`
- Barcode-Loop: EAN `4006381333931` eingeben → Label erscheint → Asset angelegt ohne `coverImageId`
---
### Phase 4 — Inventar-UI
**Requires:** Phase 1 abgeschlossen (Repositories vorhanden)
**Produces:**
- `client/src/lib/components/AssetCard.svelte`
- `client/src/routes/inventory/+page.svelte`
- `client/src/routes/inventory/rooms/+page.svelte`
- `client/src/routes/inventory/rooms/[id]/+page.svelte`
- `client/src/routes/inventory/items/+page.svelte`
- `client/src/routes/inventory/[id]/+page.svelte`
- `client/src/lib/components/Sidebar.svelte` — Inventar-Eintrag ergänzt
**Ziel:** Dashboard, Asset-Detailseite, Sidebar.
#### Schritt 4.1 — AssetCard Komponente [parallel]
- Datei: `client/src/lib/components/AssetCard.svelte` (NEU)
- Cover-Bild (via `getImageUrl`) oder Platzhalter-Icon
- Titel + Kategorie-Badge + Draft-Badge (wenn `status='draft'`)
- Click → `goto('/inventory/' + id)`
#### Schritt 4.2 — Inventar-Dashboard [parallel]
- Datei: `client/src/routes/inventory/+page.svelte` (NEU)
- Seed-Aufruf: `seedDefaultRoomsIfNeeded()`
- Stats-Cards: Gegenstände-Zähler, Räume-Zähler, Gesamtwert (Summe `purchasePrice`)
- Versicherungshinweis unter Gesamtwert
- Primär-CTA: "📷 Einfach fotografieren" → RoomSelectSheet → Foto-Loop
- Sekundär: "+ Gegenstand" (manuell) | "+ Raum" (inline-Modal)
- Link zu `/inventory/rooms` + `/inventory/items`
#### Schritt 4.3 — Räume-Übersicht [parallel]
- Datei: `client/src/routes/inventory/rooms/+page.svelte` (NEU)
- 3-Spalten-Grid, gruppiert nach `groupType`
- Raumkarte: Icon (hardcodiertes Name→Icon Mapping) + Name + Asset-Zähler + `···` Menu
- `···`: Umbenennen (inline), Löschen (Soft-Delete + ConfirmDialog)
- Click → `/inventory/rooms/[id]` (gefilterte Asset-Liste)
- Datei: `client/src/routes/inventory/rooms/[id]/+page.svelte` (NEU)
- Asset-Liste gefiltert auf diesen Raum (wie `/inventory/items` aber ohne Raum-Filter)
#### Schritt 4.4 — Globale Asset-Liste [parallel]
- Datei: `client/src/routes/inventory/items/+page.svelte` (NEU)
- Header: "Gegenstände" + Zähler + `[📷 Fotografieren]` + `[+ Manuell]`
- Suchfeld (client-seitig, filtert Titel/Marke/Modell)
- Filter-Dropdowns: Raum | Kategorie | Status
- Grid/Liste-Toggle (Svelte store, persistiert in `settings`)
- Sections pro Raum (collapsible), Status-Icon (grün/gelb)
- Asset-Card: Cover-Bild, Preis-Badge, Status-Icon, Titel, Raum-Badge
#### Schritt 4.6 — Asset-Detailseite
- Datei: `client/src/routes/inventory/[id]/+page.svelte` (NEU)
- Muster: analog `wiki/[id]/+page.svelte` (`$derived(page.params.id)`, `$effect`)
- Cover-Bild groß + Galerie (weitere Bilder horizontal scrollbar)
- Inline-Edit alle Felder (sofort gespeichert via `updateAsset`)
- Status-Toggle: Draft → Complete
- Galerie: `+ Foto hinzufügen` (ohne KI) + Löschen (Soft-Delete `assetImages`)
- Notizen: `MarkdownEditor.svelte` (bestehende Komponente)
- **Personen-Sektion**: "Gehört / zugeordnet" — Chips für zugeordnete Familienmitglieder
- `getFamilyPersons()` → Dropdown/Picker mit allen `family`-Personen
- Hinzufügen → `addAssetPerson`, Entfernen → `removeAssetPerson`
- Anzeige: Avatar-Chip mit `PersonMeta.abbreviation` oder `fullName`
- Löschen-Button → `ConfirmDialog.svelte``softDeleteAsset``goto('/inventory')`
#### Schritt 4.7 — Sidebar
- Datei: `client/src/lib/components/Sidebar.svelte`
- Neuer Abschnitt "Inventar" mit Link `/inventory`, Icon: `Package` (lucide)
- `BottomTabBar` bleibt unverändert — Navigation über Sidebar + CommandBar ausreichend
**Verifikation 4:**
- `/inventory` lädt: Seed-Räume sichtbar (15 Default-Räume), Gesamtwert 0 €
- `seedDefaultRoomsIfNeeded` ein zweites Mal aufrufen → `db.rooms.count()` bleibt gleich (kein Doppel-Seed)
- `/inventory/rooms` zeigt 3 Gruppen-Header, alle 15 Default-Räume als Cards
- Raum umbenennen → Name ändert sich ohne Reload
- Raum löschen → `deletedAt` gesetzt, Card verschwindet
- `/inventory/items` mit Filter Raum=X → nur Assets dieses Raums sichtbar
- Asset-Detailseite: Titel ändern → `updateAsset` aufgerufen, Seite zeigt neuen Titel
- Status-Toggle Draft→Complete → `status='complete'` in Dexie, Badge ändert Farbe
- Foto hinzufügen (ohne KI) → `assetImages` Eintrag + neues Bild in Galerie
- Löschen-Button → ConfirmDialog → `softDeleteAsset` → Redirect auf `/inventory`
- Sidebar zeigt "Inventar"-Link → navigiert zu `/inventory`
---
### Phase 5 — Settings (Vision-API-Key)
**Requires:** Phase 2 (Vision-Backend) abgeschlossen — insbesondere `GET/PUT /api/vision/settings`
**Produces:**
- `client/src/routes/settings/+page.svelte` — Abschnitt "Bild-Erkennung" ergänzt
- `client/src/lib/services/visionSettingsService.ts` (kann parallel zu Phase 3.1 entstehen)
**Ziel:** Vision-API-Key pro User konfigurierbar. Muss vor Phase 3 implementiert sein.
#### Schritt 5.1 — Settings-Sektion
- Datei: `client/src/routes/settings/+page.svelte`
- Neuer Abschnitt "Bild-Erkennung"
- Provider-Wahl: Radio OpenAI / Gemini
- API-Key Input (type=password)
- Anzeige wenn gesetzt: `●●●●●●...XXXX` (letzten 4 Zeichen aus Server-Response)
- Tageslimit + Verbrauch (aus `GET /api/vision/settings`)
- Speichern → `saveVisionSettings()`
**Verifikation 5:**
- Settings-Seite öffnen → Abschnitt "Bild-Erkennung" vorhanden
- Provider wählen + Key eingeben + Speichern → `PUT /api/vision/settings` HTTP 200
- Seite neu laden → Key-Feld zeigt `●●●●●●...XXXX` (letzten 4 Zeichen), Provider korrekt vorgewählt
- `dailyUsage` + `dailyLimit` angezeigt
> ⚠️ **Implementierungsreihenfolge**: Phase 5 (Settings) sollte vor oder parallel zu Phase 3 (Erfassungs-Flow) umgesetzt werden — ohne konfigurierten API-Key ist Phase 3 nicht testbar.
---
### Phase 6 — CommandBar-Integration
**Requires:** Phase 4 abgeschlossen (Repositories + `/inventory/[id]` Route vorhanden)
**Produces:**
- `client/src/lib/components/CommandBar.svelte` — Assets in Suchergebnissen + 2 neue Slash-Commands
**Ziel:** Assets über CommandBar findbar, `/asset`-Command für schnelle Erfassung.
#### Schritt 6.1 — Asset-Suche in CommandBar
- Datei: `client/src/lib/components/CommandBar.svelte`
- Datenquelle: `liveQuery(() => db.assets.filter(a => !a.deletedAt).toArray())` — kein neuer Endpoint
- Filterung: `asset.title + asset.brand + asset.model` (analog bestehende Context-Suche)
- Anzeige-Format:
```
│ 📦 Flauschige Decke INVENTAR │
│ Ankleidezimmer · Eltern │
```
- Ergebnis-Limit: max. 3 Assets (in bestehende 3+3-Logik einpassen)
- `Enter``goto('/inventory/' + asset.id)`
#### Schritt 6.2 — Slash-Commands
- Datei: `client/src/lib/components/CommandBar.svelte`
- 2 neue Einträge im `COMMANDS`-Array:
| Command | Verhalten |
|---|---|
| `/inventar` | `goto('/inventory')` |
| `/asset [Titel]` | Mit Text: `createAsset(title, roomId=null)``goto('/inventory/' + id)`; ohne Text: `goto('/inventory')` mit Manuell-Flag |
**Verifikation 6:**
- Asset "Flauschige Decke" in Dexie vorhanden
- `Ctrl+K` → "decke" tippen → Eintrag mit Badge `INVENTAR` erscheint in Ergebnissen
- Enter → Redirect auf `/inventory/{id}`
- `Ctrl+K``/inventar` Enter → Redirect auf `/inventory`
- `Ctrl+K``/asset Laptop` Enter → neues Asset mit `title='Laptop'`, `roomId=null`, `status='draft'` in Dexie, Detailseite öffnet
- `Ctrl+K``/asset` ohne Text Enter → Redirect auf `/inventory` (kein Fehler)
---
### Phase 7 — Feature-Dokumentation
**Requires:** Phase 4 abgeschlossen (UI + Datenmodell stabil), Phase 6 abgeschlossen (CommandBar-Befehle final)
**Produces:** `docs/feature-inventory.md` — versioniert im Repo, Inhalt stimmt mit implementiertem Stand überein
**Ziel:** `docs/feature-inventory.md` anlegen (analog `feature-tasks.md`, `feature-commandbar.md`).
Wird nach Abschluss von Phase 4+6 geschrieben — wenn UI + Datenmodell stabil sind.
#### Inhalt `docs/feature-inventory.md` (Entwurf)
```markdown
# Feature: Inventar
## Zweck
Hausinventar erfassen und verwalten: Gegenstände per Kamera oder Barcode aufnehmen,
KI erkennt Titel + Kategorie automatisch, Zuordnung zu Räumen und Familienmitgliedern.
Gesamtwert als Grundlage für Versicherungssumme.
---
## Räume
Räume werden in drei Gruppen eingeteilt (hardcodiert, nicht erweiterbar):
| `groupType` | Label | Beispiel-Default-Räume |
|---|---|---|
| `living` | Wohnbereiche | Wohnzimmer, Schlafzimmer, Kinderzimmer, Esszimmer, Flur |
| `functional` | Funktionsräume | Küche, Bad, WC, Arbeitszimmer, Hauswirtschaft |
| `outdoor` | Außen & Nebenbereiche | Keller, Dachboden, Garage, Garten, Abstellraum |
Default-Räume werden beim ersten Aufruf von `/inventory` automatisch angelegt
(`seedDefaultRoomsIfNeeded()`, Flag `'inventory.seeded'` in `settings`-Tabelle).
Räume sind löschbar (Soft-Delete). Jeder Raum hat ein `icon`-Feld (lucide icon name).
---
## Asset-Felder
| Feld | Typ | Beschreibung |
|---|---|---|
| `title` | string | Name des Gegenstands (KI-Vorschlag oder manuell) |
| `roomId` | string\|null | Zugeordneter Raum (null = ohne Raum) |
| `category` | string\|null | Aus hardcodierter Liste (s.u.) oder null |
| `status` | `draft`\|`complete` | Draft = Schnellerfassung, noch unvollständig |
| `condition` | `new`\|`good`\|`fair`\|`poor`\|null | Zustand |
| `brand` | string\|null | Marke |
| `model` | string\|null | Modell |
| `serialNumber` | string\|null | Seriennummer |
| `purchasePrice` | real\|null | Kaufpreis (€) |
| `purchaseYear` | integer\|null | Anschaffungsjahr |
| `notes` | string\|null | Freies Markdown-Notizfeld |
| `coverImageId` | string\|null | Primärbild (→ imageBlobs.id, Server-seitig) |
---
## Kategorien (hardcoded)
`null` (Keine Kategorie), Elektronik, Möbel, Kleidung, Schmuck, Kunstwerke,
Sportgeräte, Haushaltsgeräte, Haustechnik, Werkzeuge, Musikinstrumente,
Bücher & Medien, Spielzeug, Sammlerstücke, Gartengeräte, Fahrzeugzubehör, Sonstiges
Kein eigenes DB-Table — als TypeScript-Konstante in `client/src/lib/utils/inventory.ts`.
---
## Erfassungs-Modi
### Foto-Modus (Schnellerfassung)
1. Raum wählen (einmalig für die Session)
2. Loop: Foto aufnehmen → KI-Erkennung → Label + Kategorie bestätigen → Asset(draft) anlegen
3. [Fertig] → Dashboard
Route: `/inventory/capture/photo?roomId=...`
KI: POST `/api/vision/recognize` (OpenAI gpt-4o-mini oder Gemini gemini-1.5-flash)
### Barcode-Modus (Schnellerfassung)
1. Raum wählen (einmalig)
2. Loop: EAN eingeben ODER Barcode fotografieren → UPCitemdb-Lookup → bestätigen → Asset(draft)
3. [Fertig] → Dashboard
Route: `/inventory/capture/barcode?roomId=...`
Lookup: POST `/api/vision/barcode` → UPCitemdb → Open Food Facts → EAN-Fallback
### Manuell (Einzel)
Raum wählen → Titel eingeben → Asset(draft) → Detailseite
Auch via CommandBar: `/asset Titel`
---
## Bilder-Architektur
**Inventory-Fotos werden NICHT lokal gecacht** (Grund: 500 Assets × ~300 KB = 150 MB).
Statt `storeImage()` (schreibt in Dexie): `uploadInventoryImage(blob)`:
1. Client komprimiert: JPEG, max-width 1920px, quality 0.75 → ~300 KB
2. POST `/api/inventory/images` → Server speichert in `imageBlobs`-Tabelle
3. Client erhält `imageId` zurück, speichert nur die ID im Asset
4. Anzeige: `getImageUrl(id)` fetcht live via `/api/sync/blob/{id}`
`storeImage()` aus `imageStore.ts` wird weiterhin für Editor-Paste-Bilder verwendet —
**nicht** für Inventory.
---
## Personen-Zuordnung
Assets können Familienmitgliedern zugeordnet werden (`assetPersons`-Tabelle, n:m).
Auswählbar sind nur Personen mit `contexts.type='person'` + `meta.personSubType='family'`.
Anzeige auf Detailseite als Avatar-Chips (`PersonMeta.abbreviation` oder `fullName`).
---
## Vision-API-Key
Kein globaler API-Key — jeder User hinterlegt seinen eigenen Key in den Einstellungen
(Abschnitt "Bild-Erkennung"). Keys werden AES-256-GCM verschlüsselt in `user_settings`
gespeichert. Server-seitiges Verschlüsselungs-Secret: `SETTINGS_ENCRYPTION_KEY` in `.env`.
Rate-Limit: 100 Vision-Calls/Tag pro User (DB-basiert via `vision_usage`-Tabelle).
---
## Routen-Übersicht
| Route | Beschreibung |
|---|---|
| `/inventory` | Dashboard: Stats, Gesamtwert, Quick-Actions |
| `/inventory/rooms` | Räume-Grid (3-spaltig, gruppiert) |
| `/inventory/rooms/[id]` | Asset-Liste eines Raums |
| `/inventory/items` | Alle Assets (Suche, Filter, Grid/Liste) |
| `/inventory/capture/photo` | Foto-Schnellerfassungs-Loop |
| `/inventory/capture/barcode` | Barcode-Schnellerfassungs-Loop |
| `/inventory/[id]` | Asset-Detailseite |
---
## CommandBar-Integration
- Suche: Assets erscheinen in Ergebnissen (Badge `INVENTAR`, Icon 📦)
- `/inventar` → Dashboard
- `/asset [Titel]` → Asset anlegen (roomId=null) → Detailseite
---
## Server-Endpoints (Inventory-spezifisch)
```
POST /api/inventory/images Bild hochladen → { id }
POST /api/vision/recognize Foto → { label, category, candidates }
POST /api/vision/barcode EAN oder Barcode-Foto → { label, category }
GET /api/vision/settings { provider, apiKeySet, dailyUsage, dailyLimit }
PUT /api/vision/settings { provider?, apiKey? }
```
Sync läuft über den bestehenden `/api/sync/push` + `/api/sync/pull` Endpunkt.
Neue Tabellen im Sync: `rooms`, `assets`, `assetImages`, `assetPersons`.
---
## Wichtige Implementierungshinweise
### Bild-Upload vs. storeImage()
`storeImage()` und `uploadInventoryImage()` sind NICHT austauschbar:
- `storeImage()` → Dexie lokal + Sync (für Editor-Bilder)
- `uploadInventoryImage()` → direkt Server, kein Dexie (für Inventory-Fotos)
Verwechslung führt zu 75+ MB lokalem Storage.
### coverImageId und assetImages
`assets.coverImageId` = das Hauptbild (aus `imageBlobs.id`).
`assetImages` = weitere Bilder (ebenfalls `imageBlobs`-Referenzen).
Das Cover kann auf der Detailseite durch ein `assetImages`-Bild ersetzt werden
(`updateAsset({ coverImageId: assetImage.imageId })`).
Es gibt keine automatische Verknüpfung: Cover-Änderung aktualisiert NICHT `assetImages`.
### Draft vs. Complete
Assets beginnen immer als `draft`. Status `complete` muss manuell gesetzt werden.
Dashboard zeigt Gesamtzahl unabhängig vom Status.
`/inventory/items` filtert nach Status (Dropdown "Alle Status").
### Raum-Seed
`seedDefaultRoomsIfNeeded()` prüft `db.settings.get('inventory.seeded')`.
Wird nur einmal ausgeführt. Danach kann der User Räume löschen/umbenennen ohne Re-Seed.
### Barcode-Erkennung
Kein client-seitiger Barcode-Scanner (ZXing entfällt — kein live scan auf iOS).
Stattdessen: EAN manuell eingeben ODER Foto → POST `/api/vision/barcode`.
Server versucht UPCitemdb → Open Food Facts → EAN als Label.
---
## Bekannte Einschränkungen
- Kamera-Erfassung erfordert Online-Verbindung (kein offline capture)
- Barcode-Lookup max. 100/Tag (UPCitemdb free tier)
- Räume können nicht per Drag-Drop sortiert werden (`sortOrder` vorhanden, aber kein UI)
- Asset-Export (PDF/CSV) nicht implementiert (geplant als spätere Phase)
- Preisrecherche nicht implementiert (geplant: eBay Browse API)
```
**Verifikation 7:**
- `docs/feature-inventory.md` existiert (nicht leer)
- Alle Routen aus "Routen-Übersicht" im Dokument entsprechen tatsächlich implementierten SvelteKit-Routen
- Alle Felder in "Asset-Felder"-Tabelle stimmen mit `shared/src/types.ts` überein
- Alle Server-Endpoints im Dokument sind in `routes/vision.ts` + `routes/inventory.ts` implementiert
- CommandBar-Commands `/inventar` und `/asset` im Dokument = exakte Command-Namen in `CommandBar.svelte`
---
## Spätere Phase: Automatische Preisrecherche
Auf Basis von Marke + Modell (+ optional Seriennummer):
| Dienst | Art | Bewertung |
|---|---|---|
| **eBay Browse API** | Offizielle REST-API | Gebrauchtpreise, kostenlos mit App-Key → **Primärziel** |
| **Open EAN / UPCitemdb** | Barcode→Produktinfo | Ergänzend für Basisdaten |
| **Google Shopping** (Custom Search) | Preisvergleich | Kostenpflichtig ab Volumen |
| **idealo** | Scraping | Fragil + rechtlich grenzwertig → nicht empfohlen |
| **Versicherungswert-Formel** | Offline | `Kaufpreis × Altersfaktor` als schnelle Schätzung |
Umsetzung: Button "Preis recherchieren" auf Detailseite → `GET /api/inventory/[id]/price-lookup` → eBay API Query mit `brand + model` → Median-Preis + Link.
---
## Wiederverwendete bestehende Dateien
| Datei | Verwendung |
|---|---|
| `lib/db/imageStore.ts: storeImage()` | Weiterhin für Editor-Bilder — **nicht** für Inventory-Fotos |
| `lib/db/imageStore.ts: getImageUrl()` | Inventory: direkt `/api/sync/blob/{id}` fetchen (kein Dexie-Write) |
| `lib/db/helpers.ts: newId(), now()` | IDs + Timestamps |
| `lib/auth/authStore.ts: getAccessToken()` | Bearer Token für Vision-API |
| `lib/components/MarkdownEditor.svelte` | Notizfeld Detailseite |
| `lib/components/ConfirmDialog.svelte` | Lösch-Bestätigung |
| `server/src/lib/route-utils.ts: handle()` | Route-Wrapper |
---
## Risiken
| Risiko | Mitigation |
|---|---|
| HEIC von iOS-Kamera | `compressImage()`: Canvas `toBlob('image/jpeg', 0.75)` + max-width 1920px |
| Kamera-Erfassung erfordert Online | PWA mit WLAN — akzeptabel; Fehlermeldung wenn offline |
| OpenAI/Gemini Timeout | 10s Timeout, Fallback: manuelles Label |
| Rate-Limit-Zähler bei DB-Fehler | `INSERT OR IGNORE` + UPDATE — Worst case: limit nicht enforced (akzeptabel) |
| UPCitemdb Tages-Limit (100/Tag frei) | Fallback auf Open Food Facts; für höheres Volumen kostenpflichtiger Plan |
| Barcode nicht in Datenbank | Fallback: roher EAN-String als Label |
| Asset ohne Bild (Barcode-Modus) | `coverImageId = null`, Platzhalter-Icon in Card |
| Vision-API nicht konfiguriert | Fehlermeldung "Bitte API-Key in Einstellungen hinterlegen" statt 500 |
| Rate-Limit Timezone-Drift | Server-UTC für `date`-Feld — dokumentiert, kein Fix nötig |
## Spätere Phase: Asset-Export
Für Versicherungszwecke:
- PDF-Export: Inventarliste mit Fotos, Werten, Seriennummern (via `jsPDF` oder Server-seitiger PDF-Render)
- CSV-Export: Alle Assets als Tabelle (client-seitig, kein Server nötig)
- Button auf Dashboard: "Inventar exportieren"