upd inventory

This commit is contained in:
beo3000 2026-03-07 09:05:36 +01:00
parent b4ed2055a2
commit 7121308525
11 changed files with 2123 additions and 42 deletions

View File

@ -36,10 +36,13 @@ Räume sind löschbar (Soft-Delete). Jeder Raum hat ein `icon`-Feld (lucide icon
| `brand` | string\|null | Marke |
| `model` | string\|null | Modell |
| `serialNumber` | string\|null | Seriennummer |
| `purchasePrice` | real\|null | Kaufpreis (€) |
| `purchasePrice` | real\|null | Kaufpreis (€), manuell oder via KI-Schätzung übernommen |
| `purchaseYear` | integer\|null | Anschaffungsjahr |
| `notes` | string\|null | Freies Markdown-Notizfeld |
| `coverImageId` | string\|null | Primärbild (→ imageBlobs.id, Server-seitig) |
| `lastPriceEstimate` | real\|null | Letzter geschätzter Marktwert (€) |
| `lastPriceEstimateAt` | string\|null | ISO-Zeitstempel der letzten Schätzung |
| `lastPriceEstimateSource` | `'ebay'`\|`'formula'`\|`'category'`\|null | Quelle der Schätzung |
---
@ -61,34 +64,66 @@ Kein eigenes DB-Table — als TypeScript-Konstante in den jeweiligen Komponenten
3. [Fertig] → Dashboard
Route: `/inventory/capture/photo?roomId=...`
KI: POST `/api/vision/recognize` (OpenAI gpt-4o-mini oder Gemini gemini-1.5-flash)
KI: `POST /api/vision/recognize` (OpenAI gpt-4o-mini oder Gemini)
Input: `accept="image/jpeg,image/png,image/webp"` — kein HEIC (Canvas kann HEIC nicht dekodieren)
### Barcode-Modus (Schnellerfassung)
1. Raum wählen (optional via `?roomId=` Query-Parameter)
2. Loop: EAN eingeben → UPCitemdb-Lookup → bestätigen → Asset(draft)
2. Loop: EAN manuell eingeben oder via physischem Scanner → 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
Lookup: `POST /api/vision/barcode` → UPCitemdb → Open Food Facts → EAN als Fallback-Label
Rate-Limit gilt **nicht** für Barcode-Lookups (nutzt UPCitemdb, nicht die Vision-API des Users).
### Manuell (Einzel)
Raum wählen → Titel eingeben → Asset(draft) → Detailseite
Titel eingeben → Asset(draft) → Detailseite
Auch via CommandBar: `/asset Titel`
---
## KI-Preisschätzung (enrichAsset)
Auf der Detailseite: Button "Preis schätzen" → `POST /api/vision/enrich`
Die KI schätzt auf Basis der vorhandenen Felder:
- `title` (Pflicht)
- `brand`, `model` (optional, verbessern Genauigkeit)
**Response:**
```json
{
"brand": "Sony",
"model": "WH-1000XM5",
"estimatedNewPrice": 350,
"estimatedUsedPrice": 180
}
```
Der User kann Marke/Modell und Preise einzeln übernehmen. `purchasePrice` wird auf den
übernommenen Wert gesetzt. `lastPriceEstimate` / `lastPriceEstimateAt` / `lastPriceEstimateSource`
werden beim Speichern der Schätzung aktualisiert.
Rate-Limit: zählt gegen die 100 Vision-Calls/Tag des Users (gleicher Zähler wie Foto-Erkennung).
---
## 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
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.
`storeImage()` aus `imageStore.ts` wird weiterhin für Editor-Paste-Bilder verwendet — **nicht** für Inventory.
### Cover-Konsistenz bei Image-Löschung
`softDeleteAssetImage` prüft ob `asset.coverImageId === assetImage.imageId`. Wenn ja:
`updateAsset({ coverImageId: nächstesAssetImage?.imageId ?? null })`.
---
@ -96,7 +131,7 @@ Statt `storeImage()` (schreibt in Dexie): `uploadInventoryImage(blob)`:
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 Chips.
Anzeige auf Detailseite als Chips (`PersonMeta.abbreviation` oder `fullName`).
---
@ -106,7 +141,11 @@ Kein globaler API-Key — jeder User hinterlegt seinen eigenen Key in den Einste
(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).
Rate-Limit: 100 Vision-Calls/Tag pro User (`vision_usage`-Tabelle). Gilt für:
- `POST /api/vision/recognize` (Foto-Erkennung)
- `POST /api/vision/enrich` (Preisschätzung)
Gilt **nicht** für `POST /api/vision/barcode` (UPCitemdb, kein User-API-Key).
---
@ -136,8 +175,9 @@ Rate-Limit: 100 Vision-Calls/Tag pro User (DB-basiert via `vision_usage`-Tabelle
```
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 }
POST /api/vision/recognize Foto → { label, category, candidates, remaining }
POST /api/vision/enrich { label, brand?, model? } → { brand, model, estimatedNewPrice, estimatedUsedPrice }
POST /api/vision/barcode { ean } → { label, category? } (kein Rate-Limit)
GET /api/vision/settings { provider, apiKeySet, dailyUsage, dailyLimit }
PUT /api/vision/settings { provider?, apiKey? }
```
@ -153,13 +193,6 @@ Neue Tabellen im Sync: `rooms`, `assets`, `assetImages`, `assetPersons`.
`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 })`).
### Draft vs. Complete
Assets beginnen immer als `draft`. Status `complete` muss manuell gesetzt werden.
@ -171,16 +204,15 @@ Dashboard zeigt Gesamtzahl unabhängig vom Status.
Wird nur einmal ausgeführt. Danach kann der User Räume löschen/umbenennen ohne Re-Seed.
### Barcode-Erkennung
Kein client-seitiger Barcode-Scanner.
Stattdessen: EAN manuell eingeben → POST `/api/vision/barcode`.
Server versucht UPCitemdb → Open Food Facts → EAN als Label.
Kein client-seitiger Barcode-Scanner (BarcodeDetector API unterstützt kein iOS/Safari).
EAN manuell eingeben oder physischen Scanner verwenden → `POST /api/vision/barcode`.
---
## Bekannte Einschränkungen
- Kamera-Erfassung erfordert Online-Verbindung (kein offline capture)
- Barcode-Lookup max. 100/Tag (UPCitemdb free tier)
- Barcode-Lookup max. 100/Tag server-weit (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)
- BarcodeDetector API (live Kamera-Scan) nicht implementiert — iOS unterstützt es nicht

View File

@ -1 +1 @@
1.2.33
1.2.34

View File

@ -0,0 +1,3 @@
ALTER TABLE `assets` ADD `last_price_estimate` real;--> statement-breakpoint
ALTER TABLE `assets` ADD `last_price_estimate_at` text;--> statement-breakpoint
ALTER TABLE `assets` ADD `last_price_estimate_source` text;

File diff suppressed because it is too large Load Diff

View File

@ -127,6 +127,13 @@
"when": 1772831133949,
"tag": "0017_kind_luke_cage",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1772869791129,
"tag": "0018_graceful_butterfly",
"breakpoints": true
}
]
}

