# 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](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 , 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 `` 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 ``/`` (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 ``/`` 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-` 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 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 `` ist ein reines Mobil-Feature) — stattdessen wird ein einzelnes Frame aus dem bereits laufenden `