Files
CalTracker/AGENTS.md
T

21 KiB
Raw Blame History

AGENTS.md

Kontext und Konventionen für Coding-Agents, die an diesem Repo arbeiten.

Projekt

Selbst gehosteter Kalorien-Tracker (Webapp). Backend: FastAPI + SQLModel + SQLite. Frontend: React (Vite) + TailwindCSS, mobile-first, mit Kamera-Barcode-Scanner. Details und Setup: siehe README.md.

Das Projekt entstand phasenweise (Backend-Fundament → Barcode/Logs-Logik → Frontend → Docker → Mahlzeiten/Ziele/Historie → Log-Edit/Delete + Kochbuch). Es gibt keine Alembic-Migrationen, aber database.create_db_and_tables() ruft nach create_all zusätzlich _add_missing_columns() auf: das inspiziert bestehende Tabellen und ergänzt fehlende Spalten per ALTER TABLE ... ADD COLUMN (inkl. DEFAULT, aus dem Python-Feld-Default abgeleitet). Deckt additive Schemaänderungen (neue Spalte mit Default) automatisch ab, ohne die DB zu löschen — Umbenennungen, Typänderungen oder das Entfernen von Spalten werden NICHT unterstützt (dafür weiterhin DB löschen). Neue Felder in models.py sollten deshalb wenn möglich einen Default bekommen (auch wenn das zugehörige Pydantic-Schema es als Pflichtfeld behandelt — die beiden sind unabhängig, siehe FoodLog.meal_type = MealType.snack als Beispiel).

Struktur

backend/app/
├── main.py          FastAPI-App, Router-Registrierung, CORS, Lifespan
├── database.py      SQLModel engine/session
├── models.py         User, Product, FoodLog, Dish, DishIngredient, DishShare (Tabellen)
├── schemas.py        Pydantic-Schemas für Requests/Responses
├── security.py        Passwort-Hashing, JWT
├── deps.py             get_session, get_current_user
├── access.py            product_barcode_accessible / user_can_access_dish (Zugriffsprüfung für private Dish-Produkte)
├── llm.py               OpenAI-kompatibler Chat-Client (complete_json) für die KI-Nährwertschätzung
├── core/config.py       Settings (pydantic-settings, liest .env)
└── routers/            auth.py, users.py (inkl. /me/goals, GET / für Sharing-Picker), products.py (inkl. /search), logs.py (inkl. /today, /history, /day/{day}, PUT+DELETE /{log_id}), dishes.py (Kochbuch-CRUD + /shares)

frontend/src/
├── api/client.js         axios-Instanz mit Bearer-Interceptor
├── context/              AuthContext, ThemeContext (Dark Mode, .dark-Klasse auf <html>, localStorage-persistiert)
├── utils/                mealTypes.js (MEAL_TYPES, defaultMealType(), groupByMealType()), numbers.js (parseDecimal)
├── components/           BarcodeScanner, MacroProgress, MealTypeSelect, ProductFoundModal, ProductNotFoundForm (mode: 'log' | 'createOnly'), AddMealModal, IngredientPickerModal, ProductSearchInput, EditLogModal, ConfirmDialog, DishShareManager, CalorieTrendChart, MacroTrendChart, ProtectedRoute, ThemeToggle
└── pages/                LoginPage, RegisterPage, DashboardPage, HistoryPage, SettingsPage, CookbookPage, DishFormPage, DishDetailPage

Dark Mode: Tailwind v4 nutzt standardmäßig nur prefers-color-scheme für dark:. Für manuelles Umschalten ist in index.css @custom-variant dark (&:where(.dark, .dark *)); gesetzt — dark:-Klassen greifen dadurch, sobald <html> die Klasse dark trägt (gesteuert von ThemeContext.jsx). Neue UI muss beide Varianten mitbringen (z. B. bg-white dark:bg-gray-800, text-gray-900 dark:text-gray-50) — sonst ist die Komponente im Dark Mode unlesbar hell/dunkel.

