Add trend charts and calendar-week averages to History page

Adds Recharts-based calorie and macro trend charts plus real
calendar-week (Mon-Sun) averages, replacing the old rolling
7-day average. Colors were chosen and validated with the dataviz
skill's contrast/CVD checks separately for light and dark mode.

Fixes a timezone bug where Date.toISOString() shifted week
boundaries by one day in UTC+ timezones, and two Recharts pitfalls
(negative chart margin hiding the Y-axis, mount animation stuck
invisible in headless browsers).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 13:36:38 +02:00
co-authored by Claude Sonnet 5
parent 2f95eeda83
commit 5196ff0194
8 changed files with 685 additions and 24 deletions
+4 -1
View File
@@ -26,7 +26,7 @@ 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
├── 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
```
@@ -34,6 +34,8 @@ frontend/src/
`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.
@@ -66,6 +68,7 @@ frontend/src/
- **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