Files

84 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
├── 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.