User trägt calorie_goal/protein_goal/carbs_goal/fat_goal (Default 2000/100/250/70), änderbar über PUT /api/users/me/goals. Product und FoodLog haben zusätzlich zu calories/carbs/protein/fat auch sugar, fiber, saturated_fat, salt (Default 0.0, bei Open-Food-Facts-Import aus sugars_100g/fiber_100g/saturated-fat_100g/salt_100g). GET /api/logs/history?days=N liefert Tages-Totals für die letzten N Tage (Default 30, für Wochen-/Monatsdurchschnitte schneidet das Frontend das Array selbst statt einen eigenen Averages-Endpoint zu haben). GET /api/logs/day/{YYYY-MM-DD} liefert Logs+Totals für einen beliebigen Tag (gleiche Helper-Funktion wie /today).

Historie-Grafiken (CalorieTrendChart.jsx, MacroTrendChart.jsx, Recharts): beide bekommen das rohe days-Array von /api/logs/history und flachen es intern für Recharts ab. Farben sind hex-hardcodiert (nicht Tailwind-Klassen, da Recharts SVG-Props braucht) und je Theme unterschiedlich gewählt, nicht einfach dieselbe Farbe für hell/dunkel wiederverwendet — die dataviz-Skill-Validierung verlangt für dunkle Hintergründe einen engeren Lightness-Bereich (OKLCH L ≈ 0.48–0.67) als für helle (≈ 0.43–0.77), z. B. ist Amber-500 in Light OK, aber in Dark zu hell und muss auf Amber-700 wechseln. Zwei Recharts-Fallstricke, auf die man reinfällt, wenn man Chart-Margins/Animationen unbedacht kopiert: (1) ein negatives margin.left am <BarChart>/<LineChart> (gedacht als Trick, um die Y-Achsen-Reservefläche zu verkleinern) schiebt bei dieser Recharts-Version die komplette Y-Achse inkl. Beschriftung ins Negative und damit unsichtbar aus dem SVG-viewBox — margin.left muss >= 0 bleiben, Breite stattdessen über YAxis width={...} steuern. (2) Recharts animiert Bars/Lines standardmäßig beim Mount (strokeDasharray-Animation bzw. Höhen-Wachstum); in headless/automatisierten Browsern (z. B. dieses Preview-Tooling) läuft die Animation nicht weiter und die Marks bleiben dauerhaft im unsichtbaren Startzustand — isAnimationActive={false} auf <Bar>/<Line> setzen behebt es und ist für ein Dashboard ohnehin passender als eine Eintritts-Animation bei jedem Reload.

Mahlzeiten & Produktsuche (models.MealType: breakfast/lunch/dinner/snack): FoodLog.meal_type ist ein Pflichtfeld — jeder Log-Eintrag (Scan-Flow und Such-Flow) muss ihn mitschicken, sonst 422. Product.barcode ist in der DB weiterhin str (unique, required), aber im ProductCreate-Schema optional — fehlt er (manuelles Anlegen ohne Scan, z. B. „Döner“ vom Essen gehen), generiert routers/products.py serverseitig f"manual-{uuid4().hex[:12]}". GET /api/products/search?q= sucht per ILIKE über Product.name und sortiert Treffer, die der aktuelle User schon mal geloggt hat, nach Aktualität ganz nach oben (inkl. last_amount_g aus dem letzten Log dieses Barcodes für Prefill) — das ist die Basis für die „zuletzt verwendet“-Vorschläge, unabhängig davon ob das Produkt ursprünglich gescannt oder manuell angelegt wurde. Reihenfolge der Routen in products.py ist wichtig: /search muss vor /{barcode} registriert sein, sonst matcht FastAPI "search" als Barcode-Pfadparameter.

Log-Edit: PUT /api/logs/{id} skaliert die gespeicherten Nährwerte über das Verhältnis neue_menge / alte_menge direkt auf dem FoodLog-Snapshot — es wird NICHT das referenzierte Product erneut gelesen. Das ist bewusst so (konsistent mit dem "FoodLog ist ein unveränderliches Snapshot"-Prinzip): der Log bleibt korrekt, selbst wenn das zugrunde liegende Produkt/Gericht später gelöscht oder bearbeitet wurde.

Kochbuch/Gerichte (models.Dish + DishIngredient, routers/dishes.py): Ein Gericht ist technisch ein virtuelles Produkt — beim Anlegen/Bearbeiten werden die Zutaten-Nährwerte zu Pro-100g-Werten aggregiert und in einer eigenen Product-Zeile mit Barcode dish-<uuid> gecacht. Dadurch funktioniert das Loggen eines Gerichts über den ganz normalen POST /api/logs-Flow (Barcode + Menge + Mahlzeit), und Gerichte tauchen automatisch in GET /api/products/search auf (per is_dish-Flag markiert, erkennbar am dish--Präfix). Beim Löschen eines Gerichts wird auch die zugehörige Product-Zeile gelöscht — das ist unproblematisch, weil vergangene FoodLog-Einträge bereits eigene Snapshots ihrer Nährwerte sind und nicht auf die Product-Zeile angewiesen bleiben. DishIngredientRead liefert bewusst Pro-100g-Werte (nicht auf amount_g skaliert), damit die Zutaten-Objekte strukturell identisch zu ProductSearchResult sind — das Frontend (DishFormPage.jsx) nutzt dieselbe Form für frisch gesuchte Zutaten und für beim Bearbeiten geladene Zutaten, inkl. client-seitig live berechneter Nährwert-Vorschau.

Mehrbenutzer-Absicherung (app/access.py, genutzt von products.py, logs.py, dishes.py): Product hat kein user_id und ist absichtlich global — alle Nutzer sehen/loggen dieselben Lebensmittel. Die einzige Ausnahme sind Gerichte (Product.barcode beginnt mit dish-): die sind über models.DishShare (dish_id, shared_with_user_id) an ihren Besitzer gebunden. access.product_barcode_accessible(session, barcode, user_id) ist die zentrale Prüfung — gibt für normale Barcodes immer True zurück, für dish-*-Barcodes nur wenn user_id Besitzer ist oder ein DishShare-Eintrag existiert. Wird an drei Stellen aufgerufen: GET /api/products/{barcode} und GET /api/products/search (private Gerichte anderer werden wie "nicht gefunden" behandelt, keine Existenz-Leaks), sowie POST /api/logs (verhindert Loggen fremder privater Gerichte auch wenn der Barcode bekannt ist). dishes.py unterscheidet _get_owned_dish (strikt Besitzer, für Bearbeiten/Löschen/Sharing-Verwaltung) von _get_accessible_dish (Besitzer ODER Freigabe-Empfänger, für die Detailansicht). DishRead.is_owner/owner_username sagen dem Frontend, ob Bearbeiten/Löschen/Teilen-UI angezeigt werden darf — das Backend ist die eigentliche Durchsetzung, das Frontend blendet nur aus. GET /api/users (nur Username, keine persönlichen Daten) existiert einzig für den Sharing-Picker im Frontend.

