75 lines
16 KiB
Markdown
75 lines
16 KiB
Markdown
# 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 <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 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.
|
|
|
|
## 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.
|