Zuletzt aktualisiert:

Silverscale Family Hub – Architektur

Stand: 05.06.2026 · Familie Fisch 🐟 · Multi-User kcal/Makro-Tracking + Einkaufs-/Vorrats-/Preis-Tracking auf Basis der bestehenden Lidl-Pipeline.

Gesamtbild

                       ┌─────────────────────────── VPS ───────────────────────────┐
  Lidl-Plus-API        │                                                           │
  (tickets v2/v3) ──▶ lidl-grocy/lidl.py ──▶ Bons (JSON, geparst)                  │
                       │        │                                                  │
                       │        ├──▶ sync.py ──▶ Grocy (bleibt unverändert laufen) │
                       │        │                                                  │
                       │        └──▶ app/backend/importer.py ─▶ data/silverscale.db│
                       │             (+ lidl-artikel-mapping.json als Food-Seed)   │
                       │                              │                            │
                       │            FastAPI (uvicorn, Docker "silverscale")        │
                       │            REST /api/* + statisches Frontend (Svelte)     │
                       │                              │                            │
                       └──────── 127.0.0.1:9300 + 192.168.3.8:9300 (WireGuard) ────┘
                                                      │
                       Handy/Desktop (mobile-first SPA, PWA-Manifest, Charts)

Entscheidungen (ADR-Kurzform)

Stand 05.06.2026, Ende v1.6. ADR-1–10 aus v1.0, ADR-11+ aus dem Tagesverlauf.

  1. Stack: FastAPI + SQLite + Svelte 4/Vite. Python passt zur bestehenden Pipeline (lidl.py wird direkt importiert statt dupliziert); SQLite ist bei <10 Nutzern und <100k Zeilen die wartungsärmste Wahl (WAL-Modus, Indizes). Svelte liefert das geforderte Polish mit minimalem Runtime-Gewicht; Build auf dem Host (Node 22 user-lokal in ~/.local/node), Container braucht kein Node.
  2. Keine Authentifizierung, nur Netzwerk-Schutz. Erreichbar ausschließlich über localhost + WireGuard (wie Grocy). Familien-App: Nutzerwahl ist ein Avatar-Switcher (localStorage), kein Login. Frau-muss-es-lieben > Passwörter.
  3. Eigene DB statt Grocy-DB. Grocy läuft unangetastet weiter (Parallelbetrieb während der Evaluation). Silverscale importiert dieselben Quelldaten (Bons + Mapping) in ein eigenes, App-gerechtes Schema. Keine Live-Kopplung, kein Lock-Risiko.
  4. Tagebuch speichert Nährwert-SNAPSHOTS. Beim Loggen werden kcal/Makros aus dem Produkt berechnet und in den Eintrag kopiert – spätere Produkt- korrekturen verfälschen die Historie nicht.
  5. kcal-Budget = Basisbudget + Aktivitäten. Jede erfasste Aktivität erhöht das Tagesbudget („aufstocken"). Restbudget = Basis + Aktivität − gegessen.
  6. Vorräte: Auto-Befüllung nur ab Go-live (05.06.2026). Historische Bons sind verbraucht und dienen nur Preis-/Kaufstatistik; jeder Bon hat aber einen „in Vorrat übernehmen"-Button (idempotent pro Bon).
  7. Produkt-Identität: Lidl-Artikelnummer(n). foods.art_ids ist eine Liste – Lidl recycelt Artikelnummern über Sorten und vergibt mehrere pro Produkt (Erkenntnis aus der Grocy-Phase, siehe lidl-grocy/CLAUDE.md/HANDOFF.md). Seed aus lidl-grocy/lidl-artikel-mapping.json (1020 Artikel inkl. EAN/Nährwerte/Steuerklasse).
  8. Hash-Routing + PWA-Manifest, aber kein HTTPS. Über WireGuard gibt es nur HTTP → „App installieren" (A2HS) funktioniert eingeschränkt; die App ist trotzdem voll responsive mit Bottom-Nav. Wenn später gewünscht: Caddy mit interner CA davor.
  9. Charts: Chart.js (gebundelt, kein CDN – WireGuard-only-Betrieb). Schriften via @fontsource gebundelt (Fraunces Display, Inter Tight Text).
  10. Täglicher Import per Cron (07:45, nach dem Grocy-Sync): importer.py zieht neue Bons inkrementell über lidl.py (nur unbekannte Bon-IDs).
  11. HelloFresh zweistufig (v1.6): Lieferwochen über die interne /gw/-API mit dem kurzlebigen JWT (~30 min, data/hf-token.txt, Antwort gecacht in data/hf-deliveries.json) – Rezept-Inhalte tokenlos von den öffentlichen Rezeptseiten (__NEXT_DATA__). Begründung: Token-Beschaffung ist der fragile Teil; alles, was ohne geht, geht ohne. Sync ist cache-first und überlebt damit tote Tokens. Bilder über media.hellofresh.com/…/hellofresh_s3{path} (per Reverse-Engineering der Webseite ermittelt; img.hellofresh.com-Varianten werfen 404).
  12. Feste Nährwerte als GESAMT-Werte (dishes.nutrition, JSON): je Portion = gesamt ÷ servings. Bewusst NICHT je Portion gespeichert, damit unportionierte HF-Extras (z. B. 1800 kcal „für 4 Personen") durch bloßes Ändern der Portionszahl korrekt werden. Hat ein Gericht keine festen Werte, summiert dish_macros() über die Zutaten. HF-Gerichte sind über hf_id idempotent und nur 1× im Wochenplan zuweisbar (eine Box = ein Gericht); „als eigenes Gericht übernehmen" (clone, source='eigen') hebt die Beschränkung fürs Nachkochen auf.
  13. AI-Assistenz als Host-Job-Queue: Der Container kann kein claude aufrufen → Tabelle ai_jobs (kind/ref_id/payload/status/note) + minütlicher Host-Worker ai_jobs.py, der claude -p mit eng begrenzten Tools fährt (WebSearch/WebFetch/Read/Bash(curl:*) je nach Job-Art) und Ergebnisse ausschließlich über die API zurückschreibt. Job-Arten: portion (Haiku), ideas/photo/insights (Sonnet), steps (Haiku); dazu ai-resolve (geflaggte Artikel, Trigger-Datei) und dish-ai (Haiku-Naming). UI-Feedback ist Pflicht: globaler Poller → Sonar-Pill, Spinner-Cursor, Fertig-Toast, Auto-Refresh.
  14. Tagebuch-Einträge referenzieren optional dish_id/servings bzw. food_id/grams und bleiben trotzdem Snapshots: Beim nachträglichen Editieren rechnet der Server aus der Referenz neu; ohne Referenz (Freitext/Foto) bleibt der Eintrag fix. Plan-Einträge tragen consumed (= ins Tagebuch übernommen, Button verschwindet).
  15. Ein-Schreiber-Regel: Am 05.06. liefen kurz zwei Claude-Sessions parallel im Repo (doppelte Endpoints, konkurrierende Migrationsstände, Phantom-Spalten). Konsequenz: immer nur EINE schreibende Session; Merge-Aufwand > Parallelisierungs-Gewinn.
  16. Öffentliche Domain + Pocket-ID-Session-Auth (06.06.2026). Ersetzt ADR-2/8: App läuft jetzt unter https://silverscale.dennisfisch.de hinter Traefik v3 (Let's Encrypt per DNS-01/Cloudflare, traefik/), Pocket ID (Passkeys, https://id.silverscale.dennisfisch.de) als OIDC-Provider. Das Backend führt den Authorization-Code-Flow+PKCE selbst (backend/auth.py, public client, kein Secret) und setzt ein 180-Tage-HttpOnly-Cookie (sessions-Tabelle) – PWA-freundlich, kein Token-Handling im SPA. OIDC-sub → Familienprofil via oidc_identities (Heuristik beim Erstlogin: Name-Match). Host-Worker (ai_jobs, dish_ai, ai-resolve, Cron-curls, Importer-Push) umgehen die Middleware per X-Internal-Token (aus Repo-.env). Notausstieg: AUTH_DISABLED=1. WG-Zugang http://192.168.3.8:9300 bleibt als Netz-Fallback bestehen (anderer Origin → eigenes Login nötig). Grocy bleibt bewusst OHNE Traefik-Router (WireGuard-only).
  17. Web Push mit VAPID, minimaler Service Worker (06.06.2026). backend/push.py + pywebpush, Subscriptions in push_subscriptions (tote Endpoints werden bei 404/410 aufgeräumt; Senden ist überall try/except = darf nie etwas brechen). public/sw.js macht NUR push/notificationclick, bewusst KEIN Offline-Caching (Cache-Invalidierungs-Hölle vermeiden). Trigger: Test-Button (Einstellungen), Bon-Import (importer.pull → POST /api/push/broadcast mit Internal-Token, da der Importer auf Host UND im Container läuft) und ai_jobs-finish. iOS braucht die installierte Home-Bildschirm-PWA (≥16.4) und eine User-Geste für die Permission.
  18. Barcode-Scan: nativer BarcodeDetector + zxing-wasm-Fallback (06.06.2026). WebKit/iOS hat die Shape-Detection-API nie ausgeliefert (Bug #281848) – auf den iPhone-Hauptzielgeräten braucht es zwingend den Fallback. Gemeinsame Komponente components/ScanSheet.svelte (API-agnostisch, dispatcht nur scan({ean})) + Strategie in lib/scan.js: BarcodeDetector wenn vorhanden, sonst dynamischer Import von zxing-wasm/reader (eigener Chunk ~33 KB + ~940 KB .wasm, selbst gehostet via Vite-?url, kein CDN; lädt erst beim ersten Scan). Drei Einbauorte: Artikel-Detail (EAN hinterlegen), Artikel-Anlage per EAN, Bon-Positionen mit „EAN fehlt 📷". Beim Schließen immer alle MediaStream-Tracks stoppen (iOS hält sonst die Kamera).
  19. Einkaufsliste als persistente Wochen-Liste (06.06.2026). Tabelle shopping_items (week = Plan-Montag). POST /api/shopping/{week}/generate aggregiert Zutaten der geplanten Gerichte (Skalierung plan.servings/dish.servings; note-only-Zutaten: führende Zahl parsen, z. B. „2 Stück"). Merge statt Reset: abgehakte und manuelle Einträge überleben das Regenerieren, Auto-Einträge werden aktualisiert, obsolete nicht-abgehakte gelöscht. UI #/liste/{week} gruppiert nach Warengruppen-Sortierung; Vorrats-Hinweis + letzter Lidl-Preis je Item.
  20. Nachkoch-Preisvergleich mit Ehrlichkeits-Modi (06.06.2026). HF-Boxpreis kommt automatisch aus data/hf-subscriptions.json (productType.price/shippingPrice in Cent, specs={meals,size} → 61,00 € + 5,99 € / 5 Gerichte / 2 Portionen = 6,70 €/Portion; hellofresh.box_info()). Lidl-Seite je Zutat aus dem letzten Kauf ((total+discount)/qty): Modus kg = exakt (€/kg × Gramm), pack = 1 ganze Packung als Schätzung (Packungsgewicht unbekannt → kann überzeichnen, Olivenöl-Effekt), none = nie gekauft. UI zeigt Abdeckungsquote + Aufschlüsselung statt falscher Präzision.
  21. HF-Staples über das shipped-Flag (06.06.2026). Die öffentlichen Rezept-JSONs markieren je Zutat shipped: false für Basics (Salz, Öl, Butter …), die HF nicht liefert → dish_ingredients.is_staple. Staples erscheinen im Gericht nur als „🧂 Du brauchst zusätzlich"-Zeile und fliegen aus Einkaufsliste UND Preisvergleich (behebt den Olivenöl-Effekt aus ADR-20: Philly Cheesesteak 9,86 € → 2,69 €/Portion). Backfill für Bestands-Gerichte: hellofresh.backfill_staples() (refetcht die öffentlichen Rezeptseiten, tokenlos).
  22. HF-Dauerbetrieb via Auth0-Refresh-Token (06.06.2026). Der HF-Web-Client (azp B1n0Q24hv7e4AHc7yG1WwQyuMvpCAIya, hellofresh-live.eu.auth0.com) erlaubt grant_type=refresh_token ohne Secret (verifiziert). Refresh-Token einmalig aus dem Browser-localStorage (@@auth0spajs@@…body.refresh_token) ins Einstellungs-Token-Feld (Auto-Erkennung in /api/hf/token) → data/hf-refresh-token.txt (chmod 600). Rotation aktiv: jeder Refresh liefert einen neuen Refresh-Token, refresh_access_token() persistiert ihn VOR der Weiterverarbeitung (write-before-use, Lidl-Muster). _load_weeks() refresht automatisch bei totem Access-Token; Cache bleibt Fallback. Risiko: erneuter HF-Browser-Login kann die Token-Family revoken → einmalig neu extrahieren. Nur das Backend refresht (Ein-Schreiber).

  23. Plan↔Tagebuch-Verknüpfung (06.06.2026). diary.plan_id koppelt übernommene Plan-Einträge ans Tagebuch: Löschen des diary-Eintrags ODER POST /api/plan/{id}/uneat setzt meal_plan.consumed=0 zurück.

  24. Scan-Ergebnis-Popup + off-preview (06.06.2026). GET /api/foods/off-preview?ean= macht den OFF-Lookup ohne DB-Write (muss vor der int-Route /api/foods/{food_id} stehen); ScanResultPopup.svelte zeigt Kerndaten mit Countdown-Auto-Akzeptieren (Verwerfen/Anpassen/Akzeptieren) in Bon- und Stammdaten-Scan. FoodSheet bekommt einen 📷-Scan ins kcal-Tracking (by-ean → Portionsschritt).
  25. meal_plan: Artikel + Favoriten-Sortierung (06.06.2026). meal_plan per Rebuild auf nullable dish_id + food_id (Artikel im Plan, Makros je Portion aus foods). dish_favorites analog zu favorites. GET /api/plan-pick sortiert HF-Woche → Favoriten → zuletzt → häufigste; /api/foods-Ranking um Häufigkeit/Recency erweitert.
  26. HF-Feedback-Import + Markdown-Schritte (06.06.2026). Das feedback- Objekt der past-deliveries-Antwort (favorite/rating 1–4/comment) und aggregateRating/aggregateRatingsCount aus dem öffentlichen Rezept-JSON landen in dishes (hf_favorite/hf_rating/hf_comment/hf_agg_*). backfill_hf_feedback() zieht Bestand nach. Gerichte-Seite: HF-Woche oben, Liebling-/Bewertungs-Chips, „reine HF ausblenden"-Toggle. Zubereitungsschritte: marked (Markdown→HTML), auto-wachsende Textarea, Edit/Vorschau pro Schritt. (PVG-Robustheit: Rabatt-bedingt negative eff_price fällt auf den Listenpreis zurück.)

  27. AI-Flow: 2-s-Pickup statt Minuten-Cron (06.06.2026). Gemessen: claude -p selbst braucht nur 5–8 s (Haiku) — der Engpass war der minütliche Cron (0–60 s Pickup) + 5-s-Polling (worst case >90 s). Fix: Worker-Skripte laufen als 55-s-Schleife mit 2-s-Takt (Cron hält sie am Leben, flock verhindert Überlappung), Frontend pollt adaptiv (1,5 s busy / 8 s idle). Crontab hat jetzt eine PATH-Kopfzeile (~/.local/bin) — ohne sie fand cron claude nicht (Ursache stiller ideas/insights-Fehler). Neue Job-Art estimate (Freitext → kcal/Makros, Haiku): end-to-end 10,9 s gemessen. Weitere Hebel, falls nötig: persistenter Worker-Daemon (spart CLI-Startup), synchroner OpenRouter-Pfad (~2–5 s), SSE statt Polling.

  28. Gericht-Historie + Wir-Modus (06.06.2026). /api/dishes/{id}/history (Tagebuch- + Plan-Einträge je Gericht, Deeplinks: Diary/Plan akzeptieren Route-Params). meal_plan.shared: „👫 Gemeinsam für heute" plant 1 Portion je Profil; „essen" loggt in JEDES Tagebuch (alle Einträge mit plan_id → zurücknehmen räumt beide). Außerdem: Geld-Charts ohne Null-Linie (beginAtZero:false + grace:'15%' — Preisspannen sichtbar), Bon-Deeplink mit Scroll+Puls (#/bon/{id}/{food_id}), EAN-Korrektur (barcode-POST replace:true, amber Affordance), Einkaufslisten-Panel „Zuletzt verwendet" + freies Löschen + Plan-Markierung.

  29. AI-Fortschritt mit echten Stages (06.06.2026). Recherche-basiert (NN/G-Limits, Buell/Norton „Labor Illusion", Harrison CHI 2010, Cloud Four): determinate Donut statt Spinner, Füllung an ECHTEN Stage-Wechseln verankert, monoton, Deckel 95 %, Overrun = Puls + rotierende Texte (nur transform/opacity = GPU). Pipeline: Worker meldet picked via POST /api/ai/jobs/{id}/stage (Internal-Token); claude() läuft mit --output-format stream-json --verbose und meldet model (system/init = CLI bereit) und gen (erstes assistant-Event = TTFT); ai_jobs.stages (JSON) + done_at; expected_s = rollierender Ø der letzten 5 done-Jobs je kind (lernt mit). AiProgress.svelte ersetzt alle Hand-Poller (FoodSheet/Dishes/Dish/Stats/Article). Außerdem: Dark-Theme-Override für das Tint-Paar --ss-aqua-100/700 — ohne ihn waren Hover-/Aktiv-Flächen im Dark fast weiß bei hellem Text (Pantry-Stepper, Settings-Pfeile …).

  30. Aktivitäten-Strang: zwei Quellen, eine UI (06.06.2026). Statistik bekommt Geschwister: ein eigener „Aktivitäten"-Screen mit eGYM (Studio + InBody-Waage) und Garmin (Index S2 + Outdoor-Aktivitäten). Quellen-Aufteilung — Knochenmasse NUR Garmin (eGYM-FITHUB hat sie nicht), Skelettmuskel/viszerales Fett/Bio-Alter NUR eGYM, Gewicht/BMI/%Fett/%Wasser von BEIDEN (FITHUB = Ground Truth, Garmin füllt die Tages-Lücken). Outdoor-Sport NUR Garmin, EGYM-Geräte NUR eGYM. Auth: Garmin via garminconnect 0.3.5 (native curl_cffi, kein garth – upstream deprecated, ~1 Jahr OAuth-Refresh aus app/data/garmin//). eGYM via verifizierter Kette aus RECHERCHE-EGYM.md: Netpulse-Login (Passwort Fernet-verschlüsselt je Profil in app/data/egym//creds.enc) → JSESSIONID → tokens/FLS → MWA-Bearer. MWA-Token-Cache 5 min Buffer (Netpulse liefert oft Token mit ~2 min Restleben zurück → kleinerer Buffer → 401 beim ersten Call). Schema: vier neue Tabellen plus statischer Geräte-Katalog – idempotent über die nativen externen PKs (eGYM id, Garmin samplePk, eGYM checkInDate). Einheits-Falle: Garmin liefert weight/boneMass/muscleMass in Gramm, wir normalisieren beim Import auf kg. „Besuch" rekonstruieren wir tagsüber aus Check-in + Strength- Messungen (Cluster ±3 h um den Check-in; Sets ohne passenden Check-in werden zu Auto-Besuchen). Sync: zwei eigene Threading-Worker (/api/egym/sync, /api/garmin/sync) parallel zum HF-Muster; Cron 06:15 / 06:25 vor HF-08:10. UI „Aktivitäten" (Phase 2): Besuche-Liste → Besuchs-Detail mit Geräten → Geräte-Detail mit Verlauf + „Besuche, bei denen das Gerät genutzt wurde" (analog Artikel↔Bon-Beziehung). Körperdaten-Charts: FITHUB-Punkte als Sterne, Garmin-Punkte als kleine Dots; Knochenmasse separat als „nur Garmin". UI-Falle: strength/latest-measurements zeigt das letzte je Gerät auch wenn 1 Jahr alt – „zuletzt vor X" muss explizit angezeigt werden. Umgesetzt (Phase 2): Screen Activity.svelte (Tabs Heute/Besuche/Geräte/ Körper/Outdoor) + Visit.svelte + Machine.svelte; Endpoints /api/activity/* inkl. gebündeltem /today (Snapshot+Delta+Streaks+Bio+Pokale). Zwei hart erarbeitete Fakten: (a) eGYM hat ZWEI activityId-Familien je Gerät – strength (994–1012, seltene Krafttests) UND workouts (329–592, jedes reguläre Training); per deutschem Label (case-insensitiv!) auf den Kanon-PK gemappt. Die Arbeitssätze (Gewicht×Reps) sind die Motivations-Grundlage zwischen den seltenen Messungen (Pokal/Stern bei Steigerung). (b) cur.lastrowid nach INSERT OR IGNORE liefert bei IGNORE einen STALE rowid (nicht None) → beim Re-Sync falsche FK-Verknüpfung/Crash; immer cur.rowcount prüfen bzw. den PK frisch SELECTen. Sync-Fenster 365 Tage (Verlaufs-Calls sind fensterunabhängig gleich teuer). (c) Besuche werden pro Kalendertag geclustert (ein Studiobesuch = ein Tag), NICHT per ±3h-Fenster – das riss einen Besuch auseinander (Check-in 06:30, Training bis 09:36 > 3h → zwei Phantom-Besuche). Tür-Check-in markiert via ext_checkin_id. (d) Detail-Endpoints MÜSSEN nach user_id filtern (auch in Sub-Queries wie der „Besuche je Gerät"-Liste) – sonst Cross-User-Leak (Jaqueline sah Dennis' Gerätedaten). (e) Aktiver Tab des Aktivitäten-Screens steckt in der URL (#/aktivitaet/<tab>), damit history.back() aus Detailseiten zum richtigen Tab zurückführt.

  31. PWA-Kamera-Falle: Foto-Fallback statt hängendem getUserMedia (06.06.2026). Ergänzt ADR-18. In der installierten Home-Screen-PWA rendert iOS den Kamera-Permission-Prompt oft nicht (WebKit #185448, mit Hash-Routing zusätzlich #215884): getUserMedia bleibt ewig pending (kein reject!) und der unsichtbare System-Dialog blockiert alle Touches – die App war nur noch per Swipe-up beendbar. Dreifach-Abwehr in ScanSheet.svelte: (a) im standalone-Modus mit permissions.query({name:'camera'})==='prompt' getUserMedia gar nicht erst aufrufen, (b) sonst Timeout-Race 6 s mit Spät-Resolve-Cleanup, (c) Fehlerzustand bietet „📷 Foto aufnehmen": <input capture="environment"> öffnet die NATIVE Kamera (keine Web-Permission nötig, funktioniert in der PWA immer), Foto-Blob geht direkt in readBarcodes() (zxing-wasm 2.x nimmt Blobs). In Safari selbst fragt iOS pro Sitzung neu, solange die Website-Einstellung auf „Fragen" steht – Abhilfe: aA-Menü → Website-Einstellungen → Kamera → „Erlauben" (gilt vermutlich auch für die PWA → dann wieder Live-Scan statt Foto).

  32. eGYM/Garmin-Aktivitäten automatisch ins Tagebuch (06.06.2026). Der Sync legt je Trainingsbesuch (eGYM) bzw. je Aktivität (Garmin) einen activities-Eintrag an (= stockt das kcal-Budget auf, ADR-5), idempotent über (source, ext_ref). Aktive kcal: Garmin zählt nur calories − bmrCalories (Grundumsatz raus), nie negativ; gespeichert als garmin_activities.active_calories. Doppelzählung vermieden: trägt man beim eGYM-Training die Garmin-Uhr, loggt Garmin eine Indoor-Kraft-Aktivität – die wird an Tagen mit eGYM-Besuch NICHT ins Tagebuch übernommen (eGYM ist Studio-Quelle), bleibt aber im Outdoor-Tab. Verknüpfung: Tagebuch- Einträge linken zu ihrem Ursprung – eGYM → #/besuch/{visit_id}, Garmin → Outdoor-Tab + externer Link connect.garmin.com/modern/activity/{id} (in activities.ext_url). Sync-Tiefe auf 900 Tage (alle Daten ab ~Mitte 2024). Wunschgewicht je Profil (users.target_weight_kg); Heute-Delta-Zeitraum konfigurierbar (/today?delta_days=).

  33. Anzeigenamen-Schicht foods.display_name (06.06.2026, UX-Audit Block 2). Bon-Rohnamen („CocaCola light … zzgl. Pfand 0,15 €") bleiben als foods.name (Matching/Referenz) erhalten; die UI zeigt überall COALESCE(display_name, name) – zentral in row2food() (liefert name=Anzeige, raw_name=Original) plus 6 Label-Queries (Journal, Einkaufsliste, Vorschläge, PVG, Statistik). Tagebuch-Snapshots nutzen ab sofort den Anzeigenamen; Alt-Snapshots hebt POST /api/admin/backfill-diary-names an. Generierung: app/polish_names.py (Host, Haiku-Batches à 40 via claude -p, schreibt per PATCH über die API; --groups verteilt „Sonstiges" auf bestehende Gruppen). Artikel-Seite editiert den Anzeigenamen, Bon-Name steht als Hinweis darunter.

  34. UX-Härtung aus dem Audit vom 06.06.2026 (Blocks 1–4, siehe UI-UX-AUDIT-2026-06-06.md). Tagebuch-Löschen ist undo-bar (DELETE liefert die Zeile, POST /api/diary/restore setzt sie inkl. id + plan_id-Konsumstatus wieder ein; Toast mit „Rückgängig"-Action). ConfirmDialog.svelte + confirmDialog.ask() ersetzen window.confirm. Alle Submit-Pfade haben saving-Locks. Pydantic-Felder mit gt-Grenzen (keine negativen Gramm/kcal). visibilitychange: >60 s weg → Daten-Bump, >30 min → Reload. eGYM-Falle: Netpulse meldet abgelaufene Sessions als 403 (nicht 401) – Auth-Retry in egym.py behandelt beide; der Sync-Button pollt /api/activity/status und zeigt Worker-Fehler als Toast.

  35. iPhone-Feedback-Runde 06.06.2026 abends (Wellen A–D). Tagebuch: JEDER Eintrag editierbar (auch Schnelleinträge: Name/kcal/Makros direkt), Mahlzeit+Uhrzeit je Eintrag verschiebbar, Quell-Badges (📦/🍽/✏️/HF). Essen-Scan unbekannter EANs: OFF-Vorschau-Popup statt stillem Anlegen (off-preview liefert known+food_id für den Schnellpfad). PVG: foods.packaging_weight_g + Preis-Modus anteilig. Router merkt sich Scroll-Positionen; Sheets konsumieren Browser-Back via pushState/popstate. InBody-Grundumsatz kJ→kcal (÷4,184). Latenz-Benchmark claude -p: Floor ~2,5–3 s pro Call (CLI 0,1 s + API-Session), modellunabhängig; -r-Resume bringt NICHTS; stream-json ~+1 s vs text; realer Haiku-Job 6–7 s. Queue-Pickup ist via ADR-27 schon ≤2 s → unter ~7 s Gesamt kommt man nur mit direkter API (OpenRouter/Anthropic-Key, Kosten bei unserem Volumen <1 €/Monat) – bewusst vertagt. Food-DB-Recherche: OFF bleibt (Lücken = einzelne EANs, nicht die DB; Schwester-EANs + USDA FDC + KI-Fallback; OFF-Write-Back als Roadmap). Bildgen-Recherche: Nano Banana 2 (gemini-3.1-flash-image-preview, ~0,067 $/1024px-Bild, i2i mit Referenzbildern) via direktem Google-Key empfohlen – wartet auf API-Key-Entscheidung.

  36. Feedback-Runde 2 am 06.06.2026 nachts (Wellen 1–5). Vorrats-Konzept (bestätigt): ein Logging-Pfad (FoodSheet) überall – Artikel-Seite öffnet FoodSheet mit presetFood; Checkbox „🪸 vom Vorrat abziehen" (nur sichtbar bei Bestand, Default AN); Abzug serverseitig via DiaryIn.deplete_stock/DishLog.deplete_stock + _deplete_stock() (kg→g/1000, Stück+packaging_weight_g→g/Packung, sonst 1 Stück, nie negativ, inv_log reason='gegessen'; Gerichte anteilig × Portionen ohne Staples). Dabei gefundener Bug: inventory/adjust rief add_inventory ohne unit → Pantry-Stepper warf 500. Preise: foods.manual_price_eur (Nicht-Lidl-Artikel) als _last_price-Fallback; Gericht-Detail liefert je Zutat Preis/Modus + Vorrats-Ampel (ok/some/none) + Kosten-Summe; PVG-Items ebenso. polish_names.py --pack befüllt Packungsgewichte (OFF product_quantity → Namens-Regex → Haiku, konservativ). HF-FALLE (Root Cause, nicht erneut suchen): Die ÖFFENTLICHE Rezeptseite redirected Gerichte mit Thermomix-Schwester für ALLE Varianten-IDs auf die Thermomix-Seite. Varianten-treues Rezept NUR über GET /gw/api/recipes/{id} (Token). match_food war beidseitiges Substring-Matching → „Wasser"→„Fruchtgummi Wassermelone", „Zucker"→„Coca-Cola Zero"; jetzt exakt (Name+display_name) + Basics-Stopliste + längenbegrenzter Substring. Reparatur/Prüfung: /api/admin/hf-repair + /api/admin/hf-audit (19/19 repariert, Audit 0). OpenRouter-Benchmark (BENCHMARK-OPENROUTER-2026-06-06.md): gemini-3.1-flash-lite 1,1 s wall vs claude -p haiku 4–10 s; Switch empfohlen, wartet auf Go.

  37. OpenRouter-Migration + Bild-KI (06.06.2026 nachts, beide mit Go). ai_jobs.py: or_chat()/llm_json() rufen google/gemini-3.1-flash-lite via OpenRouter (Key in Wurzel-.env, jetzt auch Container-Env) – Worker- Durchlauf für estimate von ~7-11 s auf 2,0 s; photo läuft als echte Vision (base64 statt Read-Tool, 3 s). Jeder Pfad fällt bei Fehlern transparent auf die claude-CLI zurück (Stages model/gen bleiben). AUSNAHME: insights bleibt CLI-Sonnet (agentic, braucht curl-Tools). dish_ai.py + polish_names.py nutzen denselben Weg. Bild-KI (Feedback 13): POST /api/ai/dish-image/{id} (mode generate|enhance) ruft google/gemini-3.1-flash-image-preview (Nano Banana 2, ~0,07 $/Bild) BLOCKIEREND (~10 s), skaliert per Pillow auf 512 px JPEG und legt eine tmp-Vorschau an; /decide übernimmt (dishes.image) oder löscht. UI: 🪄-Badge auf dem Gericht-Bild, Vorschau-Dialog mit Verwerfen / 🎲 Nochmal / Übernehmen – nichts wird ohne Bestätigung gespeichert. Prompts: Generate aus Titel+Zutaten (engl. Food-Fotografie-Stil), Enhance = i2i mit Erhalt des Gerichts.

  38. HF-Generika-Mapping + Geschäft-Feld (07.06.2026 früh). HF-Zutaten („Naturjoghurt 1,5%") werden kuratiert auf Stammdaten-Artikel gemappt: foods.hf_aliases (JSON-Array der abgedeckten HF-Namen) ist Stufe 0 in match_food -> einmal zugeordnet, greift es bei jedem künftigen Import/Repair automatisch. dish_ingredients.hf_name bewahrt den HF-Originalnamen; UI zeigt „HF-Name → Artikel" klein in Zutaten + PVG. BEWUSST KEIN Automatismus (Nutzer-Entscheidung): Button „🔗 zuordnen (N)" am Gericht -> POST /api/dishes/{id}/map-suggest (Gemini flash-lite bekommt den GANZEN Katalog, ~1,5 s, max 3 Vorschläge je Zutat) -> Dialog mit Chips + Artikel-Suche; Vorauswahl = bester Vorschlag, NICHT bei Staples (sonst würde „Wasser"->Mineralwasser durchrutschen). map-apply setzt Alias, hängt Zutaten GLOBAL um (WHERE hf_name=?) und löscht referenzlose Generika-Platzhalter. foods.store („Geschäft"): Migration setzt Bon-Artikel auf 'Lidl'; UI-Feld mit Datalist am Artikel; Einkaufsliste zeigt Badge bei Nicht-Lidl (MyProtein, Kaufland …). Dialog v2 (Feedback-Iteration): zeigt ALLE HF-Zutaten – zugeordnete als kompakte ✓-Zeile („ändern" lädt Gemini-Chips on-demand per Einzel-Call), Basics-Toggle (default ausgeblendet). Eskalations-Kaskade je Zutat: Gemini-Chips → lokale Suche → 🌐 OFF-Suche (POST /api/foods/off-search, Search-a-licious zweistufig mit stores:"lidl" zuerst) → Anlage über den bestehenden by-ean-Flow + store='Lidl' + direkt als Zuordnung gewählt. Damit sind auch nie gekaufte Artikel zuordenbar (Beispiel kuratiert: HF „Kokosmilch" → Freshona Kokosmilch, EAN 4056489367611, 177 kcal aus OFF).

  39. Artikel-Split + Namens-Präzedenz im Importer (07.06.2026 nachts). Lidl vergibt teils EINE artId für mehrere Sorten (Protein-Pudding Vanille+Schoko liefen als ein Artikel, Fund: Artikel 568). Lösung: GET /api/foods/{id}/bon-names (distinct Bon-Namen + Counts) + POST /api/foods/{id}/split (gewählte Bon-Namen -> NEUER Artikel, Positionen ziehen um; Makros als Startpunkt kopiert, EAN/display_name bewusst nicht; mind. ein Name bleibt). UI: ⚠️-Chip an der Bon-Sektion, sobald >1 Bon-Name, mit Checkbox-Dialog. Dauerhaft trennt der Importer: ensure_food prüft den exakten Bon-Namen VOR dem artId-Mapping – sonst wachsen gesplittete Sorten beim nächsten Import wieder zusammen. foods/{id}/receipts aggregiert pro Bon (zwei Positionen eines Bons erschienen sonst als Doppel-Eintrag). Real gesplittet: 568 = Pudding Schoko, 1076 = Pudding Vanille.

  40. Geräte-Code-Login für die Capacitor-App (07.06.2026). WKWebView kann kein WebAuthn (Passkey-Aufruf hängt statt sauber zu scheitern) → die Einstellungen erzeugen einen 8-stelligen Einmal-Code (10 min gültig, Tabelle device_codes, Alphabet ohne 0/O/1/I), /api/auth/login liefert bei User-Agent SilverscaleApp eine Code-Eingabeseite statt des Pocket-ID-Redirects, /api/auth/device-login setzt die normale 180-Tage-Session. device-code prüft die Session selbst (liegt unter dem Middleware-freien /api/auth/*). ios-shell: app/ios-shell/ (Capacitor-Remote-Modus, lädt die Domain live; IPA via SideStore — Spec in docs/superpowers/specs/, Build-Anleitung im README dort).
  41. Phase 0 „Jaqueline-Gate" – App-Härtung fürs iPhone (07.06.2026, Frontend-Audit ui-audit/cap-audit-2026-06-07/). Die Capacitor-App ist der primäre Nutzungsweg; die Nutzerin soll nie merken, dass eine Website darunter liegt. Frontend: globaler Klick-Interceptor für externe Links (lib/native.js – in der App via Capacitor-Browser-Plugin als Safari-Sheet, sonst _blank; behebt WKWebView-Kaperung durch Garmin/OFF-Links) · isApp-Erkennung über UA SilverscaleApp (Einstellungen: PWA-Push-Hinweis ersetzt, Geräte-Login-Block versteckt) · Touch-Semantik: user-select/touch-callout aus auf UI-Chrome (Inputs/ .selectable/pre/code bleiben selektierbar), ALLE 29 :hover-Regeln hinter @media (hover: hover) (klebten sonst auf Touch) · Sheet-Dirty-Check (Sheet.svelte Prop dirty: Backdrop/✕/Escape/ Zurück-Geste fragen „Eingaben verwerfen?"; popstate pusht den History-Eintrag zurück, solange nicht bestätigt; genutzt von FoodSheet, ActivitySheet, Tagebuch-Edit) · Profilwechsel-Toast + gedimmte inaktive Avatare (gegen Falsch-Profil-Tracking) · Plan-Löschen mit Undo-Toast (Re-POST aus bekannten Feldern), Einkaufslisten-Haken mit Rollback bei Netzfehler. ios-shell (EIN gebündelter IPA-Rebuild nötig, Anleitung im README): gebrandetes Icon+Splash aus public/icon.svg generiert (vorher Default-Capacitor-Logo!), Portrait-Lock, Swipe-Back (allowsBackForwardNavigationGestures im AppDelegate, auf dem Gerät gegen die Login-History testen), Plugins Browser/Haptics/StatusBar/ SplashScreen/App vorinstalliert (Nutzung kommt per Frontend-Deploy), backgroundColor #0d2438 gegen den weißen Lade-Blitz.
  42. Phase 1 „Native-Feinschliff" (07.06.2026, Audit-Roadmap). Netz-Robustheit: api.js mit 15-s-Timeout (AbortSignal PRO VERSUCH neu – ein abgelaufenes Signal bleibt abgebrochen), GETs 3 Versuche mit Backoff (300/900 ms) bei Netzfehler/5xx, Schreibzugriffe bewusst ohne Auto-Retry (nicht idempotent; Sheet bleibt offen, Mensch wiederholt); Fehlertexte in Menschensprache (📡/⏱/🌊), 4xx behält das Server-Detail (Pydantic). Hintergrund-Rückkehr >30 min lädt nur noch neu, wenn das Bundle sich geändert hat (Asset-Hash aus / vs. eingebettetes <script src>); sonst Daten-Bump. Skeletons (.skel-Shimmer in app.css) für Heute/Tagebuch/ Plan statt leerer Fläche; 150-ms-Crossfade bei Seitenwechseln ({#key $route.page} + in:fade, Key bewusst OHNE Param – Tage blättern bleibt in-place). Haptics an 6 Kernaktionen (Essen/Aktivität gebucht, Listen-Haken, Scan-Treffer, Plan-essen, Profilwechsel) über haptic()/native.js – No-Op bis die IPA das Plugin enthält, genau wie der StatusBar-Theme-Sync in stores.js (Style.Dark = helle Schrift). Hülle: www/offline.html (gebrandet, Auto-Retry bei online) via server.errorPath – statt weißer Wand beim Offline-Kaltstart; fährt im Phase-0-IPA-Rebuild mit. F26 falsifiziert: Statistik-Charts rendern korrekt; die „leeren" Flächen im Audit waren ein Artefakt der Full-Page-Screenshots (Chart.js-Resize während des Shots) – Charts in Sweeps nur im Viewport beurteilen. Offline-SCHREIB-Queue bewusst NICHT gebaut (Offene Frage 3 im Audit, erst Alltagserfahrung sammeln).
  43. Gerätetest-Welle 1 (07.06.2026, 19+3 Funde von Dennis' iPhone 16). WICHTIGSTE ERKENNTNIS: Die native WKWebView-Zurück-Geste (ADR-41) ist mit Hash-SPA + pushState-Sheets unbrauchbar – sie rendert VERALTETE Snapshots (Bounce/„Reload"-Flackern, #1) und raced mit dem Sheet-Dirty-Check bis zur sekundenlang eingefrorenen App (#9). Lösung: Geste nativ wieder AUS, appendUserAgent SilverscaleApp/2, Frontend aktiviert ab UA-Generation 2 einen eigenen JS-Edge-Swipe (initEdgeSwipeBack, history.back() ab 70 px vom linken Rand) – mit IPA v1 doppelt sich nichts. Router kennt jetzt nav: go|pop; bei pop kein Crossfade (Doppel-Animation). Weitere Funde: Sheet-Grip war tote Affordance -> Drag-to-dismiss (folgt dem Finger, >90 px schließt, dirty -> zurückschnappen + Dialog) + Body-Scroll-Lock (position:fixed-Muster, Zähler für Sheet-Stacks, #2/#15) · FoodSheet-Autofokus nur noch bei (hover:hover) – iOS-Keyboard schoss die View raus (#3) · Haptik-Sweep (Löschen/Undo/Einplanen/Edit, #4) · clip() für Namen in Toasts + Toast max. 2 Zeilen (#5) · Kochideen-Panels: Kontur + nowrap-Chip (#6/#7) · ConfirmDialog-Buttons gleich breit mit klaren Konturen im Dark (#8) · offline.html: Fisch schwimmt, Retry mit Feedback (#10) · touch-action: manipulation global gegen Doppeltipp-Zoom, Pinch bleibt (#11) · online-Event bumpt dayVersion + ShoppingList hört darauf (#12) · Chart.js-Animation 350 ms easeOutCubic (#18) · Bottom-Nav 72->60 px (iPhone 16, #19) · Heute-Fitness-Karten deep-linken auf ihre Metrik via Param koerper/<TYP> (#16) · Sync-UI: ZWEI unabhängige Status-Pillen (eGYM/Garmin je run/ok/err, Lichtstreif statt Dreh-Icon – zwei Systeme, keine atomare Aktion, #17) · Plan scrollt einmalig zum heutigen Tag (zwei Anläufe wegen nachladender Bilder, #22) · Garmin-Links via AppLauncher-Plugin -> Garmin-Connect-APP statt Browser (#14, IPA v2). Prozess-Entscheidung: Jede Feedback-Welle bekommt einen here.now-Testplan (interaktive Checkliste, localStorage).
  44. Gerätetest-Welle 3 (07.06.2026 nachts, 4 Funde). Route-Level-Fade (ADR-42) wieder ENTFERNT: {#key}-Remount + Fade + Skeleton + Karten- fade-in schichteten sich zu wahrnehmbarem Flackern bei jedem Tab-Wechsel – Seiten wechseln jetzt instant, nur die Karten blenden. Lehre: EINE Animationsebene pro Übergang. Skeletons erscheinen erst nach 250 ms (skelShow-Delay): schnelle Antworten flackern nicht mehr Skeleton->Inhalt, langsame behalten den Platzhalter. Grip-Drag verwirft Eingaben jetzt bewusst OHNE Rückfrage (die Geste ist die Entscheidung); Dirty-Dialog bleibt für Backdrop/✕/Escape/Zurück-Geste. Bottom-Nav 52 px + Safe-Area um 6 px angeknabbert (72->60->52, iPhone 16). Dark-Mode: --ss-primary-ink ist im Dark jetzt dunkle Tinte auf hellem Aqua (weiß auf aqua-400 war ~2,5:1, Fund „Als Gericht übernehmen"); .btn.small auf 650er-Gewicht.
  45. Phase 2 „Delight & Hygiene" (07.06.2026, Audit-Roadmap, Teil 1). Mehr-Menü + Desktop-Sidebar auf 7 EIGENE SVG-Stroke-Icons im Stil der Haupt-Tabs (Audit F22: Emoji/SVG-Stilbruch; Emojis bleiben bewusst Inhalts-Sprache für Profile/Mahlzeiten/Metriken). A11y (F23): Sheets fokussieren sich beim Öffnen (tabindex=-1), Tab-Falle hält Tastatur/ VoiceOver im Dialog, Fokus kehrt beim Schließen zum Öffner zurück; ConfirmDialog ebenso (Default-Fokus auf „Abbrechen"); Toast + KI-Pill mit role=status/aria-live; Mehr-Menü schließt auf Escape. Token-Hygiene (F24): @keyframes sonar EINMAL global (--sonar-c je Element) statt 3 Kopien; --ss-amber-ink ersetzt 3× #3a2c00. Sheet-✕ auf 44 pt (F10). Onboarding-Stups (F20): Heute zeigt „🎯 Makro-Ziele festlegen"-Chip, solange ein Profil keine Ziele hat. Bewusst NICHT in dieser Runde: Foto-Vorschau vor KI-Buchung (F15, braucht Backend-Konzept: Job bucht direkt), Pull-to-Refresh (Offene Frage 4), .row-Vereinheitlichung (Risiko > Nutzen).
  46. Welle 5: Zoom-Sperre + nativer VisionKit-Scanner (07.06.2026 abends). Pinch-Zoom-Falle: touch-action:manipulation (ADR-43) hatte Doppeltipp- Rauszoomen deaktiviert, Pinch-REIN ging aber noch -> man saß im Zoom fest. Viewport jetzt maximum-scale=1, user-scalable=no – sperrt NUR die WKWebView (Safari/PWA ignorieren es für A11y, bleiben zoombar). Nativer Scanner (aus der Native-iOS-Recherche, Option B): ios-shell/.../NativeScanner.swift – Capacitor-Plugin um VisionKits DataScannerViewController (EAN-8/13/UPC-E, Highlighting, Abbrechen- Overlay, Erfolgs-Haptik, Swipe-Dismiss = cancelled). Registrierung über packageClassList + manuell ins pbxproj (4 Einträge analog AppDelegate). ScanSheet versucht ZUERST die Bridge (Plugins.NativeScanner), fällt ohne Plugin (alte IPA, Web, Desktop) automatisch auf den zxing-Web- Scanner zurück – ein Erkennungsweg, alle Scan-Stellen abgedeckt, kein UA-Versionssprung nötig. Braucht IPA v3 (README). Doku-Archiv: Build injiziert „‹ Archiv"-Pill in HTML-Artefakte (Testpläne hatten keinen Rückweg); Makro-Stups-Chip mit Luft.
  47. Nativer Scanner LÄUFT + Apple Developer Account (07.06.2026 abends). Der VisionKit-Scanner brauchte DREI Anläufe – alle drei Fallen sind Capacitor-Eigenheiten (Details im ios-shell-README, nie wieder suchen): (1) packageClassList in capacitor.config.json wird von cap sync bei JEDEM Lauf mit der npm-Plugin-Liste überschrieben – lokale Klassen fliegen raus. (2) registerPluginType() in capacitorDidLoad ist bei aktivem autoRegisterPlugins (Default) ein STILLES No-Op (CapacitorBridge.swift:337). (3) Funktionierender Weg: registerPluginInstance(NativeScannerPlugin()) in einer CAPBridgeViewController-Subclass (MyViewController, Main.storyboard zeigt darauf). Außerdem: DataScannerViewController.isAvailable ist still false bei Kamera-Berechtigung .notDetermined -> Plugin fragt via AVCaptureDevice.requestAccess selbst. Diagnose-Helfer: Einstellungen- Stand-Zeile zeigt „Scanner: nativ/web". Gerätetest Welle 5: alles grün. Dennis hat den Apple Developer Account (99 €/Jahr) gekauft und aktiviert, App-Store-Connect-Zugriff vorhanden. Konsequenzen (Recherchen im Archiv): Ad-hoc-Signierung (1-Jahres-Profile, vertraglich „use" für Familie per DPLA §7.3) kann SideStore/7-Tage-Refresh ablösen; APNs/Live Activities/Widgets/watchOS sind jetzt freigeschaltet; TestFlight nur als Beta-Kanal (Individual-Account: Familie wäre External-Tester mit Review).
  48. Teilverbrauch sichtbar + Fly-Animationen (07.06.2026 nachts, Design-Doc #24/#25 aus dem Archiv). (a) Vorräte zeigen je Packungs-Artikel (unit != kg + gepflegtes packaging_weight_g) einen Füllbalken: Modell = EINE offene Packung pro Artikel, der Nachkommaanteil von inventory.qty ist ihr Füllstand („~250 g von 400 g", „angebrochen"-Badge, Bernstein unter ¼ Gesamtbestand). KEIN Schema-Umbau – reine Darstellung + Abzugslogik. Stepper „−" fragt bei Packungs-Artikeln nach (¼/½/Rest/ganz/freie Gramm, Journal-reason entnommen), statt stumm 1 ganze Packung abzuziehen; kg-Artikel unverändert. Beifang: POST /api/inventory/consume war seit jeher kaputt (Argument-Shift in add_inventory: "verbraucht" rutschte in unit, reason=None -> NOT-NULL-500; null „verbraucht"-Einträge im Log bewiesen es) – gefixt. (b) lib/fly.js: Ziel-Registry per Svelte-Action use:flyTarget={'name'} + flyValue(fromRectOderEl, ziel, label, {color}) – fixed Chip, WAAPI-Flug 450 ms Haus-Kurve mit leichtem Bogen, flypulse am Ziel, prefers-reduced-motion -> No-Op. Quell- Rect VOR dispatch('close') sichern (Sheet unmountet). Genau VIER Flüsse (Dosierung ist Teil des Designs): Essen loggen -> +kcal zum Tagesring (Zahl tickt via svelte/motion tweened, JS-Animation muss reduced-motion SELBST prüfen – app.css greift nur bei CSS), Vorrats- abzug -> −g-Chip zur Artikel-Zeile (Balken sinkt per width-Transition), Liste abhaken -> ✓ zum „offen"-Zähler, Aktivität -> +kcal kelp zum Ring. Fehlt das Ziel (Tagebuch-Log ohne sichtbaren Ring) passiert nichts – Toast bleibt die Quittung (jetzt mit echten Gramm beim 🪸-Abzug). Ring-Zählrichtung wie im Design-Doc vorgeschlagen: Chip zeigt +kcal, Ring-Zahl bleibt „übrig" (Diskussionspunkt an Dennis).
  49. Vorrat als Kreislauf (Welle 7, 07.06.2026 spätnachts). Kern: Abzüge kannten ihren Anlass nicht -> nichts war rückgängig machbar. Jetzt trägt jeder 🪸-Abzug inv_log.ref = "diary:{id}"; del_diary und plan_uneat buchen die Netto-Summe je Food zurück (reason storniert mit gleichem ref – dadurch idempotent UND zyklenfest: löschen→undo→löschen beliebig). restore_diary zieht frisch wieder ab (_redeplete_diary: nur wenn der Eintrag je Abzüge hatte; Gericht über Zutaten×Faktor, gedeckelt am Bestand). plan_eat ruft jetzt MIT deplete_stock=True (Welle-7-Fund #7: Plan-Essen zog nie ab). consume loggt ERST das Tagebuch und hängt den Abzug per ref dran; Abzug am Bestand gedeckelt. Weitere Welle-7-Fixes: Verbrauchen-Sheet grammgesteuert bei Packungs-Artikeln (#4) · „+ Artikel"-Einlagern mit Katalogsuche/EAN-Scan (#3, reason eingelagert, Reuse ScanSheet/ ScanResultPopup) · Bernstein-Regel folgt der OFFENEN Packung statt dem Gesamtbestand (#2 – „immer grün" sobald eine volle daneben lag) · Einkaufslisten-Kopf als EIN Hero-Panel mit Fortschrittsbalken (#5) · Gerichte im Essen-loggen-Sheet (#6, Sektion 🍲 + Portionen-Stepper + Zutaten-Abzug-Checkbox über POST /api/dishes/{id}/log). Falle dabei: .ringwrap { line-height: 0 } (ADR-48) erbte in die foreignObject- Texte des Rings – Label lag über der Zahl; display: grid statt line-height-Trick. Testplan-Feedback-Konzept (Defects am Testplan, Continuity-Paste) liegt im Archiv unter /recherchen/testplan-feedback.
  50. Testpläne hinter Login + Defect-Feedback (07.06.2026 spätnachts). Testpläne leben NUR noch auf der App-Domain: GET /testplan (Index mit 🐞-Zählern) + GET /testplan/{plan} servieren app/testplaene/*.html (ro-Mount in der Compose – neue Welle = HTML ablegen, KEIN Rebuild); auth_guard (ADR-16) deckt beide automatisch. Die Archiv-Kategorie „Testpläne" ist raus (build.py CATEGORIES). Feedback same-origin: POST /api/testplan/{plan}/img (Raw-Body wie /api/ai/photo – KEIN python-multipart nötig; Magic-Bytes PNG/JPEG, 15-MB-Limit, Name serverseitig) + POST /api/testplan/{plan}/feedback (Upsert je item_id, idempotent) → app/data/testplan-feedback/{plan}/findings.json + autogeneriertes findings.md + img/* — bleibt LOKAL (Dennis' Entscheidung: kein Auto-Push ins öffentliche Archiv; Session liest/rendert direkt). Bilder UNSKALIERT (keine Resize-Artefakte); nur Nicht-PNG/JPEG wird clientseitig verlustfrei nach PNG umkodiert (TIFF-Falle). Defect-UI als generischer Block im Welle-Template (welle-7.html): 🐞 je Punkt, Notiz, ⌘V-Paste über clipboardData.items mit image/png-Vorrang (Safari: .files ist bei Screenshots oft LEER – nie darauf verlassen), Drag&Drop + 📎-Fallback, localStorage-Persistenz, Auto-Save (1,2 s debounced), Re-Sync bei online. Falle: 🐞-Button sitzt IM

  51. Black-Box-API-Test-Suite als Rewrite-Versicherung (08.06.2026 früh). ~165 Tests (150 fast in ~1,5 s, 15 Integration) decken alle Endpoints: pytest + httpx + syrupy-Golden-Snapshots, strikt über HTTP gegen TARGET_URL — kein Backend-Import, kein TestClient. Prämisse (Dennis): Backend komplett austauschbar (Spring Boot, andere DB), Frontend darf nichts merken; Suite = Deploy-Gate (./app/tests/run.sh, baut Image, startet Wegwerf-Kandidaten mit TESTMODE=1 + leerer DB auf :9301). Seed ist API-VERTRAG: POST /api/test/reset (backend/testmode.py, Seed v1, feste IDs/Daten) — Fixture-DB-Kopien wären SQLite-gebunden. Reset räumt auch Dateisystem-Zustand (ai-resolve-Trigger/Log) ab. 3rd-Party via Marker-Split (OFF/HF/eGYM/Garmin/Gemini/Lidl/OIDC- Discovery = @integration), KI-Queue-Endpoints sind fast (nur DB). Browser-Login ist integration (OIDC-Discovery = Netz-Call). Beifang: db.init() crashte auf FRISCHER DB (hf_name-ALTER lief vor CREATE dish_ingredients — in Prod nie aufgefallen). Recherche: Archiv /recherchen/api-test-stack. Snapshots in app/tests/snapshots sind der Vertrag → immer mitcommitten.

  52. Kaufland-/Fremd-Bon-Import per PDF+KI (07.06.2026, ADR-52). Phase A aus der Recherche /recherchen/kaufland-import (Phase B = App-API fiel aus: Kaufland-App macht Cert-Pinning auf app.kaufland.net/ account.kaufland.com, passiver mitm-Capture unmöglich – §8 dort). Weg: POST /api/receipts/import-pdf (Raw-PDF, %PDF-Check, 15 MB) queut ai_job receipt_pdf; Host-Worker ai_jobs.job_receipt_pdf gibt das PDF claude-CLI+Read (PDF-fähig), extrahiert striktes Positions-JSON, POSTet an POST /api/receipts/import. Dieser ruft backend/receipt_import.py (GENERISCH, quelle-egal): Food-Matching über den NAMEN (NFKD-normalisiert, bestehend wiederverwenden sonst custom anlegen mit store), Pfand/Leergut per is_deposit/Regex erkannt (kein food), Idempotenz über deterministische ID kfl-<sha1(store|date|total)>, Auto-Bestand wie Lidl. Kontrollsumme = Σ aller Zeilen-total == Endbetrag (Pfand-/Rabattzeilen sind mit negativem total drin; deposit_returns NUR Anzeige, aus den Pfandzeilen abgeleitet) → needs_review-Flag bei Abweichung >2 ct. NICHT mit importer.py verwechselt (das bleibt Lidl/artId-zentriert). Frontend: „📄 Bon-PDF"-Button auf der Einkäufe-Seite. Contract-Tests decken Import/Idempotenz/Kontrollsumme/ PDF-Queue (6 Stück, ADR-51). Vorschau statt Auto-Import (Nachtrag 07.06.): Der PDF-Weg bucht NICHT mehr automatisch ein. job_receipt_pdf EXTRAHIERT nur (KI → Bon-JSON) und legt es als Job-note ab, importiert selbst nichts. Das Frontend pollt den Job (ReceiptPreview.svelte mit AiProgress), zeigt die erkannten Positionen, lässt jede einzeln fürs Einlagern abwählen (Pfand fix aus) und POSTet erst auf „Importieren" an /api/receipts/import – „Verwerfen" wirft alles weg, in der DB landet bis dahin NICHTS. Per-Position-Steuerung: receipt_items[].to_inventory (Default true) entscheidet, ob die Zeile den Bestand bucht; der Bon selbst kommt immer komplett ins Archiv. Kontrollsumme/Mismatch wird in der Vorschau angezeigt (⚠️), bevor man bestätigt – so kann ein verlesener Bon die DB nicht zerschießen. Frontend: „📄 Bon-PDF"-Button auf der Einkäufe- Seite öffnet nach Upload das Vorschau-Sheet. Gemini-Extraktion (Nachtrag): job_receipt_pdf nutzt jetzt llm_json mit PDF-file-Block – Gemini zuerst (liest PDF nativ, ~10 s statt ~44 s, gleiche Qualität), claude-CLI+Read als Fallback (ADR-37), beide mit demselben geschärften Prompt (_BON_RULES in ai_jobs.py). Geschärft (aus PDF-Analyse + 4× Lauf + Claude-as-Judge, Details in benchmark-claude-sub-vs-gemini): Rabattzeilen → is_discount statt Phantom-Artikel (receipt_import überspringt Food-Matching), Pfand/Leergut getrennt, Gewicht-im-Namen ≠ Gewichtsware. or_chat reicht max_tokens durch (8000 für Bons, sonst schnitt Gemini bei vielen Positionen ab).

  53. Recoverability-Klasse im Error-Envelope (11.06.2026, ADR-53, SILV-172). Bestellung aus der Stimme-Charta (P5, SILV-158): JEDE API-Fehlerantwort trägt neben detail ein maschinenlesbares recoverability{transient, permanent}, damit der Client die Fehler-Voice ehrlich branden kann statt aus dem Status zu raten. transient = heilt sich beim nächsten Reload/Retry (Netz/Timeout/Gateway/Overload) → Client zeigt KEINEN Alarm (still, wenn optimistic schon getickt). permanent = echter Verlust/Auth/kaputte Daten, Nutzer muss handeln → ruhige Handlungszeile. Umsetzung: backend/errors.py (classify(status), envelope(), AppError-Subklasse mit Override) + drei zentrale Exception-Handler in main.py (StarletteHTTPException inkl. AppError + StaticFiles-404, RequestValidationError=422, catch-all 500). Das Feld ist additivdetail bleibt 1:1 (kein Vertragsbruch). Status→Klasse (GELOCKT): transient nur {408, 425, 429, 502, 503, 504}, ALLES andere (400/401/403/404/409/422/500/…) permanent — Ehrlichkeits-Default „nie verharmlosen": was wir nicht sicher als selbstheilend kennen, ist permanent (lieber eine ruhige Handlungszeile zu viel als stilles Daten-Verschlucken). Pro Endpoint überschreibbar via raise AppError(status, msg, recoverability="…"). Die Auth-Middleware (umgeht den Handler) baut den Envelope direkt (401=permanent). Contract-Test tests/contract/test_errors.py prüft 401/404/422 echt + das volle Mapping über den TESTMODE-Werfer GET /api/test/raise/{status} (nur bei TESTMODE=1, im Prod absent). Client-Vertrag: docs/content/dev/error-envelope-vertrag.md.

  54. Warengruppen-Override je Artikel (11.06.2026, ADR-54, SILV-194). Welle A des Einkaufszettel-Redesigns (instore-moment-synthese.md §8 L7): die Laden-Reihenfolge ist der „als Nächstes"-Held der neuen Einkaufs-UI — sie ist nur so gut wie die Warengruppe je Artikel. Breiten-Pull vorab (11.06.): 1110 Foods, 65 (5 %) ohne Gruppe → „Sonstiges hinten"; jeder neue Lidl-Artikel kommt via ensure_food ohne Gruppe rein (die NULL-Quelle). Echte Fehlzuordnung (Texas-Mix= Konserve) ist klein — der Bulk hat eine plausible Gruppe. Entscheid: Override-Tabelle lohnt (primär Re-Import-Resilienz + NULL-Zufluss, nicht Massen-Chaos). Mechanik: group_override(art_id PK, group_name, by_user, ts) — Schlüssel ist die stabile 7-stellige artId (eine Food-Zeile kann nach Split/Re-Import neu entstehen, der Override überlebt das). Override gewinnt: seed_foods/ensure_food ziehen die effektive Gruppe aus der Tabelle (über die Mapping-Gruppe bzw. NULL), und ein Reconcile- Sweep importer.apply_group_overrides zieht foods.group_name nach jedem Import nach. ensure_food schreibt neue Artikel sofort mit Override-Gruppe (statt NULL) an. API: POST /api/foods/{id}/group {group_name?, by} (leer = Override entfernen) — schreibt den Override je artId + setzt foods.group_name sofort; der Stammdaten-Editor (PATCH /api/foods/{id} mit group) läuft jetzt denselben dauerhaften Pfad (_persist_food_group). Deterministisch, KEIN KI. Art-lose Foods (custom/HF) haben keine Override-Zeile — ihre Gruppe ist ohnehin nicht re-import-gefährdet. market_id (Lidl≠Kaufland-Gangfolge, L7) ist perspektivisch im PK erweiterbar, heute marktblind = Lidl-Annahme (bewusst noch nicht gebaut). Contract-Test test_group_override.py + Re-Import-Survival per Wegwerf-DB verifiziert.

  55. Einkauf-Sync = GARANTIE über Pull-Reconcile (11.06.2026, ADR-55, SILV-193). Welle A des Einkaufszettel-Redesigns (instore-moment-synthese.md §8 L5, „Bring!- Killer-Mechanik"). Problem: die geteilte Einkaufs-Sync war Best-Effort — Broadcast + la-update-Push sind beide fire-and-forget (APNs droppt/reordert), und der Client reconcilte gegen updatedAt mit Sekunden-Auflösung → zwei Aktionen in derselben Sekunde = Tie-Race. Entscheid: Push = Wecker, Pull = Wahrheit. (1) Neue Tabelle shopping_rev(week PK, rev)_bump_shopping_rev erhöht bei JEDER Mutation (add/patch/delete/generate/clear) um 1; rev additiv im LA-Content-State. (2) GET /api/shopping/{week}?since_rev=N: gleich → {week, rev, unchanged:true} (kein Item-Body/Radar), neuer → volle Wahrheit + rev. Race-sicher: rev wird vor den Items gelesen → der Client wird nie stale (höchstens ein redundanter Voll-Pull). (3) B-FIX-5 Präsenz-Heartbeat via Item-Mutation (_touch_presence): wer mit by eine Item-Aktion macht, frischt den last_heartbeat seiner aktiven shopping_session — fällt nicht aus der Partner-Präsenz, während er im Hintergrund abhakt; frischt NUR aktive Sitzungen, legt KEINE an (explizites Präsenz-Modell, einkauf-praesenz-modell.md). Zusatz: DELETE shopping/items/{id} zieht jetzt auch _la_shopping_sync (war eine Sync-Lücke). Ehrlichkeit (L5): die UI behauptet nie „live", wenn sie es nicht ist — der nächste Pull heilt, „zuletzt vor X" statt eingefrorenem Balken. Broadcast-Capability- Status (war angeblich offen) korrigiert: Channel am 08.06. erfolgreich angelegt, Sync feuert Broadcast + la-update — aber die Garantie liefert der Pull, nicht der Push. Contract-Test test_shopping_sync.py. Vertrag: docs/content/dev/live-activity-contract.md.

Datenmodell (SQLite, data/silverscale.db)

Verzeichnisse

app/
├── ARCHITEKTUR.md        # dieses Dokument
├── Dockerfile            # python:3.12-slim + fastapi/uvicorn
├── backend/              # main.py (API), db.py (Schema), importer.py
├── frontend/             # Svelte + Vite; Build → frontend/dist (im Image)
└── data/                 # silverscale.db (Volume, gitignored)

Betrieb