Merge pull request #1 from JakobFritzz/history-charts

Add trend charts and calendar-week averages to History page
This commit is contained in:
Jakob Martin Fritz
2026-07-29 13:41:38 +02:00
committed by GitHub
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
+2 -2
View File
@@ -12,7 +12,7 @@ KalorienTracker/
```
- **Backend** (`backend/`): FastAPI-API mit JWT-Login, Produktsuche (lokale DB → Fallback auf Open Food Facts, Ergebnis wird lokal gecacht), Logging von Mahlzeiten inkl. Tagesübersicht, Historie (Tage/Wochen/Monate) und selbst gesetzten Tageszielen. Mehrbenutzerfähig: jeder registrierte Nutzer sieht nur seine eigenen Tagebuch-Einträge, Ziele und privaten Rezepte, während alle Lebensmittel (Barcode-Produkte) in einer gemeinsamen Datenbank für alle Nutzer verfügbar sind. Jeder Endpunkt außer Registrierung/Login verlangt ein gültiges JWT.
- **Frontend** (`frontend/`): React-SPA mit Kamera-Barcode-Scanner (`html5-qrcode`), Dashboard mit Fortschrittsbalken für Kalorien/Protein/Carbs/Fett, Formular für manuell angelegte Produkte, Historie-Seite mit Wochen-/Monatsdurchschnitt, Settings-Seite für eigene Tagesziele, umschaltbarer Dark Mode (folgt standardmäßig der Systemeinstellung, per Klick auf das Sonne/Mond-Icon umschaltbar, Wahl bleibt gespeichert).
- **Frontend** (`frontend/`): React-SPA mit Kamera-Barcode-Scanner (`html5-qrcode`), Dashboard mit Fortschrittsbalken für Kalorien/Protein/Carbs/Fett, Formular für manuell angelegte Produkte, Historie-Seite mit Trend-Grafiken (Recharts), Kalenderwochen- und Monatsdurchschnitt, Settings-Seite für eigene Tagesziele, umschaltbarer Dark Mode (folgt standardmäßig der Systemeinstellung, per Klick auf das Sonne/Mond-Icon umschaltbar, Wahl bleibt gespeichert).
## Setup
@@ -78,7 +78,7 @@ Läuft auf `http://localhost:5173` und spricht per CORS direkt mit dem Backend a
5. Alternativ „Mahlzeit hinzufügen“ tippen, um ohne Scan nach einem Produkt zu suchen (z. B. „Döner“ vom Essen gehen) oder ein neues anzulegen. Die Suche schlägt zuerst die zuletzt selbst verwendeten Produkte vor (inkl. der zuletzt genutzten Menge) — auch wenn sie ursprünglich per Barcode gescannt wurden.
6. Dashboard zeigt die Tagessumme aus Kalorien, Protein, Carbs und Fett als Fortschrittsbalken gegen die eigenen Ziele, plus eine Zeile mit den restlichen Nährwerten (Zucker/Ballaststoffe/ges. Fett/Salz, ohne eigenes Ziel), sowie die Einträge gruppiert nach Frühstück/Mittagessen/Abendessen/Snacks. Jeder Eintrag lässt sich über die Stift-/Papierkorb-Icons nachträglich in Menge/Mahlzeit bearbeiten oder löschen.
7. Unter „Ziele“ lassen sich die eigenen Tagesziele für Kalorien, Protein, Carbs und Fett festlegen (Default: 2000 kcal / 100 g Protein / 250 g Carbs / 70 g Fett).
8. Unter „Historie“ gibt es den Wochen- und Monatsdurchschnitt (nur über Tage mit Einträgen gemittelt) sowie eine Liste der letzten 30 Tage — ein Tag antippen zeigt die geloggten Einträge dieses Tages, ebenfalls nach Mahlzeit gruppiert.
8. Unter „Historie“ gibt es einen Kalorien-Trend und einen Makro-Trend (Carbs/Protein/Fett) als Grafik über die letzten 30 Tage, eine Wochenübersicht mit echten Kalenderwochen-Durchschnitten (Montag–Sonntag) sowie den Monatsdurchschnitt (jeweils nur über Tage mit Einträgen gemittelt) und eine Liste der letzten 30 Tage — ein Tag antippen zeigt die geloggten Einträge dieses Tages, ebenfalls nach Mahlzeit gruppiert.
9. Unter „Kochbuch“ lassen sich eigene Gerichte aus bereits bekannten Produkten zusammenstellen (Name, Zubereitung, Zutaten mit Menge, bei Bedarf direkt per Scan oder manuellem Formular neu angelegt) — die Nährwerte pro 100g werden automatisch aus den Zutaten berechnet. Ein Gericht taucht danach wie ein normales Produkt in der Suche auf (mit „Gericht“-Badge) und lässt sich genauso mengenbasiert loggen, bearbeiten oder löschen — praktisch für Rezepte, die man öfter kocht, oder als Inspiration. Rezepte sind privat und nur für den Ersteller sichtbar, lassen sich aber über „Teilen“ auf der Detailseite gezielt mit anderen registrierten Nutzern teilen (diese können das Rezept dann ansehen und loggen, aber nicht bearbeiten oder löschen).
## Tech-Stack
+401 -3
View File
@@ -12,7 +12,8 @@
"html5-qrcode": "^2.3.8",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
"react-router-dom": "^7.18.1",
"recharts": "^3.10.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
@@ -460,6 +461,32 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
@@ -724,6 +751,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
@@ -1007,11 +1046,74 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1027,6 +1129,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
@@ -1096,6 +1204,15 @@
"node": ">= 0.4"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -1125,9 +1242,130 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1145,6 +1383,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1237,6 +1481,23 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz",
"integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types"
]
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1429,6 +1690,25 @@
"node": ">= 6"
}
},
"node_modules/immer": {
"version": "11.1.15",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz",
"integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -1893,6 +2173,36 @@
"react": "^19.2.7"
}
},
"node_modules/react-is": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz",
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
@@ -1931,6 +2241,57 @@
"react-dom": ">=18"
}
},
"node_modules/recharts": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
@@ -2008,6 +2369,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -2033,6 +2400,37 @@
"license": "0BSD",
"optional": true
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
+2 -1
View File
@@ -14,7 +14,8 @@
"html5-qrcode": "^2.3.8",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
"react-router-dom": "^7.18.1",
"recharts": "^3.10.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
@@ -0,0 +1,65 @@
import { Bar, BarChart, CartesianGrid, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
const BAR_COLOR = '#059669'
function formatShortDate(dateStr) {
const d = new Date(`${dateStr}T00:00:00`)
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
}
function CustomTooltip({ active, payload, label }) {
if (!active || !payload?.length) return null
return (
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2 shadow-md dark:border-gray-700 dark:bg-gray-800">
<p className="mb-1 text-xs text-gray-500 dark:text-gray-400">{formatShortDate(label)}</p>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-50">
{Math.round(payload[0].value)} kcal
</p>
</div>
)
}
export default function CalorieTrendChart({ days, calorieGoal, theme }) {
const data = days.map((day) => ({ date: day.date, calories: day.totals.calories }))
const gridColor = theme === 'dark' ? '#374151' : '#e5e7eb'
const tickColor = theme === 'dark' ? '#9ca3af' : '#6b7280'
return (
<div className="rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
<h2 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-50">Kalorien-Trend</h2>
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
<CartesianGrid stroke={gridColor} strokeWidth={1} vertical={false} />
<XAxis
dataKey="date"
tickFormatter={formatShortDate}
tick={{ fill: tickColor, fontSize: 11 }}
stroke={gridColor}
interval="preserveStartEnd"
minTickGap={24}
/>
<YAxis tick={{ fill: tickColor, fontSize: 11 }} stroke={gridColor} width={40} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: gridColor, opacity: 0.4 }} />
{calorieGoal > 0 && (
<ReferenceLine
y={calorieGoal}
stroke="#9ca3af"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{ value: 'Ziel', position: 'insideTopRight', fill: tickColor, fontSize: 11 }}
/>
)}
<Bar
dataKey="calories"
fill={BAR_COLOR}
radius={[4, 4, 0, 0]}
maxBarSize={20}
isAnimationActive={false}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
)
}
@@ -0,0 +1,95 @@
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
const SERIES = [
{ key: 'protein', label: 'Protein', light: '#2563eb', dark: '#3b82f6' },
{ key: 'carbs', label: 'Carbs', light: '#d97706', dark: '#b45309' },
{ key: 'fat', label: 'Fett', light: '#e11d48', dark: '#f43f5e' },
]
function formatShortDate(dateStr) {
const d = new Date(`${dateStr}T00:00:00`)
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
}
function CustomLegend({ payload }) {
return (
<div className="mt-1 flex justify-center gap-4">
{payload.map((entry) => (
<div key={entry.value} className="flex items-center gap-1.5">
<span className="h-0.5 w-4 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="text-xs text-gray-600 dark:text-gray-300">{entry.value}</span>
</div>
))}
</div>
)
}
function CustomTooltip({ active, payload, label }) {
if (!active || !payload?.length) return null
return (
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2 shadow-md dark:border-gray-700 dark:bg-gray-800">
<p className="mb-1 text-xs text-gray-500 dark:text-gray-400">{formatShortDate(label)}</p>
<div className="space-y-0.5">
{payload.map((entry) => (
<div key={entry.dataKey} className="flex items-center gap-1.5 text-sm">
<span className="h-0.5 w-3 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="font-semibold text-gray-900 dark:text-gray-50">{Math.round(entry.value)}g</span>
<span className="text-xs text-gray-500 dark:text-gray-400">{entry.name}</span>
</div>
))}
</div>
</div>
)
}
export default function MacroTrendChart({ days, theme }) {
const data = days.map((day) => ({
date: day.date,
protein: day.totals.protein,
carbs: day.totals.carbs,
fat: day.totals.fat,
}))
const gridColor = theme === 'dark' ? '#374151' : '#e5e7eb'
const tickColor = theme === 'dark' ? '#9ca3af' : '#6b7280'
const surfaceColor = theme === 'dark' ? '#1f2937' : '#ffffff'
return (
<div className="rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
<h2 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-50">Makro-Trend</h2>
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
<CartesianGrid stroke={gridColor} strokeWidth={1} vertical={false} />
<XAxis
dataKey="date"
tickFormatter={formatShortDate}
tick={{ fill: tickColor, fontSize: 11 }}
stroke={gridColor}
interval="preserveStartEnd"
minTickGap={24}
/>
<YAxis tick={{ fill: tickColor, fontSize: 11 }} stroke={gridColor} width={40} />
<Tooltip content={<CustomTooltip />} cursor={{ stroke: gridColor, strokeWidth: 1 }} />
<Legend content={<CustomLegend />} />
{SERIES.map((series) => {
const color = theme === 'dark' ? series.dark : series.light
return (
<Line
key={series.key}
type="monotone"
dataKey={series.key}
name={series.label}
stroke={color}
strokeWidth={2}
dot={{ r: 4, strokeWidth: 2, stroke: surfaceColor, fill: color }}
activeDot={{ r: 5, strokeWidth: 2, stroke: surfaceColor, fill: color }}
isAnimationActive={false}
/>
)
})}
</LineChart>
</ResponsiveContainer>
</div>
</div>
)
}
+67 -17
View File
@@ -1,31 +1,26 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import apiClient from '../api/client'
import CalorieTrendChart from '../components/CalorieTrendChart'
import MacroTrendChart from '../components/MacroTrendChart'
import ThemeToggle from '../components/ThemeToggle'
import { useAuth } from '../context/AuthContext'
import { useTheme } from '../context/ThemeContext'
import { groupByMealType } from '../utils/mealTypes'
import { average, groupByWeek } from '../utils/weeks'
const HISTORY_DAYS = 30
function average(days) {
const daysWithData = days.filter((d) => d.totals.calories > 0)
if (daysWithData.length === 0) {
return { calories: 0, protein: 0, carbs: 0, fat: 0, count: 0 }
}
const sum = (key) => daysWithData.reduce((acc, d) => acc + d.totals[key], 0)
return {
calories: sum('calories') / daysWithData.length,
protein: sum('protein') / daysWithData.length,
carbs: sum('carbs') / daysWithData.length,
fat: sum('fat') / daysWithData.length,
count: daysWithData.length,
}
}
function formatDate(dateStr) {
const d = new Date(`${dateStr}T00:00:00`)
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })
}
function formatShortDate(dateStr) {
const d = new Date(`${dateStr}T00:00:00`)
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
}
function AverageCard({ title, avg }) {
return (
<div className="rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
@@ -57,6 +52,8 @@ function AverageCard({ title, avg }) {
}
export default function HistoryPage() {
const { user } = useAuth()
const { theme } = useTheme()
const [days, setDays] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [expandedDate, setExpandedDate] = useState(null)
@@ -81,8 +78,8 @@ export default function HistoryPage() {
}
}
const weeklyAvg = average(days.slice(-7))
const monthlyAvg = average(days)
const weeks = groupByWeek(days)
const reversedDays = [...days].reverse()
return (
@@ -98,7 +95,60 @@ export default function HistoryPage() {
</header>
<main className="mx-auto max-w-md space-y-4 p-4">
<AverageCard title="Ø letzte 7 Tage" avg={weeklyAvg} />
{!isLoading && days.length > 0 && (
<>
<CalorieTrendChart days={days} calorieGoal={user.calorie_goal} theme={theme} />
<MacroTrendChart days={days} theme={theme} />
</>
)}
<section className="rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
<h2 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-50">Wochenübersicht</h2>
{weeks.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">Keine Daten in diesem Zeitraum.</p>
) : (
<div className="space-y-3">
{weeks.map((week) => (
<div key={week.weekStart} className="border-t border-gray-100 pt-3 first:border-0 first:pt-0 dark:border-gray-700">
<p className="mb-2 text-xs font-medium text-gray-500 dark:text-gray-400">
Woche vom {formatShortDate(week.weekStart)}–{formatShortDate(week.weekEnd)}
</p>
{week.avg.count === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">Keine Einträge in dieser Woche.</p>
) : (
<dl className="grid grid-cols-4 gap-2 text-center text-sm">
<div>
<dt className="text-gray-500 dark:text-gray-400">Kcal</dt>
<dd className="font-medium text-gray-900 dark:text-gray-50">
{Math.round(week.avg.calories)}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Protein</dt>
<dd className="font-medium text-gray-900 dark:text-gray-50">
{Math.round(week.avg.protein)}g
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Carbs</dt>
<dd className="font-medium text-gray-900 dark:text-gray-50">
{Math.round(week.avg.carbs)}g
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Fett</dt>
<dd className="font-medium text-gray-900 dark:text-gray-50">
{Math.round(week.avg.fat)}g
</dd>
</div>
</dl>
)}
</div>
))}
</div>
)}
</section>
<AverageCard title={`Ø letzte ${HISTORY_DAYS} Tage`} avg={monthlyAvg} />
<section className="rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
+49
View File
@@ -0,0 +1,49 @@
function toDateStr(d) {
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function addDays(dateStr, amount) {
const d = new Date(`${dateStr}T00:00:00`)
d.setDate(d.getDate() + amount)
return toDateStr(d)
}
export function getWeekStart(dateStr) {
const d = new Date(`${dateStr}T00:00:00`)
const isoDay = d.getDay() === 0 ? 7 : d.getDay()
return addDays(dateStr, 1 - isoDay)
}
export function average(days) {
const daysWithData = days.filter((d) => d.totals.calories > 0)
if (daysWithData.length === 0) {
return { calories: 0, protein: 0, carbs: 0, fat: 0, count: 0 }
}
const sum = (key) => daysWithData.reduce((acc, d) => acc + d.totals[key], 0)
return {
calories: sum('calories') / daysWithData.length,
protein: sum('protein') / daysWithData.length,
carbs: sum('carbs') / daysWithData.length,
fat: sum('fat') / daysWithData.length,
count: daysWithData.length,
}
}
export function groupByWeek(days) {
const weeks = new Map()
for (const day of days) {
const weekStart = getWeekStart(day.date)
if (!weeks.has(weekStart)) weeks.set(weekStart, [])
weeks.get(weekStart).push(day)
}
return Array.from(weeks.entries())
.map(([weekStart, weekDays]) => ({
weekStart,
weekEnd: addDays(weekStart, 6),
avg: average(weekDays),
}))
.sort((a, b) => b.weekStart.localeCompare(a.weekStart))
}