# 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`: 1. Bild client-seitig komprimieren: Canvas `toBlob('image/jpeg', 0.75)` + max-width 1920px → ~300 KB. HEIC wird durch `accept="image/jpeg,image/png,image/webp"` im Input verhindert. 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.1–2.4: vollständig parallel (je eigene neue Datei) - Phase 5 + Phase 2.5: parallel nach Phase 2.1–2.4 - Phase 4 Schritte 4.1–4.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": "", "category": "", "candidates": ["", ""] } ``` #### 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.1–2.4] - Datei: `server/src/routes/vision.ts` (NEU) ``` POST /api/vision/recognize — multipart { image: File } POST /api/vision/barcode — JSON { ean: string } ← nur EAN-Lookup, kein Bild-Input 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 ``` Routen: alle `authMiddleware`. Rate-Limit-Check **nur** auf `POST /api/vision/recognize` — nicht auf `/api/vision/barcode` (nutzt UPCitemdb, nicht die Vision-API des Users). UPCitemdb-Limit (100/Tag server-weit, free tier) ist ein separates Server-seitiges Limit — nicht via `vision_usage` getrackt. - 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` — max-width 1920px + quality 0.75, JPEG-Output via Canvas. HEIC wird durch `accept`-Attribut im Input vermieden (kein HEIC-Decoder nötig). - `uploadInventoryImage(blob): Promise` — POST `/api/inventory/images`, gibt imageId zurück - `recognizePhoto(blob): Promise` — POST `/api/vision/recognize` - `recognizeBarcode(ean: string): Promise` — POST `/api/vision/barcode` - **Kein ZXing client-seitig** — EAN wird manuell eingegeben (Nummernpad oder physischer Barcode-Scanner) - Barcode-via-Foto entfällt aus Phase 3: `POST /api/vision/barcode` akzeptiert nur `{ ean: string }`, kein Bild. Foto→EAN wäre ein separater Endpoint oder via BarcodeDetector API (spätere Phase). - 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` - `` - 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) - **Eingabe:** EAN manuell eingeben (Nummernpad oder physischer Barcode-Scanner via Keyboard-Input) - States: `idle → looking_up → confirm | error` - Props: `onconfirm(label, category, ean?), oncancel` - **Kein Foto-Pfad** in Phase 3 — `POST /api/vision/barcode` akzeptiert nur `{ ean: string }`. Foto→EAN kommt erst mit BarcodeDetector API (spätere Phase). #### 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`) - **Cover-Konsistenz bei Image-Löschung:** `softDeleteAssetImage` prüft ob `asset.coverImageId === assetImage.imageId` → wenn ja: `updateAsset({ coverImageId: nächstesAssetImage?.imageId ?? null })` - 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 Preisschätzung ### Stufenmodell — je mehr Daten, desto präziser | Verfügbare Daten | Strategie | Quelle | |---|---|---| | Nur Label/Kategorie | Kategorie-Durchschnittsrange (hardcoded) | Offline | | + Brand + Model | Produktsuche → Median aktueller Listings | eBay Browse API | | + Condition | Zustandsfaktor anwenden | Client-seitig | | + purchaseYear | Abschreibungsfaktor zusätzlich | Client-seitig | | + purchasePrice (ohne Markt) | `Kaufpreis × Zeitfaktor × Zustandsfaktor` | Offline-Formel | **Zustandsfaktoren:** `new`=100%, `good`=70%, `fair`=45%, `poor`=20% **Abschreibung:** `-8% pro Jahr` (Elektronik: `-15%`), Minimum 10% Restwert ### API-Quellen | 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 | ### Endpoint `GET /api/inventory/[id]/price-lookup` → Response: ```json { "estimatedValue": 320, "rangeMin": 240, "rangeMax": 410, "confidence": 3, "source": "ebay", "breakdown": "Marktpreis 450 € × Zustand 'gut' (70%) = 315 €", "ebayListingUrl": "https://..." } ``` **Konfidenz-Score (0–3):** - 0 = nur Label/Kategorie → Range aus hardcodierter Tabelle - 1 = +Brand+Model → eBay-Suche möglich - 2 = +Condition → Zustandsfaktor auf eBay-Preis angewendet - 3 = +Condition+purchaseYear → Abschreibungsfaktor zusätzlich `purchaseYear` erhöht Score nur wenn `condition` bereits bekannt (sonst keine Aussagekraft). ### UI auf Detailseite ``` [Preis schätzen ●●●○] ← Konfidenz-Indikator → "~320 € (eBay-Marktpreise)" ← mit Quelle → "150–400 € (Kategorie-Schätzung, Marke/Modell ergänzen für besseres Ergebnis)" [Wert übernehmen] → setzt purchasePrice ``` Schätzung wird in eigenen Spalten von `assets` gespeichert (nicht in `notes` — strukturierte Daten gehören nicht in Freitext-Felder): - `lastPriceEstimate REAL` — geschätzter Wert (Median) - `lastPriceEstimateAt TEXT` — Zeitstempel der letzten Schätzung - `lastPriceEstimateSource TEXT` — `'ebay'|'formula'|'category'` Diese Felder sind Teil des Asset-Datenmodells (Phase 1 Schritt 1.2 + 1.3 entsprechend ergänzen). --- ## 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 | Browser kann HEIC nicht nativ via Canvas dekodieren. Fix: `` → iOS bietet dann JPEG an (kein HEIC). Alternativ: `heic2any`-Library als Fallback für direkten File-Drop. Canvas-only reicht **nicht**. | | 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: KI-Zustandserkennung aus Foto Beim Foto-Erfassungs-Flow erkennt die KI nicht nur Label + Kategorie, sondern auch `condition`. Erweiterung des Vision-Prompts: ``` Return JSON: { "label": "...", "category": "...", "condition": "new|good|fair|poor|unknown", "candidates": [...] } ``` - `condition` wird in LabelConfirm als vorausgefülltes Dropdown angezeigt (editierbar) - `"unknown"` ist **kein gültiger** `condition`-Typ — Server-seitig auf `null` mappen bevor Antwort an Client geht - Kein Breaking Change — bestehende Response ohne `condition` → null **Nutzen:** Spart einen manuellen Schritt auf der Detailseite; verbessert Preisschätzungs-Qualität direkt nach Erfassung. --- ## Spätere Phase: Offline-Capture-Queue Aktuell: Kamera-Erfassung erfordert Online-Verbindung (hard constraint). **Verbesserung:** Fotos offline aufnehmen, in IndexedDB queuen, bei nächster Verbindung hochladen. ``` OfflineCaptureQueue (Dexie): id, blob, roomId, capturedAt, uploadedAt (null = pending) ``` - UI zeigt Badge "X Fotos ausstehend" wenn Queue nicht leer - Background-Sync (Service Worker) oder manueller "Jetzt hochladen"-Button - Nach Upload: normaler createAsset-Flow mit zurückgegebenem imageId **Komplexität:** Mittel (Service Worker optional — auch ohne funktioniert manueller Trigger). --- ## Spätere Phase: BarcodeDetector API (Native Browser) Chrome 83+ und Android WebView unterstützen `BarcodeDetector` nativ — **Safari wird nicht unterstützt** (weder iOS noch macOS, Stand 2026). Für iOS-User bleibt das manuelle EAN-Eingabefeld der primäre Pfad. ```javascript const detector = new BarcodeDetector({ formats: ['ean_13', 'ean_8', 'upc_a'] }); const barcodes = await detector.detect(videoFrame); ``` - Live-Kamera-Stream → EAN direkt client-seitig extrahiert - Danach: `POST /api/vision/barcode` mit EAN (wie bisher) für Produktdaten-Lookup - Fallback für nicht unterstützte Browser: manuelles EAN-Eingabefeld (wie aktuell geplant) - Kein ZXing-Dependency nötig **Nutzen:** Echter Barcode-Scanner-Feeling ohne Bibliothek. --- ## Spätere Phase: Draft-Reminder Assets die länger als X Tage als `draft` verbleiben → Hinweis auf Dashboard. ``` Dashboard: "5 Gegenstände noch unvollständig (> 7 Tage)" [Jetzt vervollständigen →] ``` - Client-seitig via `liveQuery` — kein Server nötig - Schwellwert: 7 Tage (hardcoded oder in Settings konfigurierbar) --- ## 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"