Binary file not shown.

Binary file not shown.

View File

@ -1,4 +1,4 @@
import { sqliteTable, text, integer, blob, index, primaryKey, foreignKey } from 'drizzle-orm/sqlite-core';
import { sqliteTable, text, integer, real, blob, index, primaryKey, foreignKey } from 'drizzle-orm/sqlite-core';
export const contexts = sqliteTable('contexts', {
id: text('id').notNull(),
@ -236,6 +236,9 @@ export const assets = sqliteTable('assets', {
purchaseYear: integer('purchase_year'),
notes: text('notes'),
coverImageId: text('cover_image_id'),
lastPriceEstimate: real('last_price_estimate'),
lastPriceEstimateAt: text('last_price_estimate_at'),
lastPriceEstimateSource: text('last_price_estimate_source'),
updatedAt: text('updated_at').notNull(),
deletedAt: text('deleted_at'),
purgedAt: text('purged_at'),

View File

@ -206,6 +206,9 @@ function mapAsset(row: typeof assets.$inferSelect): Asset {
purchaseYear: row.purchaseYear ?? null,
notes: row.notes ?? null,
coverImageId: row.coverImageId ?? null,
lastPriceEstimate: row.lastPriceEstimate ?? null,
lastPriceEstimateAt: row.lastPriceEstimateAt ?? null,
lastPriceEstimateSource: (row.lastPriceEstimateSource ?? null) as Asset['lastPriceEstimateSource'],
updatedAt: row.updatedAt,
deletedAt: row.deletedAt ?? null,
purgedAt: row.purgedAt ?? null,
@ -427,7 +430,7 @@ export async function pushChanges(request: SyncPushRequest, userId: string): Pro
}
for (const as of assts) {
const row = { id: as.id, userId, roomId: as.roomId ?? null, title: as.title, category: as.category ?? null, status: as.status, condition: as.condition ?? null, brand: as.brand ?? null, model: as.model ?? null, serialNumber: as.serialNumber ?? null, purchasePrice: as.purchasePrice ?? null, purchaseYear: as.purchaseYear ?? null, notes: as.notes ?? null, coverImageId: as.coverImageId ?? null, updatedAt: as.updatedAt, deletedAt: as.deletedAt, purgedAt: as.purgedAt ?? null, version: as.version };
const row = { id: as.id, userId, roomId: as.roomId ?? null, title: as.title, category: as.category ?? null, status: as.status, condition: as.condition ?? null, brand: as.brand ?? null, model: as.model ?? null, serialNumber: as.serialNumber ?? null, purchasePrice: as.purchasePrice ?? null, purchaseYear: as.purchaseYear ?? null, notes: as.notes ?? null, coverImageId: as.coverImageId ?? null, lastPriceEstimate: as.lastPriceEstimate ?? null, lastPriceEstimateAt: as.lastPriceEstimateAt ?? null, lastPriceEstimateSource: as.lastPriceEstimateSource ?? null, updatedAt: as.updatedAt, deletedAt: as.deletedAt, purgedAt: as.purgedAt ?? null, version: as.version };
if (await upsertEntity(assets, row, conflicts, 'asset', userId)) accepted++;
}

View File

@ -116,6 +116,9 @@ export interface Asset extends SyncEntity {
purchaseYear: number | null;
notes: string | null;
coverImageId: string | null;
lastPriceEstimate: number | null;
lastPriceEstimateAt: string | null;
lastPriceEstimateSource: 'ebay' | 'formula' | 'category' | null;
}
export interface AssetImage extends SyncEntity {

View File

@ -188,7 +188,7 @@ PRIMARY KEY (userId, date)
`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
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
@ -366,7 +366,7 @@ Phase 4 → Phase 7 (Docs) [nach stabilem UI]
- Datei: `server/src/routes/vision.ts` (NEU)
```
POST /api/vision/recognize — multipart { image: File }
POST /api/vision/barcode — JSON { ean: string }
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? }
```
@ -374,7 +374,8 @@ Phase 4 → Phase 7 (Docs) [nach stabilem UI]
```
POST /api/inventory/images — multipart { image: File } → speichert in imageBlobs, gibt { id } zurück
```
Alle Routen: `authMiddleware`. `/api/vision/*` zusätzlich Rate-Limit-Check.
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:**
@ -404,12 +405,12 @@ Phase 4 → Phase 7 (Docs) [nach stabilem UI]
#### 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
- `compressImage(blob): Promise<Blob>` — 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<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
- **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
@ -434,12 +435,10 @@ Phase 4 → Phase 7 (Docs) [nach stabilem UI]
#### 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)
- **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 ZXing** — Barcode-Erkennung im Foto via Server (KI-Modell kann Barcodes lesen)
- **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)
@ -513,6 +512,7 @@ Phase 4 → Phase 7 (Docs) [nach stabilem UI]
- 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
@ -816,9 +816,22 @@ Server versucht UPCitemdb → Open Food Facts → EAN als Label.
---
## Spätere Phase: Automatische Preisrecherche
## Spätere Phase: Automatische Preisschätzung
Auf Basis von Marke + Modell (+ optional Seriennummer):
### 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 |
|---|---|---|
@ -826,9 +839,45 @@ Auf Basis von Marke + Modell (+ optional Seriennummer):
| **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.
### 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 (03):**
- 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
→ "150400 € (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).
---
@ -850,7 +899,7 @@ Umsetzung: Button "Preis recherchieren" auf Detailseite → `GET /api/inventory/
| Risiko | Mitigation |
|---|---|
| HEIC von iOS-Kamera | `compressImage()`: Canvas `toBlob('image/jpeg', 0.75)` + max-width 1920px |
| HEIC von iOS-Kamera | Browser kann HEIC nicht nativ via Canvas dekodieren. Fix: `<input accept="image/jpeg,image/png,image/webp">` → 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) |
@ -860,6 +909,74 @@ Umsetzung: Button "Preis recherchieren" auf Detailseite → `GET /api/inventory/
| 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: