16 KiB
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)
├── 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, 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).
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.
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 keintable=True-Modell ist — dannmodel_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"(nichttype="number") und werden überutils/numbers.js#parseDecimalgeparst, 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.4ist inkompatibel mitbcrypt>=4.1(crasht beim Hashen mitValueError: password cannot be longer than 72 bytes).bcryptist deshalb inbackend/requirements.txtexplizit auf4.0.1gepinnt — beim Aktualisieren der Dependencies nicht versehentlich lösen. - html5-qrcode: Im Cleanup von
BarcodeScanner.jsxniemals ungeprüftscanner.stop()aufrufen. Wennstart()(noch) nicht erfolgreich war (z. B. Kamera-Permission verweigert), wirftstop()synchron und crasht ohne Error Boundary den gesamten React-Tree. Immer mitscanner.isScanningguarden. - Foto-Fallback beim Scannen:
BarcodeScanner.jsxhat 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 perscanner.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 desscanFile-Aufrufs. Button ist deaktiviert (isCameraReady-State), solange kein Live-Video läuft (z. B. während der Permission-Anfrage oder wennstart()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 (scanFilevon 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:
getUserMediafunktioniert in Browsern nur in einem "secure context" (https://oderlocalhost). Beim Testen/Hosten im LAN überhttp://<ip>:8080schlägt der Scanner-Zugriff auf allen Geräten außerlocalhostfehl — das ist kein Bug, siehe README. - Open Food Facts: Antworten sind uneinheitlich befüllt (nicht jedes Produkt hat
energy-kcal_100g).routers/products.pyfällt bei fehlendem Kcal-Wert aufenergy_100g(kJ) zurück und rechnet um; fehlende Makro-Felder werden mit0.0befü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_URLwird im Docker-Build deshalb bewusst leer gelassen (""), damit Axios relative Pfade nutzt. Für die lokale Entwicklung ohne Docker stehtVITE_API_URL=http://localhost:8000infrontend/.env, und CORS ist inbackend/app/main.pyfürlocalhost:5173freigeschaltet. - 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 mitgetcwd: 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 uppublished Backend und Frontend auf eigenen Ports (siehedocker-compose.yml, aktuell8300/8310, perrestart: unless-stoppedkönnen sie nach einem Docker-Neustart von selbst wieder hochkommen). Läuft parallel noch ein lokaleruvicorn-Prozess (z. B. für manuelles Testen von Codeänderungen), kann der Browserhttp://localhost:8000je 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ährendcurl 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 daherdocker psprüfen und ggf.docker compose downausführen. - CORS-Origin ist konfigurierbar:
backend/app/main.pyliest den erlaubten Frontend-Port aus der Env-VarFRONTEND_PORT(Default8310, passend zum aktuellendocker-compose.yml). Für lokale Entwicklung auf Port 5173 (npm run dev) muss der lokale uvicorn-Prozess mitFRONTEND_PORT=5173 uvicorn app.main:app --reloadgestartet werden, sonst schlägt die CORS-Preflight-Anfrage (OPTIONS→ 400) fehl und Login/Register funktionieren im Browser nicht, obwohlcurlnormal 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 deshalbtype="text"mitinputMode="decimal"; das Parsen läuft überparseDecimal()(frontend/src/utils/numbers.js), das Komma zu Punkt normalisiert.
Testen
Es gibt aktuell keine automatisierten Tests (keine pytest-/vitest-Suite). Änderungen werden manuell verifiziert:
- Backend:
curlgegen 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
localhostoder HTTPS) verifiziert werden.