KI-Nährwertschätzung (app/llm.py, POST /api/products/estimate, Frontend in ProductNotFoundForm.jsx): Freitext („Currywurst mit Pommes") → LLM schätzt alle acht Nährwerte pro 100 g plus portion_g und füllt damit das bestehende Anlege-Formular vor. Bewusst nur ein Vorschlag: die Werte landen in den normalen Eingabefeldern, der Nutzer speichert erst nach Prüfung (Hinweistext im UI), und es wird nichts automatisch geloggt. Konfiguration komplett über .env (LLM_API_URL/LLM_API_KEY/LLM_MODEL), Client ist OpenAI-kompatibel — Gateway/Modell also austauschbar ohne Codeänderung. Ohne LLM_API_KEY antwortet der Endpunkt sauber mit 503, der Rest der App bleibt unberührt (getestet). Die LLM-Antwort wird zusätzlich per Pydantic (NutritionEstimateResponse, mit ge/le-Grenzen z. B. calories <= 900) validiert, damit offensichtlich unsinnige Halluzinationen nicht ins Formular gelangen; scheitert das, gibt es 502 statt kaputter Werte.

  • Modellwahl ist nicht beliebig — gemessen, nicht geraten: Von den freien kit.*-Modellen der KIT-Toolbox lieferte im direkten Vergleich (gleicher Prompt, je 4–6 Anfragen) nur kit.mistral-small-4-119b-a8b durchgängig valides JSON, bei ~4 s pro Anfrage. kit.qwen3.5-397b-A17b gab gar keinen content zurück (0/3, vermutlich Reasoning-only-Ausgabe), kit.minimax-m2.7-229b schrieb <think>-Blöcke vor das JSON, kit.gemma4-31b-it brauchte 14–17 s, kit.gpt-oss-120b war unzuverlässiger. Deshalb ist mistral-small der Default. response_format={"type": "json_object"} verbessert die Trefferquote spürbar und wird immer mitgeschickt; _extract_json() räumt trotzdem defensiv <think>-Blöcke und Markdown-Fences ab, damit ein Modellwechsel per .env nicht sofort alles bricht.
  • Die KIT-Toolbox meldet transiente Fehler als HTTP 400: sporadisch kommen {"detail": "Model not found"}, {"detail": "Function not found: token_usage_display"} oder sogar durchgereichte psycopg.OperationalError-Meldungen zurück — bei einem unveränderten Request, der Sekunden später funktioniert. Das sind Gateway-Aussetzer, keine Client-Fehler. complete_json() wiederholt deshalb jeden Fehler bis zu MAX_ATTEMPTS (3) mal, obwohl man einen 400er normalerweise nie wiederholen würde — ohne das schlägt gefühlt jede vierte Anfrage grundlos fehl. max_retries=0 am OpenAI-Client ist Absicht, damit die Wiederholungslogik an einer Stelle liegt.

Kein window.confirm(): Lösch-Bestätigungen laufen über die eigene ConfirmDialog-Komponente, nicht über natives window.confirm(). Grund: native Dialoge blockieren/verhalten sich inkonsistent in automatisierten Browsern (z. B. Preview-Tooling in dieser Umgebung) und passen optisch nicht zum Rest der App. Neue Lösch-Aktionen sollten ConfirmDialog wiederverwenden.

ProductNotFoundForm hat zwei Modi: mode="log" (Default) erstellt ein Produkt UND loggt es sofort (zeigt Mahlzeit-Auswahl + Mengenfeld, Button "Anlegen & Loggen") — genutzt vom Scan-/Such-Flow. mode="createOnly" legt nur das Produkt an und ruft onCreated(product) auf, ohne zu loggen (kein Mahlzeit-/Mengenfeld, Button "Anlegen") — genutzt von IngredientPickerModal.jsx beim Zutaten-Anlegen im Kochbuch, wo ein frisch angelegtes Produkt nur der Zutatenliste hinzugefügt werden soll, nicht dem Tages-Log. IngredientPickerModal bündelt für den Zutaten-Picker dieselben drei Wege wie AddMealModal (Suche, Barcode-Scan, manuelles Anlegen) — bei 404 nach einem Scan wird automatisch ProductNotFoundForm im createOnly-Modus mit dem gescannten Barcode vorausgefüllt geöffnet.

Konventionen

  • UI-Texte sind auf Deutsch (Labels, Fehlermeldungen). Neue UI-Texte ebenfalls auf Deutsch verfassen.
  • Keine Kommentare im Code, außer für nicht-offensichtliche WARUM-Erklärungen (z. B. Workarounds für Library-Bugs).
  • Backend: neue Endpunkte bekommen ein Pydantic-Schema in schemas.py (nicht die SQLModel-Tabellenklasse direkt als Response-Model verwenden, wenn es kein table=True-Modell ist — dann model_config = ConfigDict(from_attributes=True) nicht vergessen, sonst 500er bei der Serialisierung).
  • Frontend: Komponenten, die selbst einen API-Call auslösen (z. B. ProductFoundModal, ProductNotFoundForm), sind für ihren eigenen Loading-/Error-State verantwortlich (kein globaler State-Manager, reicht für diese Projektgröße).
  • Frontend: Numerische Eingaben für Nährwerte/Mengen/Ziele sind type="text" + inputMode="decimal" (nicht type="number") und werden über utils/numbers.js#parseDecimal geparst, damit auch das deutsche Komma als Dezimaltrennzeichen funktioniert (siehe Fallstricke unten). Neue numerische Eingabefelder sollten diesem Muster folgen.

Bekannte Fallstricke

  • passlib + bcrypt: passlib==1.7.4 ist inkompatibel mit bcrypt>=4.1 (crasht beim Hashen mit ValueError: password cannot be longer than 72 bytes). bcrypt ist deshalb in backend/requirements.txt explizit auf 4.0.1 gepinnt — beim Aktualisieren der Dependencies nicht versehentlich lösen.
  • html5-qrcode: Im Cleanup von BarcodeScanner.jsx niemals ungeprüft scanner.stop() aufrufen. Wenn start() (noch) nicht erfolgreich war (z. B. Kamera-Permission verweigert), wirft stop() synchron und crasht ohne Error Boundary den gesamten React-Tree. Immer mit scanner.isScanning guarden.
  • Foto-Fallback beim Scannen: BarcodeScanner.jsx hat neben dem Live-Video-Scan einen roten Auslöser-Button, fest am unteren Bildschirmrand (absolute positioniert, nicht Teil des normalen Flex-Flows — sonst wird er von der Videovorschau aus dem sichtbaren Bereich geschoben). Der Button macht keinen separaten Foto-/Datei-Dialog auf (das würde auf dem Desktop nur den normalen Dateibrowser öffnen, capture="environment" auf <input type="file"> ist ein reines Mobil-Feature) — stattdessen wird ein einzelnes Frame aus dem bereits laufenden <video>-Element der Live-Vorschau per Canvas (drawImage → toBlob) eingefroren und per scanner.scanFile(file, false) in Ruhe analysiert. Funktioniert dadurch identisch auf Mobilgerät und Mac/Desktop, bleibt ein einziger durchgehender Screen, und ist zuverlässiger als der Live-Scan bei schlechtem Fokus/Licht, weil ein scharfes Einzelbild statt wackliger Live-Frames ausgewertet wird. Das Bild wird nie hochgeladen oder gespeichert — nur als lokales Canvas/Blob für die Dauer des scanFile-Aufrufs. Button ist deaktiviert (isCameraReady-State), solange kein Live-Video läuft (z. B. während der Permission-Anfrage oder wenn start() fehlgeschlagen ist) — sonst gäbe es nichts zu fotografieren. Schlägt die Erkennung fehl, wird die Live-Kamera automatisch neu gestartet. Der Decode-Pfad selbst (scanFile von einem Canvas-generierten Bild) wurde im Browser mit zwei unterschiedlichen, synthetisch per Canvas erzeugten echten EAN-13-Barcodes verifiziert (korrekte Erkennung inkl. Prüfziffer, einmal sogar zufällig ein echter Open-Food-Facts-Treffer). Echter Kamera-Zugriff (getUserMedia) bleibt in dieser Umgebung wie immer ungetestet (kein Kamerazugriff im Headless-Browser) — dort ist nur verifizierbar, dass der Button beim fehlgeschlagenen Kamerastart korrekt deaktiviert bleibt und nichts crasht.
  • Kamera-Zugriff braucht HTTPS: getUserMedia funktioniert in Browsern nur in einem "secure context" (https:// oder localhost). Beim Testen/Hosten im LAN über http://<ip>:8080 schlägt der Scanner-Zugriff auf allen Geräten außer localhost fehl — das ist kein Bug, siehe README.
  • Open Food Facts: Antworten sind uneinheitlich befüllt (nicht jedes Produkt hat energy-kcal_100g). routers/products.py fällt bei fehlendem Kcal-Wert auf energy_100g (kJ) zurück und rechnet um; fehlende Makro-Felder werden mit 0.0 befüllt statt den Request abzulehnen.
  • Docker-Networking: Das Frontend spricht das Backend nicht direkt an, sondern über den nginx-Proxy (frontend/nginx.conf, location /api/ → http://backend:8000). VITE_API_URL wird im Docker-Build deshalb bewusst leer gelassen (""), damit Axios relative Pfade nutzt. Für die lokale Entwicklung ohne Docker steht VITE_API_URL=http://localhost:8000 in frontend/.env, und CORS ist in backend/app/main.py für localhost:5173 freigeschaltet.
  • Preview-/Sandbox-Tools: In dieser Entwicklungsumgebung konnte das FastAPI-Backend nicht über den preview_start-Mechanismus (.claude/launch.json) gestartet werden (Sandbox verweigert .venv-Zugriff mit getcwd: Operation not permitted). Das Frontend funktioniert darüber problemlos. Falls das Backend für einen Preview-Test laufen muss, direkt per Shell starten (cd backend && source .venv/bin/activate && uvicorn app.main:app --reload).
  • Laufende Docker-Container vor lokalen Tests prüfen: docker compose up published Backend und Frontend auf eigenen Ports (siehe docker-compose.yml, aktuell 8300/8310, per restart: unless-stopped können sie nach einem Docker-Neustart von selbst wieder hochkommen). Läuft parallel noch ein lokaler uvicorn-Prozess (z. B. für manuelles Testen von Codeänderungen), kann der Browser http://localhost:8000 je nach IPv4/IPv6-Auflösung an den ALTEN Docker-Container statt an den neu gestarteten lokalen Prozess schicken — mit dem Ergebnis, dass die Browser-Session gegen einen veralteten Codestand (und eine andere, bereits befüllte SQLite-Datei) läuft, während curl 127.0.0.1:8000 (explizit IPv4) den lokalen Prozess trifft und alles korrekt aussieht. Führt zu sehr verwirrenden "funktioniert per curl, crasht im Browser"-Bugs. Vor jedem lokalen Backend-Test daher docker ps prüfen und ggf. docker compose down ausführen.
  • CORS-Origin ist konfigurierbar: backend/app/main.py liest den erlaubten Frontend-Port aus der Env-Var FRONTEND_PORT (Default 8310, passend zum aktuellen docker-compose.yml). Für lokale Entwicklung auf Port 5173 (npm run dev) muss der lokale uvicorn-Prozess mit FRONTEND_PORT=5173 uvicorn app.main:app --reload gestartet werden, sonst schlägt die CORS-Preflight-Anfrage (OPTIONS → 400) fehl und Login/Register funktionieren im Browser nicht, obwohl curl normal antwortet.
  • Dezimal-Eingabe mit Komma: <input type="number"> akzeptiert in vielen Locales/Browsern kein Komma als Dezimaltrennzeichen — Eingaben wie „9,8“ werden als ungültig verworfen. Alle Nährwert-/Mengen-/Ziel-Inputs sind deshalb type="text" mit inputMode="decimal"; das Parsen läuft über parseDecimal() (frontend/src/utils/numbers.js), das Komma zu Punkt normalisiert.
  • Date.toISOString() für reine Datums-Strings ist ein Timezone-Bug-Magnet: new Date("YYYY-MM-DDT00:00:00") wird als LOKALE Zeit geparst, aber .toISOString() gibt UTC zurück — in einer Zeitzone östlich von UTC (z. B. Europe/Berlin) kippt lokal Mitternacht auf den VORTAG in UTC, wodurch .toISOString().slice(0,10) ein um einen Tag falsches Datum liefert. Ist tatsächlich passiert in frontend/src/utils/weeks.js (getWeekStart/groupByWeek bauten falsche Wochengrenzen, dadurch systematisch falsche Wochendurchschnitte) und wurde erst im Browser-Test mit manuell nachgerechneten Werten auffällig, nicht durch bloßes Ansehen des gerenderten Ergebnisses. Fix: Datum-zu-String immer über lokale getFullYear()/getMonth()/getDate() zusammenbauen (siehe toDateStr() in weeks.js), niemals toISOString() für ein reines Datum (ohne Uhrzeit-Bedeutung) verwenden. Neue Datums-Arithmetik-Helper sollten diesem Muster folgen.

Testen

Es gibt aktuell keine automatisierten Tests (keine pytest-/vitest-Suite). Änderungen werden manuell verifiziert:

  • Backend: curl gegen die Endpunkte (siehe Beispiele in der Commit-/Session-Historie) oder Swagger-UI unter /docs.
  • Frontend: Dev-Server starten und den Flow im Browser durchklicken (Login → Scan/Fallback → Dashboard). Kamera-Zugriff lässt sich in Headless-/Preview-Browsern nicht testen (Permission wird immer verweigert) — dafür reicht es, zu prüfen, dass der Scanner-Dialog ohne Absturz öffnet und einen Fehlertext zeigt; echtes Scannen muss auf einem Gerät mit Kamera (über localhost oder HTTPS) verifiziert werden.