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.
- 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.
- 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.
- 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.
- 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.
- kcal-Budget = Basisbudget + Aktivitäten. Jede erfasste Aktivität erhöht das Tagesbudget („aufstocken"). Restbudget = Basis + Aktivität − gegessen.
- 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).
- 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). - 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.
- Charts: Chart.js (gebundelt, kein CDN – WireGuard-only-Betrieb). Schriften via @fontsource gebundelt (Fraunces Display, Inter Tight Text).
- Täglicher Import per Cron (07:45, nach dem Grocy-Sync): importer.py zieht neue Bons inkrementell über lidl.py (nur unbekannte Bon-IDs).
- HelloFresh zweistufig (v1.6): Lieferwochen über die interne /gw/-API
mit dem kurzlebigen JWT (~30 min,
data/hf-token.txt, Antwort gecacht indata/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 übermedia.hellofresh.com/…/hellofresh_s3{path}(per Reverse-Engineering der Webseite ermittelt;img.hellofresh.com-Varianten werfen 404). - 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, summiertdish_macros()über die Zutaten. HF-Gerichte sind überhf_ididempotent 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. - AI-Assistenz als Host-Job-Queue: Der Container kann kein
claudeaufrufen → Tabelleai_jobs(kind/ref_id/payload/status/note) + minütlicher Host-Workerai_jobs.py, derclaude -pmit 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. - 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). - 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.
- Öffentliche Domain + Pocket-ID-Session-Auth (06.06.2026). Ersetzt
ADR-2/8: App läuft jetzt unter
https://silverscale.dennisfisch.dehinter 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 viaoidc_identities(Heuristik beim Erstlogin: Name-Match). Host-Worker (ai_jobs, dish_ai, ai-resolve, Cron-curls, Importer-Push) umgehen die Middleware perX-Internal-Token(aus Repo-.env). Notausstieg:AUTH_DISABLED=1. WG-Zuganghttp://192.168.3.8:9300bleibt als Netz-Fallback bestehen (anderer Origin → eigenes Login nötig). Grocy bleibt bewusst OHNE Traefik-Router (WireGuard-only). - Web Push mit VAPID, minimaler Service Worker (06.06.2026).
backend/push.py+ pywebpush, Subscriptions inpush_subscriptions(tote Endpoints werden bei 404/410 aufgeräumt; Senden ist überall try/except = darf nie etwas brechen).public/sw.jsmacht 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. - 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 nurscan({ean})) + Strategie inlib/scan.js: BarcodeDetector wenn vorhanden, sonst dynamischer Import vonzxing-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). - Einkaufsliste als persistente Wochen-Liste (06.06.2026). Tabelle
shopping_items(week = Plan-Montag).POST /api/shopping/{week}/generateaggregiert 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. - Nachkoch-Preisvergleich mit Ehrlichkeits-Modi (06.06.2026).
HF-Boxpreis kommt automatisch aus
data/hf-subscriptions.json(productType.price/shippingPricein 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): Moduskg= 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. - HF-Staples über das
shipped-Flag (06.06.2026). Die öffentlichen Rezept-JSONs markieren je Zutatshipped: falsefü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). -
HF-Dauerbetrieb via Auth0-Refresh-Token (06.06.2026). Der HF-Web-Client (
azp B1n0Q24hv7e4AHc7yG1WwQyuMvpCAIya, hellofresh-live.eu.auth0.com) erlaubtgrant_type=refresh_tokenohne 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). -
Plan↔Tagebuch-Verknüpfung (06.06.2026).
diary.plan_idkoppelt übernommene Plan-Einträge ans Tagebuch: Löschen des diary-Eintrags ODERPOST /api/plan/{id}/uneatsetztmeal_plan.consumed=0zurück. - 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.sveltezeigt Kerndaten mit Countdown-Auto-Akzeptieren (Verwerfen/Anpassen/Akzeptieren) in Bon- und Stammdaten-Scan. FoodSheet bekommt einen 📷-Scan ins kcal-Tracking (by-ean → Portionsschritt). - 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_favoritesanalog zufavorites.GET /api/plan-picksortiert HF-Woche → Favoriten → zuletzt → häufigste;/api/foods-Ranking um Häufigkeit/Recency erweitert. -
HF-Feedback-Import + Markdown-Schritte (06.06.2026). Das
feedback- Objekt der past-deliveries-Antwort (favorite/rating 1–4/comment) undaggregateRating/aggregateRatingsCountaus dem öffentlichen Rezept-JSON landen indishes(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.) -
AI-Flow: 2-s-Pickup statt Minuten-Cron (06.06.2026). Gemessen:
claude -pselbst 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 cronclaudenicht (Ursache stiller ideas/insights-Fehler). Neue Job-Artestimate(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. -
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-POSTreplace:true, amber Affordance), Einkaufslisten-Panel „Zuletzt verwendet" + freies Löschen + Plan-Markierung. -
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
pickedviaPOST /api/ai/jobs/{id}/stage(Internal-Token);claude()läuft mit--output-format stream-json --verboseund meldetmodel(system/init = CLI bereit) undgen(erstes assistant-Event = TTFT);ai_jobs.stages(JSON) +done_at;expected_s= rollierender Ø der letzten 5 done-Jobs je kind (lernt mit).AiProgress.svelteersetzt 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 …). -
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
garminconnect0.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, GarminsamplePk, eGYMcheckInDate). 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-measurementszeigt das letzte je Gerät auch wenn 1 Jahr alt – „zuletzt vor X" muss explizit angezeigt werden. Umgesetzt (Phase 2): ScreenActivity.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) UNDworkouts(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.lastrowidnachINSERT OR IGNOREliefert bei IGNORE einen STALE rowid (nicht None) → beim Re-Sync falsche FK-Verknüpfung/Crash; immercur.rowcountprü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 viaext_checkin_id. (d) Detail-Endpoints MÜSSEN nachuser_idfiltern (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>), damithistory.back()aus Detailseiten zum richtigen Tab zurückführt. -
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):
getUserMediableibt ewig pending (kein reject!) und der unsichtbare System-Dialog blockiert alle Touches – die App war nur noch per Swipe-up beendbar. Dreifach-Abwehr inScanSheet.svelte: (a) im standalone-Modus mitpermissions.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 inreadBarcodes()(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). -
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 nurcalories − bmrCalories(Grundumsatz raus), nie negativ; gespeichert alsgarmin_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 Linkconnect.garmin.com/modern/activity/{id}(inactivities.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=). -
Anzeigenamen-Schicht
foods.display_name(06.06.2026, UX-Audit Block 2). Bon-Rohnamen („CocaCola light … zzgl. Pfand 0,15 €") bleiben alsfoods.name(Matching/Referenz) erhalten; die UI zeigt überallCOALESCE(display_name, name)– zentral inrow2food()(liefertname=Anzeige,raw_name=Original) plus 6 Label-Queries (Journal, Einkaufsliste, Vorschläge, PVG, Statistik). Tagebuch-Snapshots nutzen ab sofort den Anzeigenamen; Alt-Snapshots hebtPOST /api/admin/backfill-diary-namesan. Generierung:app/polish_names.py(Host, Haiku-Batches à 40 viaclaude -p, schreibt per PATCH über die API;--groupsverteilt „Sonstiges" auf bestehende Gruppen). Artikel-Seite editiert den Anzeigenamen, Bon-Name steht als Hinweis darunter. -
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/restoresetzt sie inkl. id +plan_id-Konsumstatus wieder ein; Toast mit „Rückgängig"-Action).ConfirmDialog.svelte+confirmDialog.ask()ersetzenwindow.confirm. Alle Submit-Pfade habensaving-Locks. Pydantic-Felder mitgt-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 inegym.pybehandelt beide; der Sync-Button pollt/api/activity/statusund zeigt Worker-Fehler als Toast. -
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-previewliefertknown+food_idfür den Schnellpfad). PVG:foods.packaging_weight_g+ Preis-Modusanteilig. Router merkt sich Scroll-Positionen; Sheets konsumieren Browser-Back via pushState/popstate. InBody-Grundumsatz kJ→kcal (÷4,184). Latenz-Benchmarkclaude -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. -
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_logreason='gegessen'; Gerichte anteilig × Portionen ohne Staples). Dabei gefundener Bug:inventory/adjustriefadd_inventoryohne 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 --packbefü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 überGET /gw/api/recipes/{id}(Token).match_foodwar 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. -
OpenRouter-Migration + Bild-KI (06.06.2026 nachts, beide mit Go).
ai_jobs.py:or_chat()/llm_json()rufengoogle/gemini-3.1-flash-litevia 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:insightsbleibt CLI-Sonnet (agentic, braucht curl-Tools).dish_ai.py+polish_names.pynutzen denselben Weg. Bild-KI (Feedback 13):POST /api/ai/dish-image/{id}(mode generate|enhance) ruftgoogle/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. -
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 inmatch_food-> einmal zugeordnet, greift es bei jedem künftigen Import/Repair automatisch.dish_ingredients.hf_namebewahrt 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-applysetzt 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). -
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_foodprüft den exakten Bon-Namen VOR dem artId-Mapping – sonst wachsen gesplittete Sorten beim nächsten Import wieder zusammen.foods/{id}/receiptsaggregiert pro Bon (zwei Positionen eines Bons erschienen sonst als Doppel-Eintrag). Real gesplittet: 568 = Pudding Schoko, 1076 = Pudding Vanille. - 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/loginliefert bei User-AgentSilverscaleAppeine Code-Eingabeseite statt des Pocket-ID-Redirects,/api/auth/device-loginsetzt die normale 180-Tage-Session.device-codeprü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). - 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 UASilverscaleApp(Einstellungen: PWA-Push-Hinweis ersetzt, Geräte-Login-Block versteckt) · Touch-Semantik:user-select/touch-calloutaus 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.sveltePropdirty: 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 auspublic/icon.svggeneriert (vorher Default-Capacitor-Logo!), Portrait-Lock, Swipe-Back (allowsBackForwardNavigationGesturesim AppDelegate, auf dem Gerät gegen die Login-History testen), Plugins Browser/Haptics/StatusBar/ SplashScreen/App vorinstalliert (Nutzung kommt per Frontend-Deploy),backgroundColor #0d2438gegen den weißen Lade-Blitz. - Phase 1 „Native-Feinschliff" (07.06.2026, Audit-Roadmap).
Netz-Robustheit:
api.jsmit 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) überhaptic()/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 beionline) viaserver.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). - 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 jetztnav: go|pop; beipopkein 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: manipulationglobal 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 Paramkoerper/<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). - 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-inkist 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. - 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).
- 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 überpackageClassList+ 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. - 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)
packageClassListin capacitor.config.json wird voncap syncbei 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.isAvailableist 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). - Teilverbrauch sichtbar + Fly-Animationen (07.06.2026 nachts,
Design-Doc #24/#25 aus dem Archiv). (a) Vorräte zeigen je
Packungs-Artikel (
unit != kg+ gepflegtespackaging_weight_g) einen Füllbalken: Modell = EINE offene Packung pro Artikel, der Nachkommaanteil voninventory.qtyist 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-reasonentnommen), statt stumm 1 ganze Packung abzuziehen; kg-Artikel unverändert. Beifang:POST /api/inventory/consumewar seit jeher kaputt (Argument-Shift inadd_inventory: "verbraucht" rutschte inunit, reason=None -> NOT-NULL-500; null „verbraucht"-Einträge im Log bewiesen es) – gefixt. (b)lib/fly.js: Ziel-Registry per Svelte-Actionuse:flyTarget={'name'}+flyValue(fromRectOderEl, ziel, label, {color})– fixed Chip, WAAPI-Flug 450 ms Haus-Kurve mit leichtem Bogen,flypulseam Ziel,prefers-reduced-motion-> No-Op. Quell- Rect VORdispatch('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). - 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_diaryundplan_uneatbuchen die Netto-Summe je Food zurück (reasonstorniertmit gleichem ref – dadurch idempotent UND zyklenfest: löschen→undo→löschen beliebig).restore_diaryzieht frisch wieder ab (_redeplete_diary: nur wenn der Eintrag je Abzüge hatte; Gericht über Zutaten×Faktor, gedeckelt am Bestand).plan_eatruft jetzt MITdeplete_stock=True(Welle-7-Fund #7: Plan-Essen zog nie ab).consumeloggt 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, reasoneingelagert, 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: gridstatt line-height-Trick. Testplan-Feedback-Konzept (Defects am Testplan, Continuity-Paste) liegt im Archiv unter /recherchen/testplan-feedback. -
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}servierenapp/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 überclipboardData.itemsmit 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 -
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. -
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_jobreceipt_pdf; Host-Workerai_jobs.job_receipt_pdfgibt das PDF claude-CLI+Read (PDF-fähig), extrahiert striktes Positions-JSON, POSTet anPOST /api/receipts/import. Dieser ruftbackend/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 IDkfl-<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_pdfEXTRAHIERT nur (KI → Bon-JSON) und legt es als Job-noteab, importiert selbst nichts. Das Frontend pollt den Job (ReceiptPreview.sveltemitAiProgress), 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_pdfnutzt jetztllm_jsonmit 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_RULESin ai_jobs.py). Geschärft (aus PDF-Analyse + 4× Lauf + Claude-as-Judge, Details inbenchmark-claude-sub-vs-gemini): Rabattzeilen →is_discountstatt Phantom-Artikel (receipt_import überspringt Food-Matching), Pfand/Leergut getrennt, Gewicht-im-Namen ≠ Gewichtsware.or_chatreichtmax_tokensdurch (8000 für Bons, sonst schnitt Gemini bei vielen Positionen ab). -
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
detailein maschinenlesbaresrecoverability∈{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 inmain.py(StarletteHTTPException inkl. AppError + StaticFiles-404, RequestValidationError=422, catch-all 500). Das Feld ist additiv —detailbleibt 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 viaraise AppError(status, msg, recoverability="…"). Die Auth-Middleware (umgeht den Handler) baut den Envelope direkt (401=permanent). Contract-Testtests/contract/test_errors.pyprüft 401/404/422 echt + das volle Mapping über den TESTMODE-WerferGET /api/test/raise/{status}(nur beiTESTMODE=1, im Prod absent). Client-Vertrag:docs/content/dev/error-envelope-vertrag.md. -
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 viaensure_foodohne 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_foodziehen die effektive Gruppe aus der Tabelle (über die Mapping-Gruppe bzw. NULL), und ein Reconcile- Sweepimporter.apply_group_overridesziehtfoods.group_namenach jedem Import nach.ensure_foodschreibt 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 + setztfoods.group_namesofort; der Stammdaten-Editor (PATCH /api/foods/{id}mitgroup) 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-Testtest_group_override.py+ Re-Import-Survival per Wegwerf-DB verifiziert. -
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 gegenupdatedAtmit Sekunden-Auflösung → zwei Aktionen in derselben Sekunde = Tie-Race. Entscheid: Push = Wecker, Pull = Wahrheit. (1) Neue Tabelleshopping_rev(week PK, rev)—_bump_shopping_reverhöht bei JEDER Mutation (add/patch/delete/generate/clear) um 1;revadditiv 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:revwird 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 mitbyeine Item-Aktion macht, frischt denlast_heartbeatseiner aktivenshopping_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-Testtest_shopping_sync.py. Vertrag:docs/content/dev/live-activity-contract.md.
Datenmodell (SQLite, data/silverscale.db)
users– Name, Emoji, Farbe, kcal-Basisbudget, Makro-Zielefoods– Produktkatalog: art_ids[], eans[], Name, Gruppe, Nährwerte/100g, Steuerklasse, is_food, source (mapping | receipt | custom)group_override– dauerhafte Warengruppen-Korrektur jeart_id(ADR-54, SILV-194): Override gewinnt + überlebt Re-Import; speistfoods.group_namegroups– Warengruppen +sort_order(= Laden-Reihenfolge)shopping_rev– monotone Revision je Woche (ADR-55, SILV-193): +1 je Mutation, Basis für den Pull-Reconcile (?since_rev) +rev-Guard der Live Activityreceipts/receipt_items– vollständiges Bon-Archiv inkl. Rabatten, Gewichtsartikeln, Steuerklasse; Preisstatistik wird hieraus abgeleitetdiary– Log-Einträge (User, Datum, Mahlzeit, Food-Ref ODER Freitext, Gramm, kcal/P/K/F-Snapshot)activities– User, Datum, Bezeichnung, Minuten, kcal (Budget-Aufstockung)inventory+inv_log– Bestand je Food + Journal aller Bewegungenfavorites– User↔Food (Schnellzugriff beim Loggen)- Aktivitäten-Strang (ADR-30):
egym_visits– Studio-Besuche je User (visited_at, optionalext_checkin_id)egym_strength_sets– Kraft-Messung je Gerät (PK = eGYM-id, idempotent), Score + kg + reps + Region, Verknüpfung zum Besuchegym_machines– statischer Geräte-Katalog (activity_idPK, deutsches Label, Region, M-Code, Icon-Pfad) – seed-bar ausegym-assets/machine-icons/body_measurements+body_metrics– Body-Composition aus zwei Quellen in EINEM Long-Schema (source ∈ {fithub, index_scale, manual},ext_idfür Idempotenz, Typ-Codes vereinheitlicht:WEIGHT_KG,BODY_FAT_PERCENTS,BONE_MASS_KG, …)garmin_activities– Outdoor-Aktivitäten (PK = GarminactivityId), kcal/Dauer/Distanz/HR/Sport-Typegym_accounts/garmin_accounts– Verknüpfung User → Profil-Slug (für Status-Endpoints + Multi-User;dennisundjaquelineals Slugs)
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
- Docker-Service
silverscalein der Repo-compose; Ports nur 127.0.0.1:9300 + 192.168.3.8:9300 (ufw ist inaktiv – nie öffentlich binden!) - Import:
python backend/importer.py --file <receipts.json>(Bulk) bzw.--pull(inkrementell via Lidl-API). Idempotent über Bon-IDs. - Frontend-Build:
cd frontend && PATH=$HOME/.local/node/bin:$PATH npm run build