From 8c60601ed13c5c09f59cd5ceaf48de60a4ee957b Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 19 Apr 2026 16:46:05 +0200 Subject: [PATCH 1/7] feat: implement touch-friendly KPI details with bottom sheet interaction - Added a new button for displaying KPI details on touch devices, replacing hover functionality. - Introduced a bottom sheet component to present detailed information when the info button is clicked. - Enhanced the BodyKpiOverview component to detect touch UI and adjust interactions accordingly. - Updated CSS styles for new touch elements, ensuring a responsive and user-friendly design. --- frontend/src/app.css | 107 +++++++++++++++++++++++++++++++++ frontend/src/pages/History.jsx | 88 +++++++++++++++++++++++++-- 2 files changed, 190 insertions(+), 5 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index af03b56..73c1f3d 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -220,6 +220,113 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we box-shadow: 0 1px 5px rgba(0, 0, 0, 0.07); } +/* Kennzahlen: Touch — iOS hat kein Hover; ℹ öffnet Bottom-Sheet */ +.body-kpi-info-btn { + position: absolute; + top: 6px; + right: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 36px; + min-height: 36px; + margin: 0; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text3); + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} +.body-kpi-info-btn:active { + background: var(--surface); + color: var(--accent); +} + +.body-kpi-touch-backdrop { + position: fixed; + inset: 0; + z-index: 10050; + display: flex; + align-items: flex-end; + justify-content: center; + padding: 0 12px; + padding-bottom: max(12px, env(safe-area-inset-bottom)); + background: rgba(0, 0, 0, 0.45); + animation: body-kpi-fade-in 0.15s ease; +} + +@keyframes body-kpi-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.body-kpi-touch-sheet { + width: 100%; + max-width: 520px; + max-height: min(72vh, 560px); + overflow: auto; + margin: 0 auto; + padding: 14px 16px 18px; + border-radius: 16px 16px 0 0; + background: var(--surface); + border: 1px solid var(--border); + border-bottom: none; + box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.18); +} + +.body-kpi-touch-sheet__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.body-kpi-touch-sheet__title { + margin: 0; + font-size: 16px; + font-weight: 700; + color: var(--text1); + line-height: 1.3; + flex: 1; + min-width: 0; +} + +.body-kpi-touch-sheet__close { + flex-shrink: 0; + width: 40px; + height: 40px; + margin: -6px -8px 0 0; + padding: 0; + border: none; + border-radius: 10px; + background: transparent; + color: var(--text2); + font-size: 26px; + line-height: 1; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.body-kpi-touch-sheet__close:active { + background: var(--surface2); +} + +.body-kpi-touch-sheet__body { + font-size: 13px; + line-height: 1.5; + color: var(--text2); + white-space: pre-wrap; + word-break: break-word; +} + +.body-kpi-touch-sheet__body--muted { + color: var(--text3); + font-style: italic; +} + .history-page__title { margin-bottom: 12px; } diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 4d5cd2f..b1c0269 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -6,7 +6,7 @@ import { XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, ReferenceLine, PieChart, Pie, Cell, ComposedChart } from 'recharts' -import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' +import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2, Info } from 'lucide-react' import { api } from '../utils/api' import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay' import { getBfCategory } from '../utils/calc' @@ -232,12 +232,51 @@ function buildBodyKpiTiles({ return tiles } -/** KPI-Kacheln: Kurzvergleich sichtbar, ausführlicher Text per nativem Hover (`title`). */ +function kpiTileDetailParts(t) { + const registryLine = t.keys?.length ? `Registry: ${t.keys.join(', ')}` : '' + const body = [t.hoverBody, registryLine].filter(Boolean).join('\n\n') + return { title: t.hoverTop || t.category, body } +} + +/** KPI-Kacheln: Desktop — Hover (`title`). Touch — ℹ öffnet gleichen Text im Bottom-Sheet (iOS hat kein Hover). */ function BodyKpiOverview({ tiles }) { + const [touchUi, setTouchUi] = useState(false) + const [openKey, setOpenKey] = useState(null) + + useEffect(() => { + const mq = window.matchMedia('(hover: none)') + const apply = () => setTouchUi(mq.matches) + apply() + mq.addEventListener('change', apply) + return () => mq.removeEventListener('change', apply) + }, []) + + useEffect(() => { + if (!openKey) return + const onKey = e => { if (e.key === 'Escape') setOpenKey(null) } + const prev = document.body.style.overflow + document.body.style.overflow = 'hidden' + window.addEventListener('keydown', onKey) + return () => { + document.body.style.overflow = prev + window.removeEventListener('keydown', onKey) + } + }, [openKey]) + if (!tiles?.length) return null + + const openTile = openKey ? tiles.find(x => x.key === openKey) : null + const openParts = openTile ? kpiTileDetailParts(openTile) : null + return (
Kennzahlen
+ {touchUi && ( +
+ + Auf dem Smartphone: für Erklärung und Details. +
+ )}
{tiles.map(t => { const accent = getStatusColor(t.status) @@ -246,10 +285,21 @@ function BodyKpiOverview({ tiles }) {
-
+ {touchUi && ( + + )} +
{t.icon}
{t.category}
@@ -266,6 +316,34 @@ function BodyKpiOverview({ tiles }) { ) })}
+ + {openParts && ( +
setOpenKey(null)} + > +
e.stopPropagation()} + > +
+

{openParts.title}

+ +
+ {openParts.body ? ( +
{openParts.body}
+ ) : ( +
Keine weiteren Details.
+ )} +
+
+ )}
) } From 08b7aa0ca1826c411edc3a2ee7517a79191800f5 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 19 Apr 2026 17:00:05 +0200 Subject: [PATCH 2/7] refactor: integrate KpiTilesOverview component for enhanced KPI display - Introduced the KpiTilesOverview component to streamline the presentation of KPI tiles, replacing the previous BodyKpiOverview implementation. - Updated the PilotKpiBoard and History components to utilize the new KpiTilesOverview for better touch and hover interactions. - Refactored CSS styles to accommodate the new component structure, ensuring a responsive design across devices. - Enhanced the logic for generating KPI tiles, improving data handling and user experience. --- frontend/src/app.css | 29 +++- frontend/src/components/KpiTilesOverview.jsx | 156 ++++++++++++++++++ .../src/components/pilot/PilotKpiBoard.jsx | 118 +++++++------ frontend/src/pages/History.jsx | 121 +------------- 4 files changed, 248 insertions(+), 176 deletions(-) create mode 100644 frontend/src/components/KpiTilesOverview.jsx diff --git a/frontend/src/app.css b/frontend/src/app.css index 73c1f3d..8a2c520 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -199,13 +199,16 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we .page-title { font-size: 20px; font-weight: 700; margin-bottom: 16px; } /* Verlauf: Mobile Tabs horizontale Leiste, Desktop vertikal links (P4 / RESPONSIVE_UI §5.2) */ -/* Körper-Verlauf: KPI-Übersicht (Hover = Details, kein Klick) */ +/* KPI-Kachel-Raster: gemeinsam für Verlauf Körper, Dashboard KPI-Board, … + Desktop: title-Tooltip; Touch: ℹ → Bottom-Sheet (siehe KpiTilesOverview.jsx) */ +.kpi-tiles-grid, .body-kpi-overview { display: grid; grid-template-columns: repeat(auto-fill, minmax(158px, 1fr)); gap: 8px; margin-bottom: 12px; } +.kpi-tiles-card, .body-kpi-card { background: var(--surface2); border-radius: 10px; @@ -215,12 +218,19 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we text-align: left; transition: border-color 0.15s ease, box-shadow 0.15s ease; } +@media (hover: none) { + .kpi-tiles-card, + .body-kpi-card { + cursor: default; + } +} +.kpi-tiles-card:hover, .body-kpi-card:hover { border-color: var(--border2); box-shadow: 0 1px 5px rgba(0, 0, 0, 0.07); } -/* Kennzahlen: Touch — iOS hat kein Hover; ℹ öffnet Bottom-Sheet */ +.kpi-tiles-info-btn, .body-kpi-info-btn { position: absolute; top: 6px; @@ -239,11 +249,13 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we cursor: pointer; -webkit-tap-highlight-color: transparent; } +.kpi-tiles-info-btn:active, .body-kpi-info-btn:active { background: var(--surface); color: var(--accent); } +.kpi-tiles-touch-backdrop, .body-kpi-touch-backdrop { position: fixed; inset: 0; @@ -254,14 +266,19 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we padding: 0 12px; padding-bottom: max(12px, env(safe-area-inset-bottom)); background: rgba(0, 0, 0, 0.45); - animation: body-kpi-fade-in 0.15s ease; + animation: kpi-tiles-fade-in 0.15s ease; } +@keyframes kpi-tiles-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} @keyframes body-kpi-fade-in { from { opacity: 0; } to { opacity: 1; } } +.kpi-tiles-touch-sheet, .body-kpi-touch-sheet { width: 100%; max-width: 520px; @@ -276,6 +293,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.18); } +.kpi-tiles-touch-sheet__head, .body-kpi-touch-sheet__head { display: flex; align-items: flex-start; @@ -284,6 +302,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we margin-bottom: 10px; } +.kpi-tiles-touch-sheet__title, .body-kpi-touch-sheet__title { margin: 0; font-size: 16px; @@ -294,6 +313,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we min-width: 0; } +.kpi-tiles-touch-sheet__close, .body-kpi-touch-sheet__close { flex-shrink: 0; width: 40px; @@ -310,10 +330,12 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we -webkit-tap-highlight-color: transparent; } +.kpi-tiles-touch-sheet__close:active, .body-kpi-touch-sheet__close:active { background: var(--surface2); } +.kpi-tiles-touch-sheet__body, .body-kpi-touch-sheet__body { font-size: 13px; line-height: 1.5; @@ -322,6 +344,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we word-break: break-word; } +.kpi-tiles-touch-sheet__body--muted, .body-kpi-touch-sheet__body--muted { color: var(--text3); font-style: italic; diff --git a/frontend/src/components/KpiTilesOverview.jsx b/frontend/src/components/KpiTilesOverview.jsx new file mode 100644 index 0000000..96150f9 --- /dev/null +++ b/frontend/src/components/KpiTilesOverview.jsx @@ -0,0 +1,156 @@ +import { useState, useEffect, useId } from 'react' +import { Info } from 'lucide-react' +import { getStatusColor } from '../utils/interpret' + +/** + * Zerlegt eine KPI-Kachel für Bottom-Sheet / Tooltip. + * @param {{ hoverTop?: string, category?: string, hoverBody?: string, keys?: string[] }} t + */ +export function kpiTileDetailParts(t) { + const registryLine = t.keys?.length ? `Registry: ${t.keys.join(', ')}` : '' + const body = [t.hoverBody, registryLine].filter(Boolean).join('\n\n') + return { title: t.hoverTop || t.category || 'Kennzahl', body } +} + +/** Ein Zeilentext wie natives `title` (Desktop-Hover). */ +export function buildKpiTileTitleString(t) { + return [t.hoverTop, t.hoverBody, t.keys?.length ? `Registry: ${t.keys.join(', ')}` : ''] + .filter(Boolean) + .join('\n\n') +} + +/** + * Standard-KPI-Kacheln: Desktop `title`-Tooltip, Touch ℹ → Bottom-Sheet (gleicher Inhalt). + * + * Erwartete Kachel-Felder: + * - `key` (string, eindeutig) + * - `category` (string) — Zeilenkopf + * - `value` (ReactNode) — Hauptwert + * - `status` — für Farbstreifen: `good` | `warn` | `bad` + * - optional: `icon`, `sublabel`, `verdict`, `valueColor`, `hoverTop`, `hoverBody`, `keys` + */ +export default function KpiTilesOverview({ + tiles, + heading = 'Kennzahlen', + showTouchHint = true, + gridClassName = 'kpi-tiles-grid', + marginBottom = 12, +}) { + const [touchUi, setTouchUi] = useState(false) + const [openKey, setOpenKey] = useState(null) + const sheetTitleId = useId() + + useEffect(() => { + const mq = window.matchMedia('(hover: none)') + const apply = () => setTouchUi(mq.matches) + apply() + mq.addEventListener('change', apply) + return () => mq.removeEventListener('change', apply) + }, []) + + useEffect(() => { + if (!openKey) return + const onKey = e => { if (e.key === 'Escape') setOpenKey(null) } + const prev = document.body.style.overflow + document.body.style.overflow = 'hidden' + window.addEventListener('keydown', onKey) + return () => { + document.body.style.overflow = prev + window.removeEventListener('keydown', onKey) + } + }, [openKey]) + + if (!tiles?.length) return null + + const openTile = openKey ? tiles.find(x => x.key === openKey) : null + const openParts = openTile ? kpiTileDetailParts(openTile) : null + + const showVerdict = (v) => v != null && String(v).trim() !== '' && String(v).trim() !== '—' + + return ( +
+ {heading ? ( +
{heading}
+ ) : null} + {showTouchHint && touchUi && ( +
+ + Auf dem Smartphone: für Erklärung und Details. +
+ )} +
+ {tiles.map(t => { + const accent = getStatusColor(t.status) + const tip = buildKpiTileTitleString(t) + return ( +
+ {touchUi && ( + + )} +
+ {t.icon != null && t.icon !== false ? ( + {t.icon} + ) : ( + + )} +
+
{t.category}
+
{t.value}
+ {t.sublabel ? ( +
{t.sublabel}
+ ) : null} +
+ {showVerdict(t.verdict) ? ( +
+
{t.verdict}
+
+ ) : null} +
+
+ ) + })} +
+ + {openParts && ( +
setOpenKey(null)} + > +
e.stopPropagation()} + > +
+

{openParts.title}

+ +
+ {openParts.body ? ( +
{openParts.body}
+ ) : ( +
Keine weiteren Details.
+ )} +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/pilot/PilotKpiBoard.jsx b/frontend/src/components/pilot/PilotKpiBoard.jsx index 02b3898..b98e60c 100644 --- a/frontend/src/components/pilot/PilotKpiBoard.jsx +++ b/frontend/src/components/pilot/PilotKpiBoard.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useCallback } from 'react' +import { useState, useEffect, useMemo } from 'react' import { Link } from 'react-router-dom' import dayjs from 'dayjs' import { api } from '../../utils/api' @@ -6,6 +6,7 @@ import { getBfCategory } from '../../utils/calc' import { useProfile } from '../../context/ProfileContext' import { KPI_KCAL_WINDOW_DEFAULT } from '../../widgetSystem/bodyChartDays' import { kpiTileOrderFromConfig } from '../../widgetSystem/kpiBoardTiles' +import KpiTilesOverview from '../KpiTilesOverview' const MAX_KPI = 9 @@ -113,62 +114,63 @@ export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) { return buildAutoTileIds(refTiles, hasBf, hasKcal) }, [manualOrder, refTiles, bf, avgKcal]) - const pushTileForId = useCallback( - (id, out) => { - if (id === 'body_fat') { - if (!bf) return - out.push( -
-
Körperfett
-
- {bf.pct}% -
-
{bf.cat?.label || 'Caliper'}
-
, - ) - return - } - if (id === 'avg_kcal') { - if (avgKcal == null) return - out.push( -
-
- Ø Kalorien ({KPI_KCAL_WINDOW_DEFAULT}T) -
-
{avgKcal} kcal
-
Ernährung
-
, - ) - return - } - const tk = parseRefTypeKey(id) - if (!tk) return - const tile = refByKey.get(tk) - if (!tile?.latest) return - const l = tile.latest - out.push( -
-
{tile.type_label}
-
- {formatRefVal(l)} - {l.unit ? ( - {l.unit} - ) : null} -
-
Ref.wert
-
, - ) - }, - [bf, avgKcal, refByKey], - ) - - const visibleTiles = useMemo(() => { + const kpiTiles = useMemo(() => { const out = [] for (const id of orderIds) { - pushTileForId(id, out) + if (id === 'body_fat') { + if (!bf) continue + out.push({ + key: 'kpi-bf', + status: 'good', + category: 'Körperfett', + icon: '🫧', + value: `${bf.pct}%`, + sublabel: bf.cat?.label || 'Caliper', + valueColor: bf.cat?.color, + hoverTop: 'Körperfett (Caliper)', + hoverBody: + `Letzte Messung: ${bf.date ? dayjs(bf.date).format('DD.MM.YYYY') : '—'}.\n` + + 'Wert aus dem Caliper-Log; die Farbe/Kategorie richtet sich nach Geschlecht und üblicher Spanne.', + }) + continue + } + if (id === 'avg_kcal') { + if (avgKcal == null) continue + out.push({ + key: 'kpi-kcal', + status: 'good', + category: `Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT}T)`, + icon: '🍽️', + value: `${avgKcal} kcal`, + sublabel: 'Ernährung', + valueColor: '#EF9F27', + hoverTop: `Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT} Tage)`, + hoverBody: + `Durchschnitt der täglichen Kalorien aus dem Ernährungs-Log über die letzten ${KPI_KCAL_WINDOW_DEFAULT} Tage (Mittel über alle geladenen Tageseinträge im Fenster).`, + }) + continue + } + const tk = parseRefTypeKey(id) + if (!tk) continue + const tile = refByKey.get(tk) + if (!tile?.latest) continue + const l = tile.latest + const valStr = formatRefVal(l) + const withUnit = l.unit ? `${valStr} ${l.unit}`.trim() : valStr + out.push({ + key: `ref-${tk}`, + status: 'good', + category: tile.type_label, + icon: '📌', + value: withUnit, + sublabel: 'Ref.wert', + hoverTop: tile.type_label, + hoverBody: + 'Persönlicher Referenzwert aus dem Profil. Verwaltung unter Einstellungen → Referenzwerte.', + }) } return out - }, [orderIds, pushTileForId]) + }, [orderIds, bf, avgKcal, refByKey]) if (loading) { return ( @@ -185,7 +187,7 @@ export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) { ) } - if (visibleTiles.length === 0) { + if (kpiTiles.length === 0) { return (
Kennzahlen
@@ -216,7 +218,13 @@ export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) { ? 'Ausgewählte Kacheln in festgelegter Reihenfolge (ohne Daten werden Kacheln ausgelassen).' : `Bis ${MAX_KPI} Kacheln: Referenzwerte, Körperfett, Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT}T).`}

-
{visibleTiles}
+
) } diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index b1c0269..587a45f 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -6,7 +6,7 @@ import { XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, ReferenceLine, PieChart, Pie, Cell, ComposedChart } from 'recharts' -import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2, Info } from 'lucide-react' +import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' import { api } from '../utils/api' import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay' import { getBfCategory } from '../utils/calc' @@ -15,6 +15,7 @@ import Markdown from '../utils/Markdown' import TrainingTypeDistribution from '../components/TrainingTypeDistribution' import NutritionCharts from '../components/NutritionCharts' import RecoveryCharts from '../components/RecoveryCharts' +import KpiTilesOverview from '../components/KpiTilesOverview' import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') @@ -232,122 +233,6 @@ function buildBodyKpiTiles({ return tiles } -function kpiTileDetailParts(t) { - const registryLine = t.keys?.length ? `Registry: ${t.keys.join(', ')}` : '' - const body = [t.hoverBody, registryLine].filter(Boolean).join('\n\n') - return { title: t.hoverTop || t.category, body } -} - -/** KPI-Kacheln: Desktop — Hover (`title`). Touch — ℹ öffnet gleichen Text im Bottom-Sheet (iOS hat kein Hover). */ -function BodyKpiOverview({ tiles }) { - const [touchUi, setTouchUi] = useState(false) - const [openKey, setOpenKey] = useState(null) - - useEffect(() => { - const mq = window.matchMedia('(hover: none)') - const apply = () => setTouchUi(mq.matches) - apply() - mq.addEventListener('change', apply) - return () => mq.removeEventListener('change', apply) - }, []) - - useEffect(() => { - if (!openKey) return - const onKey = e => { if (e.key === 'Escape') setOpenKey(null) } - const prev = document.body.style.overflow - document.body.style.overflow = 'hidden' - window.addEventListener('keydown', onKey) - return () => { - document.body.style.overflow = prev - window.removeEventListener('keydown', onKey) - } - }, [openKey]) - - if (!tiles?.length) return null - - const openTile = openKey ? tiles.find(x => x.key === openKey) : null - const openParts = openTile ? kpiTileDetailParts(openTile) : null - - return ( -
-
Kennzahlen
- {touchUi && ( -
- - Auf dem Smartphone: für Erklärung und Details. -
- )} -
- {tiles.map(t => { - const accent = getStatusColor(t.status) - const tip = [t.hoverTop, t.hoverBody, t.keys?.length ? `Registry: ${t.keys.join(', ')}` : ''].filter(Boolean).join('\n\n') - return ( -
- {touchUi && ( - - )} -
- {t.icon} -
-
{t.category}
-
{t.value}
- {t.sublabel && ( -
{t.sublabel}
- )} -
-
-
{t.verdict}
-
-
-
- ) - })} -
- - {openParts && ( -
setOpenKey(null)} - > -
e.stopPropagation()} - > -
-

{openParts.title}

- -
- {openParts.body ? ( -
{openParts.body}
- ) : ( -
Keine weiteren Details.
- )} -
-
- )} -
- ) -} - function BodyGoalsStrip({ grouped }) { const nav = useNavigate() const goals = (grouped?.body || []).filter(g => g.status === 'active').slice(0, 4) @@ -613,7 +498,7 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
)} - + {vizLoading && (
Aktualisiere…
From a8eafa8ba4507d7741935993b52ba35d7c21c3e0 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 19 Apr 2026 17:13:59 +0200 Subject: [PATCH 3/7] feat: add weekly macro distribution panel and enhance nutrition charts - Introduced the WeeklyMacroDistributionPanel component to visualize weekly macro distribution alongside existing nutrition charts. - Updated the NutritionCharts component to conditionally load the weekly macro data based on a new prop. - Enhanced CSS styles for better layout and responsiveness of the new macro distribution panel. - Added a new NutritionGoalsStrip component to display active nutrition-related goals with progress indicators in the History page. - Refactored existing components to improve data handling and user experience. --- frontend/src/app.css | 18 + frontend/src/components/NutritionCharts.jsx | 256 +++++----- frontend/src/pages/History.jsx | 487 ++++++++++++-------- 3 files changed, 475 insertions(+), 286 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 8a2c520..c6d23c7 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -350,6 +350,24 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we font-style: italic; } +/* Verlauf Ernährung: Donut (Ø-Quote) + wöchentliche Makro-Verteilung (E3) */ +.nutrition-macro-pair { + display: grid; + gap: 12px; + margin-bottom: 12px; + align-items: stretch; +} + +@media (min-width: 780px) { + .nutrition-macro-pair { + grid-template-columns: minmax(280px, 1fr) minmax(320px, 1.25fr); + } +} + +.nutrition-macro-pair__weekly { + min-width: 0; +} + .history-page__title { margin-bottom: 12px; } diff --git a/frontend/src/components/NutritionCharts.jsx b/frontend/src/components/NutritionCharts.jsx index 9a03343..9d8d312 100644 --- a/frontend/src/components/NutritionCharts.jsx +++ b/frontend/src/components/NutritionCharts.jsx @@ -1,7 +1,8 @@ import { useState, useEffect } from 'react' import { LineChart, Line, BarChart, Bar, - XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend + XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend, + ComposedChart, ReferenceArea, } from 'recharts' import { api } from '../utils/api' import dayjs from 'dayjs' @@ -135,16 +136,74 @@ function WarningCard({ title, warning_level, triggers, message }) { ) } +/** Wöchentliche Makro-Verteilung (E3) — für Verlauf neben Donut nutzbar. */ +export function WeeklyMacroDistributionPanel({ macroWeeklyData, loading, error }) { + if (loading) { + return ( +
+
+
+ ) + } + if (error) { + return ( +
{error}
+ ) + } + if (!macroWeeklyData || macroWeeklyData.metadata?.confidence === 'insufficient') { + const msg = macroWeeklyData?.metadata?.message || 'Nicht genug Daten für Wochen-Analyse (min. 7 Tage)' + return ( +
{msg}
+ ) + } + + const chartData = macroWeeklyData.data.labels.map((label, i) => ({ + week: label, + protein: macroWeeklyData.data.datasets[0]?.data[i], + carbs: macroWeeklyData.data.datasets[1]?.data[i], + fat: macroWeeklyData.data.datasets[2]?.data[i], + })) + + const meta = macroWeeklyData.metadata + + return ( + <> +
+ Anteil der Kalorien aus jedem Makronährstoff pro Kalenderwoche (100 % gestapelt). Gut vergleichbar mit der + Donut-Übersicht links. +
+ + + + + + [`${v}%`, name]} + /> + + + + + + +
+ Ø Verteilung: P {meta.avg_protein_pct}% · KH {meta.avg_carbs_pct}% · F {meta.avg_fat_pct}% · Variabilität (CV): P{' '} + {meta.protein_cv}% · KH {meta.carbs_cv}% · F {meta.fat_cv}% +
+ + ) +} + /** - * Nutrition Charts Component (E1-E5) - Konzept-konform v2.0 - * - * E1: Energy Balance (mit 7d/14d Durchschnitten) - * E2: Protein Adequacy (mit 7d/28d Durchschnitten) - * E3: Weekly Macro Distribution (100% gestapelte Balken) - * E4: Nutrition Adherence Score (0-100, goal-aware) - * E5: Energy Availability Warning (Ampel-System) + * Nutrition Charts (E1–E5). Verlauf: `showWeeklyMacroDistribution={false}` wenn E3 separat (z. B. neben Donut) gerendert wird. */ -export default function NutritionCharts({ days = 28 }) { +export default function NutritionCharts({ days = 28, showWeeklyMacroDistribution = true }) { const [energyData, setEnergyData] = useState(null) const [proteinData, setProteinData] = useState(null) const [macroWeeklyData, setMacroWeeklyData] = useState(null) @@ -159,16 +218,19 @@ export default function NutritionCharts({ days = 28 }) { useEffect(() => { loadCharts() - }, [days]) + }, [days, showWeeklyMacroDistribution]) const loadCharts = async () => { - await Promise.all([ + const tasks = [ loadEnergyBalance(), loadProteinAdequacy(), - loadMacroWeekly(), loadAdherence(), - loadWarning() - ]) + loadWarning(), + ] + if (showWeeklyMacroDistribution) { + tasks.splice(2, 0, loadMacroWeekly()) + } + await Promise.all(tasks) } const loadEnergyBalance = async () => { @@ -236,12 +298,13 @@ export default function NutritionCharts({ days = 28 }) { } } - // E1: Energy Balance Timeline (mit 7d/14d Durchschnitten) + // E1: Energy Balance — klare Farben (kein hellgraues Gewirr) const renderEnergyBalance = () => { if (!energyData || energyData.metadata?.confidence === 'insufficient') { - return
- Nicht genug Ernährungsdaten (min. 7 Tage) -
+ const msg = energyData?.metadata?.message || 'Nicht genug Ernährungsdaten für die Energiebilanz.' + return ( +
{msg}
+ ) } const chartData = energyData.data.labels.map((label, i) => ({ @@ -249,7 +312,7 @@ export default function NutritionCharts({ days = 28 }) { täglich: energyData.data.datasets[0]?.data[i], avg7d: energyData.data.datasets[1]?.data[i], avg14d: energyData.data.datasets[2]?.data[i], - tdee: energyData.data.datasets[3]?.data[i] + tdee: energyData.data.datasets[3]?.data[i], })) const balance = energyData.metadata?.energy_balance || 0 @@ -257,111 +320,90 @@ export default function NutritionCharts({ days = 28 }) { return ( <> - - - - - - - - - - - +
+ Tägliche Aufnahme, gleitende Mittel und geschätzter TDEE — Linien sind farblich getrennt (Legende unten). +
+ + + + + + + + + + + -
- - Ø {energyData.metadata.avg_kcal} kcal/Tag · - - - Balance: {balance > 0 ? '+' : ''}{balance} kcal/Tag - - - · {energyData.metadata.data_points} Tage +
+ Ø {energyData.metadata.avg_kcal} kcal/Tag · + + Balance: {balance > 0 ? '+' : ''} + {balance} kcal/Tag + · {energyData.metadata.data_points} Tage
) } - // E2: Protein Adequacy Timeline (mit 7d/28d Durchschnitten) + // E2: Protein — Zielzone als Fläche, Linien klar von E1 abgrenzbar const renderProteinAdequacy = () => { if (!proteinData || proteinData.metadata?.confidence === 'insufficient') { - return
- Nicht genug Protein-Daten (min. 7 Tage) -
+ const msg = proteinData?.metadata?.message || 'Nicht genug Protein-Daten für dieses Diagramm.' + return ( +
{msg}
+ ) } + const tl = proteinData.metadata.target_low + const th = proteinData.metadata.target_high + const chartData = proteinData.data.labels.map((label, i) => ({ date: fmtDate(label), täglich: proteinData.data.datasets[0]?.data[i], avg7d: proteinData.data.datasets[1]?.data[i], avg28d: proteinData.data.datasets[2]?.data[i], - targetLow: proteinData.data.datasets[3]?.data[i], - targetHigh: proteinData.data.datasets[4]?.data[i] })) return ( <> - - - - - - - - - - - - - - -
- {proteinData.metadata.days_in_target}/{proteinData.metadata.data_points} Tage im Zielbereich ({proteinData.metadata.target_compliance_pct}%) +
+ Grüne Zone = empfohlenes Protein-Ziel (g/Tag). Tägliche Werte und Mittel — andere Farben als Energiebilanz oben.
- - ) - } - - // E3: Weekly Macro Distribution (100% gestapelte Balken) - const renderMacroWeekly = () => { - if (!macroWeeklyData || macroWeeklyData.metadata?.confidence === 'insufficient') { - return
- Nicht genug Daten für Wochen-Analyse (min. 7 Tage) -
- } - - const chartData = macroWeeklyData.data.labels.map((label, i) => ({ - week: label, - protein: macroWeeklyData.data.datasets[0]?.data[i], - carbs: macroWeeklyData.data.datasets[1]?.data[i], - fat: macroWeeklyData.data.datasets[2]?.data[i] - })) - - const meta = macroWeeklyData.metadata - - return ( - <> - - - - - - - - - - - + + + + + + + {tl != null && th != null && ( + + )} + + + + + -
- Ø Verteilung: P {meta.avg_protein_pct}% · C {meta.avg_carbs_pct}% · F {meta.avg_fat_pct}% · - Konsistenz (CV): P {meta.protein_cv}% · C {meta.carbs_cv}% · F {meta.fat_cv}% +
+ Ziel {tl}–{th} g/Tag · {proteinData.metadata.days_in_target}/{proteinData.metadata.data_points} Tage im Zielbereich ( + {proteinData.metadata.target_compliance_pct}%)
) @@ -414,17 +456,19 @@ export default function NutritionCharts({ days = 28 }) { return (
- + {renderEnergyBalance()} - + {renderProteinAdequacy()} - - {renderMacroWeekly()} - + {showWeeklyMacroDistribution && ( + + + + )} {!loading.adherence && !errors.adherence && renderAdherence()} {!loading.warning && !errors.warning && renderWarning()} diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 587a45f..9e18919 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -13,7 +13,7 @@ import { getBfCategory } from '../utils/calc' import { getStatusColor, getStatusBg } from '../utils/interpret' import Markdown from '../utils/Markdown' import TrainingTypeDistribution from '../components/TrainingTypeDistribution' -import NutritionCharts from '../components/NutritionCharts' +import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts' import RecoveryCharts from '../components/RecoveryCharts' import KpiTilesOverview from '../components/KpiTilesOverview' import dayjs from 'dayjs' @@ -233,6 +233,51 @@ function buildBodyKpiTiles({ return tiles } +function NutritionGoalsStrip({ grouped }) { + const nav = useNavigate() + const goals = (grouped?.nutrition || []).filter(g => g.status === 'active').slice(0, 4) + if (!goals.length) return null + return ( +
+
+
Ernährungsbezogene Ziele
+ +
+
+ {goals.map(g => ( +
+
{g.name || g.label_de || g.goal_type}
+
+
+
+
+ {Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''} +
+
+ ))} +
+
+ ) +} + function BodyGoalsStrip({ grouped }) { const nav = useNavigate() const goals = (grouped?.body || []).filter(g => g.status === 'active').slice(0, 4) @@ -653,191 +698,296 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
) } -// ── Nutrition Section ───────────────────────────────────────────────────────── -function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) { - const [period, setPeriod] = useState(30) - if (!nutrition?.length) return ( - - ) - const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD') - const filtN = nutrition.filter(d=>period===9999||d.date>=cutoff) - const sorted = [...filtN].sort((a,b)=>a.date.localeCompare(b.date)) +function buildNutritionKpiTiles({ + avgKcal, avgCarbs, avgFat, n, macroRules, dateSpanLabel, +}) { + const tiles = [ + { + key: 'kcal', + category: 'Kalorien (Ø)', + icon: '🔥', + value: `${avgKcal} kcal`, + sublabel: dateSpanLabel, + status: 'good', + verdict: '', + hoverTop: 'Durchschnittliche tägliche Energie', + hoverBody: `Mittel über ${n} Tage mit Ernährungseinträgen im gewählten Zeitraum.`, + }, + { + key: 'carbs', + category: 'KH (Ø)', + icon: '🌾', + value: `${avgCarbs} g`, + sublabel: 'Kohlenhydrate / Tag', + status: 'good', + verdict: '', + hoverTop: 'Durchschnittliche Kohlenhydrate', + hoverBody: 'Summe der täglichen Werte im Zeitraum, gemittelt.', + }, + { + key: 'fat', + category: 'Fett (Ø)', + icon: '🧈', + value: `${avgFat} g`, + sublabel: 'Fett / Tag', + status: 'good', + verdict: '', + hoverTop: 'Durchschnittliches Fett', + hoverBody: 'Summe der täglichen Werte im Zeitraum, gemittelt.', + }, + ] + macroRules.forEach((r, i) => { + tiles.push({ + key: `eval-${i}`, + category: r.category, + icon: r.icon, + value: r.value, + sublabel: r.title.length > 36 ? `${r.title.slice(0, 34)}…` : r.title, + status: r.status, + verdict: verdictShort(r.status === 'warn' ? 'warn' : r.status === 'bad' ? 'bad' : 'good'), + hoverTop: r.title, + hoverBody: r.detail, + }) + }) + return tiles +} - if (!filtN.length) return ( -
- - - +/** Kalorien (Ø 7T) vs. Gewicht — gleiche Logik wie früher unter Korrelationen. */ +function KcalVsWeightChart({ corrData: corrRows, profile, cutoffDate, allTime }) { + const raw = (corrRows || []).filter(d => { + if (!d.kcal || d.weight == null) return false + const ds = typeof d.date === 'string' ? d.date.slice(0, 10) : dayjs(d.date).format('YYYY-MM-DD') + return allTime || ds >= cutoffDate + }) + if (raw.length < 5) return null + + const sex = profile?.sex || 'm' + const height = profile?.height || 178 + const latestW = raw[raw.length - 1]?.weight || 80 + const age = profile?.dob ? Math.floor((Date.now() - new Date(profile.dob)) / (365.25 * 24 * 3600 * 1000)) : 35 + const bmr = sex === 'm' ? 10 * latestW + 6.25 * height - 5 * age + 5 : 10 * latestW + 6.25 * height - 5 * age - 161 + const tdee = Math.round(bmr * 1.4) + const kcalVsW = rollingAvg(raw.map(d => ({ ...d, date: fmtDate(d.date) })), 'kcal') + + return ( +
+
+ Kalorien (Ø 7 Tage) vs. Gewicht +
+
+ Gleitender 7-Tage-Mittelwert der Kalorien vs. tägliches Gewicht (gemeinsame Tage). Orange: kcal · Blau: Gewicht. +
+ + + + + + + [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} + /> + + + + + +
+ Referenz TDEE ~{tdee} kcal (Mifflin ×1,4, gestrichelt) · {raw.length} gemeinsame Tage +
) +} + +// ── Nutrition Section ───────────────────────────────────────────────────────── +function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs, corrData }) { + const [period, setPeriod] = useState(30) + const [groupedGoals, setGroupedGoals] = useState(null) + const chartDays = period === 9999 ? 90 : period + const weeks = Math.max(4, Math.min(52, Math.ceil(chartDays / 7))) + const [weeklyMacro, setWeeklyMacro] = useState(null) + const [wmLoading, setWmLoading] = useState(false) + const [wmError, setWmError] = useState(null) + + useEffect(() => { + let cancelled = false + api.listGoalsGrouped() + .then(g => { if (!cancelled) setGroupedGoals(g) }) + .catch(() => { if (!cancelled) setGroupedGoals({}) }) + return () => { cancelled = true } + }, []) + + useEffect(() => { + let cancelled = false + setWmLoading(true) + setWmError(null) + api.getWeeklyMacroDistributionChart(weeks) + .then(d => { if (!cancelled) setWeeklyMacro(d) }) + .catch(e => { if (!cancelled) setWmError(e.message || 'Laden fehlgeschlagen') }) + .finally(() => { if (!cancelled) setWmLoading(false) }) + return () => { cancelled = true } + }, [weeks]) + + if (!nutrition?.length) { + return ( + + ) + } + + const cutoff = dayjs().subtract(period, 'day').format('YYYY-MM-DD') + const filtN = nutrition.filter(d => period === 9999 || d.date >= cutoff) + const sorted = [...filtN].sort((a, b) => a.date.localeCompare(b.date)) + + if (!filtN.length) { + return ( +
+ + + +
+ ) + } const n = filtN.length - const avgKcal = Math.round(filtN.reduce((s,d)=>s+(d.kcal||0),0)/n) - const avgProtein = Math.round(filtN.reduce((s,d)=>s+(d.protein_g||0),0)/n*10)/10 - const avgFat = Math.round(filtN.reduce((s,d)=>s+(d.fat_g||0),0)/n*10)/10 - const avgCarbs = Math.round(filtN.reduce((s,d)=>s+(d.carbs_g||0),0)/n*10)/10 - const latestW = weights?.[0]?.weight||80 - const ptLow = Math.round(latestW*1.6) - const ptHigh = Math.round(latestW*2.2) - const proteinOk = avgProtein>=ptLow + const avgKcal = Math.round(filtN.reduce((s, d) => s + (d.kcal || 0), 0) / n) + const avgProtein = Math.round(filtN.reduce((s, d) => s + (d.protein_g || 0), 0) / n * 10) / 10 + const avgFat = Math.round(filtN.reduce((s, d) => s + (d.fat_g || 0), 0) / n * 10) / 10 + const avgCarbs = Math.round(filtN.reduce((s, d) => s + (d.carbs_g || 0), 0) / n * 10) / 10 + const latestW = weights?.[0]?.weight || 80 + const ptLow = Math.round(latestW * 1.6) + const ptHigh = Math.round(latestW * 2.2) + const proteinOk = avgProtein >= ptLow - // Stacked macro bar (daily) - const cdMacro = sorted.map(d=>({ + const cdMacro = sorted.map(d => ({ date: fmtDate(d.date), - Protein: Math.round(d.protein_g||0), - KH: Math.round(d.carbs_g||0), - Fett: Math.round(d.fat_g||0), - kcal: Math.round(d.kcal||0), + Protein: Math.round(d.protein_g || 0), + KH: Math.round(d.carbs_g || 0), + Fett: Math.round(d.fat_g || 0), + kcal: Math.round(d.kcal || 0), })) - // Pie - const totalMacroKcal = avgProtein*4+avgCarbs*4+avgFat*9 + const totalMacroKcal = avgProtein * 4 + avgCarbs * 4 + avgFat * 9 const pieData = [ - {name:'Protein',value:Math.round(avgProtein*4/totalMacroKcal*100),color:'#1D9E75'}, - {name:'KH', value:Math.round(avgCarbs*4/totalMacroKcal*100), color:'#D4537E'}, - {name:'Fett', value:Math.round(avgFat*9/totalMacroKcal*100), color:'#378ADD'}, + { name: 'Protein', value: Math.round(avgProtein * 4 / totalMacroKcal * 100), color: '#059669' }, + { name: 'KH', value: Math.round(avgCarbs * 4 / totalMacroKcal * 100), color: '#EA580C' }, + { name: 'Fett', value: Math.round(avgFat * 9 / totalMacroKcal * 100), color: '#2563EB' }, ] - // Weekly macro bars - const weeklyMap={} - filtN.forEach(d=>{ - const wk=dayjs(d.date).format('YYYY-WW') - const weekNum = (() => { const dt=new Date(d.date); dt.setHours(0,0,0,0); dt.setDate(dt.getDate()+4-(dt.getDay()||7)); const y=new Date(dt.getFullYear(),0,1); return Math.ceil(((dt-y)/86400000+1)/7) })() - if(!weeklyMap[wk]) weeklyMap[wk]={label:'KW'+weekNum,n:0,protein:0,carbs:0,fat:0,kcal:0} - weeklyMap[wk].protein+=d.protein_g||0; weeklyMap[wk].carbs+=d.carbs_g||0 - weeklyMap[wk].fat+=d.fat_g||0; weeklyMap[wk].kcal+=d.kcal||0; weeklyMap[wk].n++ - }) - const weeklyData=Object.values(weeklyMap).slice(-12).map(w=>({ - label:w.label, - Protein:Math.round(w.protein/w.n), - KH:Math.round(w.carbs/w.n), - Fett:Math.round(w.fat/w.n), - kcal:Math.round(w.kcal/w.n), - })) + const macroRules = [] + if (!proteinOk) { + macroRules.push({ + status: 'bad', icon: '🥩', category: 'Protein', + title: `Unterversorgung: ${avgProtein}g/Tag (Ziel ${ptLow}–${ptHigh}g)`, + detail: `1,6–2,2g/kg KG. Fehlend: ~${Math.max(0, ptLow - Math.round(avgProtein))}g täglich. Konsequenz: Muskelverlust bei Defizit.`, + value: `${avgProtein}g`, + }) + } else { + macroRules.push({ + status: 'good', icon: '🥩', category: 'Protein', + title: `Gut: ${avgProtein}g/Tag (Ziel ${ptLow}–${ptHigh}g)`, + detail: 'Ausreichend für Muskelerhalt und -aufbau.', + value: `${avgProtein}g`, + }) + } + const protPct = Math.round(avgProtein * 4 / totalMacroKcal * 100) + if (protPct < 20) { + macroRules.push({ + status: 'warn', icon: '📊', category: 'Makro-Anteil', + title: `Protein-Anteil niedrig: ${protPct}% der Kalorien`, + detail: `Empfehlung oft 25–35%. Aktuell: ${protPct}% P / ${Math.round(avgCarbs * 4 / totalMacroKcal * 100)}% KH / ${Math.round(avgFat * 9 / totalMacroKcal * 100)}% F`, + value: `${protPct}%`, + }) + } - // Rules - const macroRules=[] - if(!proteinOk) macroRules.push({status:'bad',icon:'🥩',category:'Protein', - title:`Unterversorgung: ${avgProtein}g/Tag (Ziel ${ptLow}–${ptHigh}g)`, - detail:`1,6–2,2g/kg KG. Fehlend: ~${ptLow-Math.round(avgProtein)}g täglich. Konsequenz: Muskelverlust bei Defizit.`, - value:avgProtein+'g'}) - else macroRules.push({status:'good',icon:'🥩',category:'Protein', - title:`Gut: ${avgProtein}g/Tag (Ziel ${ptLow}–${ptHigh}g)`, - detail:`Ausreichend für Muskelerhalt und -aufbau.`,value:avgProtein+'g'}) - const protPct=Math.round(avgProtein*4/totalMacroKcal*100) - if(protPct<20) macroRules.push({status:'warn',icon:'📊',category:'Makro-Anteil', - title:`Protein-Anteil niedrig: ${protPct}% der Kalorien`, - detail:`Empfehlung: 25–35%. Aktuell: ${protPct}% P / ${Math.round(avgCarbs*4/totalMacroKcal*100)}% KH / ${Math.round(avgFat*9/totalMacroKcal*100)}% F`, - value:protPct+'%'}) + const dateSpanLabel = `${sorted[0]?.date?.slice(0, 10) ?? ''} – ${sorted[sorted.length - 1]?.date?.slice(0, 10) ?? ''}` + const kpiTiles = buildNutritionKpiTiles({ + avgKcal, avgCarbs, avgFat, n, macroRules, dateSpanLabel, + }) return (
-
- {[['Ø Kalorien',avgKcal+' kcal','#EF9F27'],['Ø Protein',avgProtein+'g',proteinOk?'#1D9E75':'#D85A30'], - ['Ø Fett',avgFat+'g','#378ADD'],['Ø KH',avgCarbs+'g','#D4537E'], - ['Einträge',n+' T','var(--text3)']].map(([l,v,c])=>( -
-
{v}
-
{l}
-
- ))} -
+

+ Kennzahlen und Charts nutzen dieselben Datenquellen wie die KI-Platzhalter (Ernährungs-Log, Gewicht).{' '} + Kalorien vs. Gewicht bezieht gemeinsame Tage aus Ernährung und Gewicht. +

- {/* Stacked macro bars (daily) */} -
-
- Makroverteilung täglich (g) · {sorted[0]?.date?.slice(0,7)} – {sorted[sorted.length-1]?.date?.slice(0,7)} + + + + + + +
+
+ Makroverteilung täglich (g) · Fokus Protein
- - - - - - - [`${v}g`,n]}/> - - - +
+ Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow} g) nach 1,6 g/kg (Referenzgewicht). +
+ + + + + + + [`${v}g`, name]} /> + + + -
- Protein - KH - Fett - Protein-Ziel +
+ Protein (oben) + KH + Fett
- {/* Pie + macro breakdown */} -
-
- Ø Makroverteilung · {n} Tage ({sorted[0]?.date?.slice(0,10)} – {sorted[sorted.length-1]?.date?.slice(0,10)}) -
-
- - - {pieData.map((e,i)=>)} - - [`${v}%`,n]}/> - -
- {pieData.map(p=>( -
-
-
{p.name}
-
{p.value}%
-
{Math.round(p.name==='Protein'?avgProtein:p.name==='KH'?avgCarbs:avgFat)}g
- {p.name==='Protein' &&
- {proteinOk?'✓':'⚠️'} Ziel {ptLow}g -
} +
+
+
+ Ø Makro-Quote ({n} Tage) +
+
+ + + {pieData.map((e, i) => )} + + [`${v}%`, name]} /> + +
+ {pieData.map(p => ( +
+
+
{p.name}
+
{p.value}%
+
+ {Math.round(p.name === 'Protein' ? avgProtein : p.name === 'KH' ? avgCarbs : avgFat)}g +
+
+ ))} +
+ Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz
- ))} -
- Gesamt: {avgKcal} kcal/Tag
-
- - {/* Weekly stacked bars */} - {weeklyData.length>=2 && ( -
-
Makros pro Woche (Ø g/Tag)
- - - - - - - [`${v}g`,n]}/> - - - - - +
+
+ Wöchentliche Makro-Verteilung (Backend) +
+
- )} - -
-
BEWERTUNG
- {macroRules.map((item,i)=>)}
- {/* New Nutrition Charts (Phase 0c) */} -
-
📊 DETAILLIERTE CHARTS
- +
+ Zeitverläufe (Energie & Protein)
+
@@ -964,10 +1114,7 @@ function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlu const bmr = sex==='m' ? 10*latestW+6.25*height-5*age+5 : 10*latestW+6.25*height-5*age-161 const tdee = Math.round(bmr*1.4) // light activity baseline - // Chart 1: Kcal vs Weight - const kcalVsW = rollingAvg(filtered.map(d=>({...d,date:fmtDate(d.date)})),'kcal') - - // Chart 2: Protein vs Lean Mass (only days with both) + // Protein vs Lean Mass (only days with both) const protVsLean = filtered.filter(d=>d.protein_g&&d.lean_mass) .map(d=>({date:fmtDate(d.date),protein:d.protein_g,lean:d.lean_mass})) @@ -1043,31 +1190,11 @@ function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlu
- {/* Chart 1: Kcal vs Weight */} -
-
- 📉 Kalorien (Ø 7T) vs. Gewicht -
- - - - - - - [`${Math.round(v)} ${n==='weight'?'kg':'kcal'}`,n==='kcal_avg'?'Ø Kalorien':'Gewicht']}/> - - - - - -
- Gestrichelt: geschätzter TDEE {tdee} kcal · — Kalorien · — Gewicht -
-
+

+ Das Diagramm Kalorien (Ø 7T) vs. Gewicht liegt unter Verlauf → Ernährung (gleiche Datenbasis). +

- {/* Chart 2: Calorie balance */} + {/* Chart: Calorie balance */}
⚖️ Kalorienbilanz (Aufnahme − TDEE {tdee} kcal) @@ -1374,7 +1501,7 @@ export default function History() {
{tab==='body' && } - {tab==='nutrition' && } + {tab==='nutrition' && } {tab==='activity' && } {tab==='recovery' && } {tab==='correlation' && } From b96b1931dbe85cfe705ba938f54087f0223a6ccc Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 19 Apr 2026 17:20:24 +0200 Subject: [PATCH 4/7] feat: implement nutrition history visualization bundle and related API endpoint - Added a new `nutrition_interpretation.py` file to handle KPI tile generation for nutrition history. - Introduced `nutrition_viz.py` to create a visualization bundle for nutrition data, integrating metrics and historical analysis. - Implemented `get_nutrition_history_viz` endpoint in `charts.py` to serve the new visualization data. - Updated frontend components to fetch and display nutrition history data, enhancing user experience with detailed insights. - Refactored existing logic to streamline data handling and improve overall performance. --- .../data_layer/nutrition_interpretation.py | 180 +++++++++++ backend/data_layer/nutrition_metrics.py | 152 ++++++++- backend/data_layer/nutrition_viz.py | 283 +++++++++++++++++ backend/routers/charts.py | 152 ++------- frontend/src/pages/History.jsx | 297 +++++++++--------- frontend/src/utils/api.js | 2 + 6 files changed, 774 insertions(+), 292 deletions(-) create mode 100644 backend/data_layer/nutrition_interpretation.py create mode 100644 backend/data_layer/nutrition_viz.py diff --git a/backend/data_layer/nutrition_interpretation.py b/backend/data_layer/nutrition_interpretation.py new file mode 100644 index 0000000..304f189 --- /dev/null +++ b/backend/data_layer/nutrition_interpretation.py @@ -0,0 +1,180 @@ +""" +Interpretation + KPI-Kacheln für Layer 2b Ernährungs-Verlauf. + +Gleiche Schwellen wie zuvor im Frontend (History.jsx); Ausgabe strukturiert +für KpiTilesOverview (keys = related_placeholder_keys). +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + + +def _verdict(status: str) -> str: + if status == "good": + return "Gut" + if status == "warn": + return "Hinweis" + return "Achtung" + + +def build_nutrition_history_kpi_tiles( + navg: Dict[str, Any], + targets: Dict[str, Any], + date_span_label: str, + n_days_with_entries: int, +) -> List[Dict[str, Any]]: + """ + KPI-Kacheln wie buildNutritionKpiTiles im Frontend (Kalorien/KH/Fett + Regeln). + """ + kcal_avg = round(float(navg.get("kcal_avg") or 0)) + avg_carbs = round(float(navg.get("carbs_avg") or 0) * 10) / 10 + avg_fat = round(float(navg.get("fat_avg") or 0) * 10) / 10 + avg_protein = round(float(navg.get("protein_avg") or 0) * 10) / 10 + + pt_low = round(float(targets.get("protein_target_low") or 0)) + pt_high = round(float(targets.get("protein_target_high") or 0)) + targets_ok = targets.get("confidence") != "insufficient" and pt_low > 0 + protein_ok = targets_ok and avg_protein >= pt_low + + total_macro_kcal = avg_protein * 4 + avg_carbs * 4 + avg_fat * 9 + prot_pct = ( + round(avg_protein * 4 / total_macro_kcal * 100) + if total_macro_kcal > 0 + else 0 + ) + kh_pct = ( + round(avg_carbs * 4 / total_macro_kcal * 100) + if total_macro_kcal > 0 + else 0 + ) + fat_pct = ( + round(avg_fat * 9 / total_macro_kcal * 100) + if total_macro_kcal > 0 + else 0 + ) + + tiles: List[Dict[str, Any]] = [ + { + "key": "kcal", + "category": "Kalorien (Ø)", + "icon": "🔥", + "value": f"{kcal_avg} kcal", + "sublabel": date_span_label, + "status": "good", + "verdict": "Gut", + "hoverTop": "Durchschnittliche tägliche Energie", + "hoverBody": f"Mittel über {n_days_with_entries} Tage mit Ernährungseinträgen im gewählten Zeitraum.", + "keys": ["nutrition_score"], + }, + { + "key": "carbs", + "category": "KH (Ø)", + "icon": "🌾", + "value": f"{avg_carbs} g", + "sublabel": "Kohlenhydrate / Tag", + "status": "good", + "verdict": "Gut", + "hoverTop": "Durchschnittliche Kohlenhydrate", + "hoverBody": "Summe der täglichen Werte im Zeitraum, gemittelt.", + "keys": ["nutrition_summary"], + }, + { + "key": "fat", + "category": "Fett (Ø)", + "icon": "🧈", + "value": f"{avg_fat} g", + "sublabel": "Fett / Tag", + "status": "good", + "verdict": "Gut", + "hoverTop": "Durchschnittliches Fett", + "hoverBody": "Summe der täglichen Werte im Zeitraum, gemittelt.", + "keys": ["nutrition_summary"], + }, + ] + + if not targets_ok: + tiles.append( + { + "key": "eval-protein", + "category": "Protein", + "icon": "🥩", + "value": f"{avg_protein}g", + "sublabel": "Referenzgewicht fehlt", + "status": "warn", + "verdict": _verdict("warn"), + "hoverTop": "Protein-Ziel nicht berechenbar", + "hoverBody": "Für 1,6–2,2 g/kg wird ein aktuelles Körpergewicht benötigt.", + "keys": ["protein_adequacy"], + } + ) + elif not protein_ok: + miss = max(0, pt_low - round(avg_protein)) + tiles.append( + { + "key": "eval-protein", + "category": "Protein", + "icon": "🥩", + "value": f"{avg_protein}g", + "sublabel": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)", + "status": "bad", + "verdict": _verdict("bad"), + "hoverTop": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)", + "hoverBody": ( + f"1,6–2,2g/kg KG. Fehlend: ~{miss}g täglich. " + "Konsequenz: Muskelverlust bei Defizit." + ), + "keys": ["protein_adequacy", "nutrition_score"], + } + ) + else: + tiles.append( + { + "key": "eval-protein", + "category": "Protein", + "icon": "🥩", + "value": f"{avg_protein}g", + "sublabel": f"Gut: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)", + "status": "good", + "verdict": _verdict("good"), + "hoverTop": f"Gut: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)", + "hoverBody": "Ausreichend für Muskelerhalt und -aufbau.", + "keys": ["protein_adequacy", "nutrition_score"], + } + ) + + if prot_pct < 20 and total_macro_kcal > 0: + tiles.append( + { + "key": "eval-macro-pct", + "category": "Makro-Anteil", + "icon": "📊", + "value": f"{prot_pct}%", + "sublabel": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien", + "status": "warn", + "verdict": _verdict("warn"), + "hoverTop": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien", + "hoverBody": ( + f"Empfehlung oft 25–35%. Aktuell: {prot_pct}% P / {kh_pct}% KH / {fat_pct}% F" + ), + "keys": ["nutrition_summary"], + } + ) + + return tiles + + +def build_macro_donut_from_averages(navg: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]: + """Anteile in % der Makro-kcal + Gramm für Legende.""" + p = float(navg.get("protein_avg") or 0) + c = float(navg.get("carbs_avg") or 0) + f = float(navg.get("fat_avg") or 0) + pkcal, ckcal, fkcal = p * 4, c * 4, f * 9 + tot = pkcal + ckcal + fkcal + if tot <= 0: + return None + return [ + {"name": "Protein", "value": round(pkcal / tot * 100), "color": "#059669", "grams": round(p, 1)}, + {"name": "KH", "value": round(ckcal / tot * 100), "color": "#EA580C", "grams": round(c, 1)}, + {"name": "Fett", "value": round(fkcal / tot * 100), "color": "#2563EB", "grams": round(f, 1)}, + ] diff --git a/backend/data_layer/nutrition_metrics.py b/backend/data_layer/nutrition_metrics.py index 7ce9fa7..9389d48 100644 --- a/backend/data_layer/nutrition_metrics.py +++ b/backend/data_layer/nutrition_metrics.py @@ -20,6 +20,7 @@ Phase 0c: Multi-Layer Architecture Version: 1.0 """ +import statistics from typing import Dict, List, Optional from datetime import datetime, timedelta, date from db import get_db, get_cursor, r2d @@ -110,7 +111,9 @@ def _get_profile_goal_mode(profile_id: str) -> str: def get_nutrition_average_data( profile_id: str, - days: int = 30 + days: int = 30, + *, + all_history: bool = False, ) -> Dict: """ Get average nutrition values for all macros. @@ -136,11 +139,18 @@ def get_nutrition_average_data( """ with get_db() as conn: cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cutoff = None if all_history else (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') # Mean over calendar days (per-day sums), not over raw log rows. + if cutoff: + inner_where = "WHERE profile_id=%s AND date >= %s" + params = (profile_id, cutoff) + else: + inner_where = "WHERE profile_id=%s" + params = (profile_id,) + cur.execute( - """SELECT + f"""SELECT AVG(daily_kcal) AS kcal_avg, AVG(daily_protein) AS protein_avg, AVG(daily_carbs) AS carbs_avg, @@ -153,10 +163,10 @@ def get_nutrition_average_data( COALESCE(SUM(carbs_g), 0)::float AS daily_carbs, COALESCE(SUM(fat_g), 0)::float AS daily_fat FROM nutrition_log - WHERE profile_id=%s AND date >= %s + {inner_where} GROUP BY date ) AS daily""", - (profile_id, cutoff), + params, ) row = cur.fetchone() @@ -494,8 +504,6 @@ def get_macro_consistency_data( "data_points": len(rows) } - import statistics - protein_pcts = [] carbs_pcts = [] fat_pcts = [] @@ -561,6 +569,136 @@ def get_macro_consistency_data( } +def get_weekly_macro_distribution_chart_data(profile_id: str, weeks: int) -> Dict: + """ + Chart E3: gestapelte Wochenbalken (Makro-%), gleiche Logik wie /charts/weekly-macro-distribution. + """ + cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime("%Y-%m-%d") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """SELECT date, protein_g, carbs_g, fat_g, kcal + FROM nutrition_log + WHERE profile_id=%s AND date >= %s + AND protein_g IS NOT NULL AND carbs_g IS NOT NULL + AND fat_g IS NOT NULL AND kcal > 0 + ORDER BY date""", + (profile_id, cutoff), + ) + rows = cur.fetchall() + + if not rows or len(rows) < 7: + return { + "chart_type": "bar", + "data": { + "labels": [], + "datasets": [], + }, + "metadata": { + "confidence": "insufficient", + "data_points": len(rows) if rows else 0, + "message": "Nicht genug Daten für Wochen-Analyse (min. 7 Tage)", + }, + } + + weekly_data: Dict[str, Dict[str, List[float]]] = {} + for row in rows: + date_obj = row["date"] if isinstance(row["date"], datetime) else datetime.fromisoformat(str(row["date"])) + iso_week = date_obj.strftime("%Y-W%V") + + if iso_week not in weekly_data: + weekly_data[iso_week] = { + "protein": [], + "carbs": [], + "fat": [], + "kcal": [], + } + + weekly_data[iso_week]["protein"].append(safe_float(row["protein_g"])) + weekly_data[iso_week]["carbs"].append(safe_float(row["carbs_g"])) + weekly_data[iso_week]["fat"].append(safe_float(row["fat_g"])) + weekly_data[iso_week]["kcal"].append(safe_float(row["kcal"])) + + labels: List[str] = [] + protein_pcts: List[float] = [] + carbs_pcts: List[float] = [] + fat_pcts: List[float] = [] + + for iso_week in sorted(weekly_data.keys())[-weeks:]: + data = weekly_data[iso_week] + + avg_protein = sum(data["protein"]) / len(data["protein"]) if data["protein"] else 0 + avg_carbs = sum(data["carbs"]) / len(data["carbs"]) if data["carbs"] else 0 + avg_fat = sum(data["fat"]) / len(data["fat"]) if data["fat"] else 0 + + protein_kcal = avg_protein * 4 + carbs_kcal = avg_carbs * 4 + fat_kcal = avg_fat * 9 + + total_kcal = protein_kcal + carbs_kcal + fat_kcal + + if total_kcal > 0: + labels.append(f"KW {iso_week[-2:]}") + protein_pcts.append(round((protein_kcal / total_kcal) * 100, 1)) + carbs_pcts.append(round((carbs_kcal / total_kcal) * 100, 1)) + fat_pcts.append(round((fat_kcal / total_kcal) * 100, 1)) + + protein_cv = ( + statistics.stdev(protein_pcts) / statistics.mean(protein_pcts) * 100 + if len(protein_pcts) > 1 and statistics.mean(protein_pcts) > 0 + else 0 + ) + carbs_cv = ( + statistics.stdev(carbs_pcts) / statistics.mean(carbs_pcts) * 100 + if len(carbs_pcts) > 1 and statistics.mean(carbs_pcts) > 0 + else 0 + ) + fat_cv = ( + statistics.stdev(fat_pcts) / statistics.mean(fat_pcts) * 100 + if len(fat_pcts) > 1 and statistics.mean(fat_pcts) > 0 + else 0 + ) + + return { + "chart_type": "bar", + "data": { + "labels": labels, + "datasets": [ + { + "label": "Protein (%)", + "data": protein_pcts, + "backgroundColor": "#1D9E75", + "stack": "macro", + }, + { + "label": "Kohlenhydrate (%)", + "data": carbs_pcts, + "backgroundColor": "#F59E0B", + "stack": "macro", + }, + { + "label": "Fett (%)", + "data": fat_pcts, + "backgroundColor": "#EF4444", + "stack": "macro", + }, + ], + }, + "metadata": { + "confidence": calculate_confidence(len(rows), weeks * 7, "general"), + "data_points": len(rows), + "weeks_analyzed": len(labels), + "avg_protein_pct": round(statistics.mean(protein_pcts), 1) if protein_pcts else 0, + "avg_carbs_pct": round(statistics.mean(carbs_pcts), 1) if carbs_pcts else 0, + "avg_fat_pct": round(statistics.mean(fat_pcts), 1) if fat_pcts else 0, + "protein_cv": round(protein_cv, 1), + "carbs_cv": round(carbs_cv, 1), + "fat_cv": round(fat_cv, 1), + }, + } + + # ============================================================================ # Calculated Metrics (migrated from calculations/nutrition_metrics.py) # ============================================================================ diff --git a/backend/data_layer/nutrition_viz.py b/backend/data_layer/nutrition_viz.py new file mode 100644 index 0000000..f05b0a5 --- /dev/null +++ b/backend/data_layer/nutrition_viz.py @@ -0,0 +1,283 @@ +""" +Layer 2b: Ernährungs-Verlauf — ein Bundle für die UI (Issue #53). + +Single Source: nutrition_metrics + dieselben Tabellen wie Ernährungs-Platzhalter. +""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta +from typing import Any, Dict, List, Optional + +from db import get_db, get_cursor, r2d +from data_layer.nutrition_interpretation import ( + build_macro_donut_from_averages, + build_nutrition_history_kpi_tiles, +) +from data_layer.nutrition_metrics import ( + estimate_tdee_kcal_from_latest_weight, + get_energy_balance_data, + get_nutrition_average_data, + get_protein_targets_data, + get_weekly_macro_distribution_chart_data, +) +from data_layer.utils import safe_float + + +def _cutoff_sql(days: int) -> Optional[str]: + if days >= 9999: + return None + return (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + + +def _iso(d: Any) -> Optional[str]: + if d is None: + return None + if hasattr(d, "isoformat"): + return d.isoformat()[:10] + return str(d)[:10] + + +def _rolling_avg(rows: List[Dict[str, Any]], key: str, window: int) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + for i, d in enumerate(rows): + sl = rows[max(0, i - window + 1) : i + 1] + vals: List[float] = [] + for x in sl: + v = safe_float(x.get(key)) + if v is not None: + vals.append(v) + if not vals: + out.append({**d, f"{key}_avg": None}) + continue + avg = round(sum(vals) / len(vals), 1) + out.append({**d, f"{key}_avg": avg}) + return out + + +def _has_nutrition_entries(profile_id: str) -> bool: + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "SELECT 1 FROM nutrition_log WHERE profile_id=%s LIMIT 1", + (profile_id,), + ) + return cur.fetchone() is not None + + +def _last_nutrition_date(profile_id: str) -> Optional[str]: + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "SELECT MAX(date) AS d FROM nutrition_log WHERE profile_id=%s", + (profile_id,), + ) + row = cur.fetchone() + if not row or row["d"] is None: + return None + return _iso(row["d"]) + + +def _fetch_daily_macro_totals(profile_id: str, cutoff: Optional[str]) -> List[Dict[str, Any]]: + with get_db() as conn: + cur = get_cursor(conn) + if cutoff: + cur.execute( + """SELECT date, + COALESCE(SUM(kcal), 0)::float AS kcal, + COALESCE(SUM(protein_g), 0)::float AS protein_g, + COALESCE(SUM(carbs_g), 0)::float AS carbs_g, + COALESCE(SUM(fat_g), 0)::float AS fat_g + FROM nutrition_log + WHERE profile_id=%s AND date >= %s + GROUP BY date + ORDER BY date ASC""", + (profile_id, cutoff), + ) + else: + cur.execute( + """SELECT date, + COALESCE(SUM(kcal), 0)::float AS kcal, + COALESCE(SUM(protein_g), 0)::float AS protein_g, + COALESCE(SUM(carbs_g), 0)::float AS carbs_g, + COALESCE(SUM(fat_g), 0)::float AS fat_g + FROM nutrition_log + WHERE profile_id=%s + GROUP BY date + ORDER BY date ASC""", + (profile_id,), + ) + return [r2d(r) for r in cur.fetchall()] + + +def _kcal_weight_points_for_window( + profile_id: str, cutoff: Optional[str] +) -> List[Dict[str, Any]]: + """Gemeinsame Tage: Tages-kcal vs. Gewicht; gleiche Idee wie /nutrition/correlations, gefiltert.""" + with get_db() as conn: + cur = get_cursor(conn) + if cutoff: + cur.execute( + """SELECT date, SUM(kcal)::float AS kcal + FROM nutrition_log + WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL + GROUP BY date""", + (profile_id, cutoff), + ) + else: + cur.execute( + """SELECT date, SUM(kcal)::float AS kcal + FROM nutrition_log + WHERE profile_id=%s AND kcal IS NOT NULL + GROUP BY date""", + (profile_id,), + ) + nk = { _iso(r["date"]): safe_float(r["kcal"]) for r in cur.fetchall() } + + if cutoff: + cur.execute( + "SELECT date, weight FROM weight_log WHERE profile_id=%s AND date >= %s ORDER BY date", + (profile_id, cutoff), + ) + else: + cur.execute( + "SELECT date, weight FROM weight_log WHERE profile_id=%s ORDER BY date", + (profile_id,), + ) + wk = { _iso(r["date"]): safe_float(r["weight"]) for r in cur.fetchall() if r.get("weight") is not None } + + common = sorted(set(nk) & set(wk)) + raw: List[Dict[str, Any]] = [] + for ds in common: + raw.append({"date": ds, "kcal": nk[ds], "weight": wk[ds]}) + rolled = _rolling_avg(raw, "kcal", 7) + out: List[Dict[str, Any]] = [] + for r in rolled: + out.append( + { + "date": r["date"], + "kcal": r.get("kcal"), + "weight": r.get("weight"), + "kcal_avg": r.get("kcal_avg"), + } + ) + return out + + +def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]: + """ + Layer 2b Bundle für Verlauf «Ernährung». + + days: Analysefenster (>=9999 = gesamte Historie für Mittelwerte / Reihen). + """ + if not _has_nutrition_entries(profile_id): + return { + "confidence": "insufficient", + "has_nutrition_entries": False, + "message": "Noch keine Ernährungsdaten", + "kpi_tiles": [], + "summary": {}, + "daily_macros": [], + "donut_avg_pct": None, + "kcal_vs_weight": {"points": [], "tdee_reference_kcal": None, "common_days_count": 0}, + "weekly_macro_chart": {}, + "tdee_reference_kcal": None, + "energy_balance_meta": {}, + "interpretation_tiles": [], + "meta": {"layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz"}, + } + + all_history = days >= 9999 + eff_days = 3650 if all_history else max(7, min(int(days), 3650)) + cutoff = _cutoff_sql(days) + + navg = get_nutrition_average_data(profile_id, eff_days, all_history=all_history) + targets = get_protein_targets_data(profile_id) + energy_days = eff_days if not all_history else min(9999, 3650) + energy_meta = get_energy_balance_data(profile_id, energy_days) + tdee = estimate_tdee_kcal_from_latest_weight(profile_id) + if tdee is None: + tdee = safe_float(energy_meta.get("estimated_tdee")) or None + else: + tdee = float(tdee) + + daily_rows = _fetch_daily_macro_totals(profile_id, cutoff) + daily_macros: List[Dict[str, Any]] = [] + for r in daily_rows: + daily_macros.append( + { + "date": _iso(r["date"]), + "kcal": round(safe_float(r.get("kcal")) or 0), + "Protein": round(safe_float(r.get("protein_g")) or 0), + "KH": round(safe_float(r.get("carbs_g")) or 0), + "Fett": round(safe_float(r.get("fat_g")) or 0), + } + ) + + date_span_label = "" + if daily_macros: + date_span_label = f"{daily_macros[0]['date']} – {daily_macros[-1]['date']}" + + n_days = int(navg.get("data_points") or 0) + kpi_tiles = build_nutrition_history_kpi_tiles( + navg, targets, date_span_label or "—", max(1, n_days) + ) + + donut = build_macro_donut_from_averages(navg) + + kw_points = _kcal_weight_points_for_window(profile_id, cutoff) + pt_low = round(float(targets.get("protein_target_low") or 0)) + + chart_days_for_pipeline = 90 if all_history else max(7, min(eff_days, 365)) + weeks_for_weekly = max(4, min(52, (chart_days_for_pipeline + 6) // 7)) + weekly_chart = get_weekly_macro_distribution_chart_data(profile_id, weeks_for_weekly) + + conf = navg.get("confidence") or "medium" + if targets.get("confidence") == "insufficient": + conf = "insufficient" + + return { + "confidence": conf, + "has_nutrition_entries": True, + "days_requested": days, + "effective_window_days": eff_days, + "nutrition_charts_days": chart_days_for_pipeline, + "weekly_macro_weeks_used": weeks_for_weekly, + "last_updated": _last_nutrition_date(profile_id), + "summary": { + "kcal_avg": navg.get("kcal_avg"), + "protein_avg": navg.get("protein_avg"), + "carbs_avg": navg.get("carbs_avg"), + "fat_avg": navg.get("fat_avg"), + "data_points": navg.get("data_points"), + "days_analyzed": navg.get("days_analyzed"), + "protein_target_low": targets.get("protein_target_low"), + "protein_target_high": targets.get("protein_target_high"), + "reference_weight_kg": targets.get("current_weight"), + }, + "kpi_tiles": kpi_tiles, + "interpretation_tiles": [], + "daily_macros": daily_macros, + "donut_avg_pct": donut, + "protein_reference_line_g": pt_low, + "kcal_vs_weight": { + "points": kw_points, + "tdee_reference_kcal": tdee, + "common_days_count": len(kw_points), + }, + "weekly_macro_chart": weekly_chart, + "tdee_reference_kcal": tdee, + "energy_balance_meta": { + "energy_balance": energy_meta.get("energy_balance"), + "avg_intake": energy_meta.get("avg_intake"), + "estimated_tdee": energy_meta.get("estimated_tdee"), + "status": energy_meta.get("status"), + "confidence": energy_meta.get("confidence"), + "data_points": energy_meta.get("data_points"), + }, + "meta": { + "layer_1": "nutrition_metrics", + "layer_2b": "nutrition_viz", + "issue": "53-phase-0c", + }, + } diff --git a/backend/routers/charts.py b/backend/routers/charts.py index d985a36..eee336a 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -32,12 +32,14 @@ from data_layer.body_metrics import ( get_circumference_summary_data ) from data_layer.body_viz import get_body_history_viz_bundle +from data_layer.nutrition_viz import get_nutrition_history_viz_bundle from data_layer.nutrition_metrics import ( get_nutrition_average_data, get_protein_targets_data, get_protein_adequacy_data, get_macro_consistency_data, get_energy_balance_data, + get_weekly_macro_distribution_chart_data, ) from data_layer.activity_metrics import ( get_activity_summary_data, @@ -265,6 +267,26 @@ def get_body_history_viz( return serialize_dates(bundle) +@router.get("/nutrition-history-viz") +def get_nutrition_history_viz( + days: int = Query( + default=90, + ge=7, + le=9999, + description="Analysefenster in Tagen (9999 = gesamte Historie)", + ), + session: dict = Depends(require_auth), +) -> Dict: + """ + Layer 2b: Ein Bundle für Verlauf «Ernährung» — Kennzahlen, Reihen, TDEE-Referenz, Wochen-Chart. + + Alle Kennzahlen aus nutrition_metrics (gleiche Logik wie Platzhalter / Chart-Endpunkte). + """ + profile_id = session["profile_id"] + bundle = get_nutrition_history_viz_bundle(profile_id, days) + return serialize_dates(bundle) + + @router.get("/circumferences") def get_circumferences_chart( max_age_days: int = Query(default=90, ge=7, le=365), @@ -830,136 +852,10 @@ def get_weekly_macro_distribution_chart( Weekly macro distribution (E3) - Konzept-konform. 100%-gestapelter Wochenbalken statt Pie Chart. - Shows macro consistency across weeks, not just overall average. - - Args: - weeks: Number of weeks to analyze (4-52, default 12) - session: Auth session (injected) - - Returns: - Chart.js stacked bar chart with weekly macro percentages + Datenberechnung: data_layer.nutrition_metrics.get_weekly_macro_distribution_chart_data """ profile_id = session['profile_id'] - - from db import get_db, get_cursor - import statistics - - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime('%Y-%m-%d') - - cur.execute( - """SELECT date, protein_g, carbs_g, fat_g, kcal - FROM nutrition_log - WHERE profile_id=%s AND date >= %s - AND protein_g IS NOT NULL AND carbs_g IS NOT NULL - AND fat_g IS NOT NULL AND kcal > 0 - ORDER BY date""", - (profile_id, cutoff) - ) - rows = cur.fetchall() - - if not rows or len(rows) < 7: - return { - "chart_type": "bar", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": len(rows) if rows else 0, - "message": "Nicht genug Daten für Wochen-Analyse (min. 7 Tage)" - } - } - - # Group by ISO week - weekly_data = {} - for row in rows: - date_obj = row['date'] if isinstance(row['date'], datetime) else datetime.fromisoformat(str(row['date'])) - iso_week = date_obj.strftime('%Y-W%V') - - if iso_week not in weekly_data: - weekly_data[iso_week] = { - 'protein': [], - 'carbs': [], - 'fat': [], - 'kcal': [] - } - - weekly_data[iso_week]['protein'].append(safe_float(row['protein_g'])) - weekly_data[iso_week]['carbs'].append(safe_float(row['carbs_g'])) - weekly_data[iso_week]['fat'].append(safe_float(row['fat_g'])) - weekly_data[iso_week]['kcal'].append(safe_float(row['kcal'])) - - # Calculate weekly averages and percentages - labels = [] - protein_pcts = [] - carbs_pcts = [] - fat_pcts = [] - - for iso_week in sorted(weekly_data.keys())[-weeks:]: - data = weekly_data[iso_week] - - avg_protein = sum(data['protein']) / len(data['protein']) if data['protein'] else 0 - avg_carbs = sum(data['carbs']) / len(data['carbs']) if data['carbs'] else 0 - avg_fat = sum(data['fat']) / len(data['fat']) if data['fat'] else 0 - - # Convert to kcal - protein_kcal = avg_protein * 4 - carbs_kcal = avg_carbs * 4 - fat_kcal = avg_fat * 9 - - total_kcal = protein_kcal + carbs_kcal + fat_kcal - - if total_kcal > 0: - labels.append(f"KW {iso_week[-2:]}") - protein_pcts.append(round((protein_kcal / total_kcal) * 100, 1)) - carbs_pcts.append(round((carbs_kcal / total_kcal) * 100, 1)) - fat_pcts.append(round((fat_kcal / total_kcal) * 100, 1)) - - # Calculate variation coefficient (Variationskoeffizient) - protein_cv = statistics.stdev(protein_pcts) / statistics.mean(protein_pcts) * 100 if len(protein_pcts) > 1 and statistics.mean(protein_pcts) > 0 else 0 - carbs_cv = statistics.stdev(carbs_pcts) / statistics.mean(carbs_pcts) * 100 if len(carbs_pcts) > 1 and statistics.mean(carbs_pcts) > 0 else 0 - fat_cv = statistics.stdev(fat_pcts) / statistics.mean(fat_pcts) * 100 if len(fat_pcts) > 1 and statistics.mean(fat_pcts) > 0 else 0 - - return { - "chart_type": "bar", - "data": { - "labels": labels, - "datasets": [ - { - "label": "Protein (%)", - "data": protein_pcts, - "backgroundColor": "#1D9E75", - "stack": "macro" - }, - { - "label": "Kohlenhydrate (%)", - "data": carbs_pcts, - "backgroundColor": "#F59E0B", - "stack": "macro" - }, - { - "label": "Fett (%)", - "data": fat_pcts, - "backgroundColor": "#EF4444", - "stack": "macro" - } - ] - }, - "metadata": { - "confidence": calculate_confidence(len(rows), weeks * 7, "general"), - "data_points": len(rows), - "weeks_analyzed": len(labels), - "avg_protein_pct": round(statistics.mean(protein_pcts), 1) if protein_pcts else 0, - "avg_carbs_pct": round(statistics.mean(carbs_pcts), 1) if carbs_pcts else 0, - "avg_fat_pct": round(statistics.mean(fat_pcts), 1) if fat_pcts else 0, - "protein_cv": round(protein_cv, 1), - "carbs_cv": round(carbs_cv, 1), - "fat_cv": round(fat_cv, 1) - } - } + return get_weekly_macro_distribution_chart_data(profile_id, weeks) @router.get("/nutrition-adherence-score") diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 9e18919..b13ca4c 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -699,62 +699,50 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl ) } -function buildNutritionKpiTiles({ - avgKcal, avgCarbs, avgFat, n, macroRules, dateSpanLabel, -}) { - const tiles = [ - { - key: 'kcal', - category: 'Kalorien (Ø)', - icon: '🔥', - value: `${avgKcal} kcal`, - sublabel: dateSpanLabel, - status: 'good', - verdict: '', - hoverTop: 'Durchschnittliche tägliche Energie', - hoverBody: `Mittel über ${n} Tage mit Ernährungseinträgen im gewählten Zeitraum.`, - }, - { - key: 'carbs', - category: 'KH (Ø)', - icon: '🌾', - value: `${avgCarbs} g`, - sublabel: 'Kohlenhydrate / Tag', - status: 'good', - verdict: '', - hoverTop: 'Durchschnittliche Kohlenhydrate', - hoverBody: 'Summe der täglichen Werte im Zeitraum, gemittelt.', - }, - { - key: 'fat', - category: 'Fett (Ø)', - icon: '🧈', - value: `${avgFat} g`, - sublabel: 'Fett / Tag', - status: 'good', - verdict: '', - hoverTop: 'Durchschnittliches Fett', - hoverBody: 'Summe der täglichen Werte im Zeitraum, gemittelt.', - }, - ] - macroRules.forEach((r, i) => { - tiles.push({ - key: `eval-${i}`, - category: r.category, - icon: r.icon, - value: r.value, - sublabel: r.title.length > 36 ? `${r.title.slice(0, 34)}…` : r.title, - status: r.status, - verdict: verdictShort(r.status === 'warn' ? 'warn' : r.status === 'bad' ? 'bad' : 'good'), - hoverTop: r.title, - hoverBody: r.detail, - }) - }) - return tiles -} +/** Kalorien (Ø 7T) vs. Gewicht — Daten aus Layer-2b-Bundle (nutrition_metrics / TDEE wie Data Layer). */ +function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffDate, allTime }) { + if (vizKcalWeight?.points?.length >= 5) { + const tdee = vizKcalWeight.tdee_reference_kcal + const kcalVsW = vizKcalWeight.points.map(d => ({ + ...d, + date: fmtDate(d.date), + })) + const n = vizKcalWeight.common_days_count ?? kcalVsW.length + const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null + return ( +
+
+ Kalorien (Ø 7 Tage) vs. Gewicht +
+
+ Gleitender 7-Tage-Mittelwert der Kalorien vs. tägliches Gewicht (gemeinsame Tage). Orange: kcal · Blau: Gewicht. +
+ + + + + + + [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} + /> + {tdeeLabel != null && ( + + )} + + + + +
+ {tdeeLabel != null + ? `Referenz TDEE ~${tdeeLabel} kcal (Data Layer, gestrichelt) · ${n} gemeinsame Tage` + : `Keine TDEE-Referenz (Gewicht/Demografie) · ${n} gemeinsame Tage`} +
+
+ ) + } -/** Kalorien (Ø 7T) vs. Gewicht — gleiche Logik wie früher unter Korrelationen. */ -function KcalVsWeightChart({ corrData: corrRows, profile, cutoffDate, allTime }) { const raw = (corrRows || []).filter(d => { if (!d.kcal || d.weight == null) return false const ds = typeof d.date === 'string' ? d.date.slice(0, 10) : dayjs(d.date).format('YYYY-MM-DD') @@ -794,21 +782,20 @@ function KcalVsWeightChart({ corrData: corrRows, profile, cutoffDate, allTime })
- Referenz TDEE ~{tdee} kcal (Mifflin ×1,4, gestrichelt) · {raw.length} gemeinsame Tage + Referenz TDEE ~{tdee} kcal (Fallback Mifflin ×1,4) · {raw.length} gemeinsame Tage
) } // ── Nutrition Section ───────────────────────────────────────────────────────── -function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs, corrData }) { +/** Layer 2b: Kennzahlen und Reihen nur aus GET /charts/nutrition-history-viz (nutrition_metrics). */ +function NutritionSection({ profile, insights, onRequest, loadingSlug, filterActiveSlugs }) { const [period, setPeriod] = useState(30) const [groupedGoals, setGroupedGoals] = useState(null) - const chartDays = period === 9999 ? 90 : period - const weeks = Math.max(4, Math.min(52, Math.ceil(chartDays / 7))) - const [weeklyMacro, setWeeklyMacro] = useState(null) - const [wmLoading, setWmLoading] = useState(false) - const [wmError, setWmError] = useState(null) + const [viz, setViz] = useState(null) + const [vizLoad, setVizLoad] = useState(true) + const [vizErr, setVizErr] = useState(null) useEffect(() => { let cancelled = false @@ -820,120 +807,110 @@ function NutritionSection({ nutrition, weights, profile, insights, onRequest, lo useEffect(() => { let cancelled = false - setWmLoading(true) - setWmError(null) - api.getWeeklyMacroDistributionChart(weeks) - .then(d => { if (!cancelled) setWeeklyMacro(d) }) - .catch(e => { if (!cancelled) setWmError(e.message || 'Laden fehlgeschlagen') }) - .finally(() => { if (!cancelled) setWmLoading(false) }) + setViz(null) + setVizLoad(true) + setVizErr(null) + const daysReq = period === 9999 ? 9999 : period + api.getNutritionHistoryViz(daysReq) + .then(v => { if (!cancelled) setViz(v) }) + .catch(e => { if (!cancelled) setVizErr(e.message || 'Laden fehlgeschlagen') }) + .finally(() => { if (!cancelled) setVizLoad(false) }) return () => { cancelled = true } - }, [weeks]) + }, [period]) - if (!nutrition?.length) { + if (vizLoad) { + return ( +
+ + +
+
+ ) + } + + if (vizErr) { + return ( +
+ +
{vizErr}
+
+ ) + } + + if (!viz?.has_nutrition_entries) { return ( ) } - const cutoff = dayjs().subtract(period, 'day').format('YYYY-MM-DD') - const filtN = nutrition.filter(d => period === 9999 || d.date >= cutoff) - const sorted = [...filtN].sort((a, b) => a.date.localeCompare(b.date)) + const summary = viz.summary || {} + const n = Math.max(0, Number(summary.data_points) || 0) + const avgKcal = Math.round(Number(summary.kcal_avg) || 0) + const ptLow = Math.round(Number(viz.protein_reference_line_g) || 0) + const chartDays = viz.nutrition_charts_days || (period === 9999 ? 90 : period) + const kpiTiles = (viz.kpi_tiles || []).map(t => ({ + ...t, + sublabel: typeof t.sublabel === 'string' && t.sublabel.length > 36 ? `${t.sublabel.slice(0, 34)}…` : t.sublabel, + })) + const pieData = viz.donut_avg_pct || [] + const cdMacro = (viz.daily_macros || []).map(d => ({ + date: fmtDate(d.date), + Protein: d.Protein, + KH: d.KH, + Fett: d.Fett, + kcal: d.kcal, + })) + const weeklyMacro = viz.weekly_macro_chart + const wmLoading = false + const wmError = null - if (!filtN.length) { + if (!cdMacro.length || n === 0) { return (
- +
) } - const n = filtN.length - const avgKcal = Math.round(filtN.reduce((s, d) => s + (d.kcal || 0), 0) / n) - const avgProtein = Math.round(filtN.reduce((s, d) => s + (d.protein_g || 0), 0) / n * 10) / 10 - const avgFat = Math.round(filtN.reduce((s, d) => s + (d.fat_g || 0), 0) / n * 10) / 10 - const avgCarbs = Math.round(filtN.reduce((s, d) => s + (d.carbs_g || 0), 0) / n * 10) / 10 - const latestW = weights?.[0]?.weight || 80 - const ptLow = Math.round(latestW * 1.6) - const ptHigh = Math.round(latestW * 2.2) - const proteinOk = avgProtein >= ptLow - - const cdMacro = sorted.map(d => ({ - date: fmtDate(d.date), - Protein: Math.round(d.protein_g || 0), - KH: Math.round(d.carbs_g || 0), - Fett: Math.round(d.fat_g || 0), - kcal: Math.round(d.kcal || 0), - })) - - const totalMacroKcal = avgProtein * 4 + avgCarbs * 4 + avgFat * 9 - const pieData = [ - { name: 'Protein', value: Math.round(avgProtein * 4 / totalMacroKcal * 100), color: '#059669' }, - { name: 'KH', value: Math.round(avgCarbs * 4 / totalMacroKcal * 100), color: '#EA580C' }, - { name: 'Fett', value: Math.round(avgFat * 9 / totalMacroKcal * 100), color: '#2563EB' }, - ] - - const macroRules = [] - if (!proteinOk) { - macroRules.push({ - status: 'bad', icon: '🥩', category: 'Protein', - title: `Unterversorgung: ${avgProtein}g/Tag (Ziel ${ptLow}–${ptHigh}g)`, - detail: `1,6–2,2g/kg KG. Fehlend: ~${Math.max(0, ptLow - Math.round(avgProtein))}g täglich. Konsequenz: Muskelverlust bei Defizit.`, - value: `${avgProtein}g`, - }) - } else { - macroRules.push({ - status: 'good', icon: '🥩', category: 'Protein', - title: `Gut: ${avgProtein}g/Tag (Ziel ${ptLow}–${ptHigh}g)`, - detail: 'Ausreichend für Muskelerhalt und -aufbau.', - value: `${avgProtein}g`, - }) - } - const protPct = Math.round(avgProtein * 4 / totalMacroKcal * 100) - if (protPct < 20) { - macroRules.push({ - status: 'warn', icon: '📊', category: 'Makro-Anteil', - title: `Protein-Anteil niedrig: ${protPct}% der Kalorien`, - detail: `Empfehlung oft 25–35%. Aktuell: ${protPct}% P / ${Math.round(avgCarbs * 4 / totalMacroKcal * 100)}% KH / ${Math.round(avgFat * 9 / totalMacroKcal * 100)}% F`, - value: `${protPct}%`, - }) - } - - const dateSpanLabel = `${sorted[0]?.date?.slice(0, 10) ?? ''} – ${sorted[sorted.length - 1]?.date?.slice(0, 10) ?? ''}` - const kpiTiles = buildNutritionKpiTiles({ - avgKcal, avgCarbs, avgFat, n, macroRules, dateSpanLabel, - }) - return (
- +

- Kennzahlen und Charts nutzen dieselben Datenquellen wie die KI-Platzhalter (Ernährungs-Log, Gewicht).{' '} - Kalorien vs. Gewicht bezieht gemeinsame Tage aus Ernährung und Gewicht. + Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '} + Kalorien vs. Gewicht und TDEE-Referenz entsprechen Mifflin–St Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg).

- +
Makroverteilung täglich (g) · Fokus Protein
- Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow} g) nach 1,6 g/kg (Referenzgewicht). + Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow || '—'} g) nach 1,6 g/kg (Referenzgewicht).
- + {ptLow > 0 && ( + + )} [`${v}g`, name]} /> @@ -953,27 +930,33 @@ function NutritionSection({ nutrition, weights, profile, insights, onRequest, lo Ø Makro-Quote ({n} Tage)
- - - {pieData.map((e, i) => )} - - [`${v}%`, name]} /> - -
- {pieData.map(p => ( -
-
-
{p.name}
-
{p.value}%
-
- {Math.round(p.name === 'Protein' ? avgProtein : p.name === 'KH' ? avgCarbs : avgFat)}g + {pieData.length > 0 ? ( + <> + + + {pieData.map((e, i) => )} + + [`${v}%`, name]} /> + +
+ {pieData.map(p => ( +
+
+
{p.name}
+
{p.value}%
+
+ {p.grams != null ? `${p.grams}g` : '—'} +
+
+ ))} +
+ Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz
- ))} -
- Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz -
-
+ + ) : ( +
Keine Makro-Mittelwerte im Zeitraum.
+ )}
@@ -1501,7 +1484,7 @@ export default function History() {
{tab==='body' && } - {tab==='nutrition' && } + {tab==='nutrition' && } {tab==='activity' && } {tab==='recovery' && } {tab==='correlation' && } diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index e6f9b38..c55e583 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -637,6 +637,8 @@ export const api = { // Nutrition Charts (E1-E5) /** Layer 2b: Verlauf Körper — Charts, Kennzahlen, Bewertung (einheitlich mit Platzhalter-Registry) */ getBodyHistoryViz: (days=90) => req(`/charts/body-history-viz?days=${days}`), + /** Layer 2b: Verlauf Ernährung — Kennzahlen, Reihen, TDEE, Wochen-Chart (nutrition_metrics) */ + getNutritionHistoryViz: (days=90) => req(`/charts/nutrition-history-viz?days=${days}`), getEnergyBalanceChart: (days=28) => req(`/charts/energy-balance?days=${days}`), getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`), getNutritionConsistencyChart: (days=28) => req(`/charts/nutrition-consistency?days=${days}`), From 31fbf330319a1d23764a8d8a3e610185baf834f3 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 19 Apr 2026 17:28:41 +0200 Subject: [PATCH 5/7] refactor: update nutrition chart colors and enhance layout - Changed color codes for macro nutrients in the nutrition interpretation and metrics files to improve visual consistency. - Added new CSS styles for uniform chart height and layout adjustments in the frontend components, enhancing the overall user experience. - Refactored the NutritionCharts component to utilize the new macro chart theme for better maintainability and readability. --- .../data_layer/nutrition_interpretation.py | 6 +- backend/data_layer/nutrition_metrics.py | 6 +- frontend/src/app.css | 29 +++++++ frontend/src/components/NutritionCharts.jsx | 43 +++++----- frontend/src/pages/History.jsx | 78 ++++++++++++------- frontend/src/pages/NutritionPage.jsx | 7 +- frontend/src/utils/macroChartTheme.js | 21 +++++ 7 files changed, 131 insertions(+), 59 deletions(-) create mode 100644 frontend/src/utils/macroChartTheme.js diff --git a/backend/data_layer/nutrition_interpretation.py b/backend/data_layer/nutrition_interpretation.py index 304f189..74e0283 100644 --- a/backend/data_layer/nutrition_interpretation.py +++ b/backend/data_layer/nutrition_interpretation.py @@ -174,7 +174,7 @@ def build_macro_donut_from_averages(navg: Dict[str, Any]) -> Optional[List[Dict[ if tot <= 0: return None return [ - {"name": "Protein", "value": round(pkcal / tot * 100), "color": "#059669", "grams": round(p, 1)}, - {"name": "KH", "value": round(ckcal / tot * 100), "color": "#EA580C", "grams": round(c, 1)}, - {"name": "Fett", "value": round(fkcal / tot * 100), "color": "#2563EB", "grams": round(f, 1)}, + {"name": "Protein", "value": round(pkcal / tot * 100), "color": "#4a8f72", "grams": round(p, 1)}, + {"name": "KH", "value": round(ckcal / tot * 100), "color": "#c17d45", "grams": round(c, 1)}, + {"name": "Fett", "value": round(fkcal / tot * 100), "color": "#6e8eb8", "grams": round(f, 1)}, ] diff --git a/backend/data_layer/nutrition_metrics.py b/backend/data_layer/nutrition_metrics.py index 9389d48..33c844a 100644 --- a/backend/data_layer/nutrition_metrics.py +++ b/backend/data_layer/nutrition_metrics.py @@ -668,19 +668,19 @@ def get_weekly_macro_distribution_chart_data(profile_id: str, weeks: int) -> Dic { "label": "Protein (%)", "data": protein_pcts, - "backgroundColor": "#1D9E75", + "backgroundColor": "#4a8f72", "stack": "macro", }, { "label": "Kohlenhydrate (%)", "data": carbs_pcts, - "backgroundColor": "#F59E0B", + "backgroundColor": "#c17d45", "stack": "macro", }, { "label": "Fett (%)", "data": fat_pcts, - "backgroundColor": "#EF4444", + "backgroundColor": "#6e8eb8", "stack": "macro", }, ], diff --git a/frontend/src/app.css b/frontend/src/app.css index c6d23c7..9cccb52 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -368,6 +368,35 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we min-width: 0; } +/* Einheitliche Chart-Höhe (Donut-Bereich ≈ E3-Balken) */ +.nutrition-macro-pair__chart-wrap { + width: 100%; + min-height: 260px; +} + +.nutrition-macro-pair__donut-inner { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; +} + +.nutrition-macro-pair__donut-chart { + width: 100%; + min-height: 260px; +} + +.nutrition-macro-pair__legend { + width: 100%; + padding-top: 2px; +} + +.nutrition-macro-pair .card.nutrition-macro-pair__donut, +.nutrition-macro-pair .card.nutrition-macro-pair__weekly { + display: flex; + flex-direction: column; +} + .history-page__title { margin-bottom: 12px; } diff --git a/frontend/src/components/NutritionCharts.jsx b/frontend/src/components/NutritionCharts.jsx index 9d8d312..acec389 100644 --- a/frontend/src/components/NutritionCharts.jsx +++ b/frontend/src/components/NutritionCharts.jsx @@ -5,6 +5,7 @@ import { ComposedChart, ReferenceArea, } from 'recharts' import { api } from '../utils/api' +import { MACRO_CHART, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme' import dayjs from 'dayjs' const fmtDate = d => dayjs(d).format('DD.MM') @@ -172,26 +173,28 @@ export function WeeklyMacroDistributionPanel({ macroWeeklyData, loading, error } Anteil der Kalorien aus jedem Makronährstoff pro Kalenderwoche (100 % gestapelt). Gut vergleichbar mit der Donut-Übersicht links.
- - - - - - [`${v}%`, name]} - /> - - - - - - +
+ + + + + + [`${v}%`, name]} + /> + + + + + + +
Ø Verteilung: P {meta.avg_protein_pct}% · KH {meta.avg_carbs_pct}% · F {meta.avg_fat_pct}% · Variabilität (CV): P{' '} {meta.protein_cv}% · KH {meta.carbs_cv}% · F {meta.fat_cv}% diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index b13ca4c..2587293 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -11,6 +11,7 @@ import { api } from '../utils/api' import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay' import { getBfCategory } from '../utils/calc' import { getStatusColor, getStatusBg } from '../utils/interpret' +import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme' import Markdown from '../utils/Markdown' import TrainingTypeDistribution from '../components/TrainingTypeDistribution' import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts' @@ -909,18 +910,18 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct {ptLow > 0 && ( - + )} [`${v}g`, name]} /> - - - + + +
- Protein (oben) - KH - Fett + Protein (unten) + Fett (Mitte) + KH (oben)
@@ -929,35 +930,52 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
Ø Makro-Quote ({n} Tage)
-
- {pieData.length > 0 ? ( - <> - - - {pieData.map((e, i) => )} - - [`${v}%`, name]} /> - -
- {pieData.map(p => ( -
-
+ {pieData.length > 0 ? ( +
+
+ + + + {pieData.map((e, i) => ( + + ))} + + [`${v}%`, name]} /> + + +
+
+ {pieData.map(p => { + const fill = macroFillByName(p.name) + return ( +
+
{p.name}
-
{p.value}%
+
{p.value}%
{p.grams != null ? `${p.grams}g` : '—'}
- ))} -
- Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz -
+ ) + })} +
+ Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz
- - ) : ( -
Keine Makro-Mittelwerte im Zeitraum.
- )} -
+
+
+ ) : ( +
Keine Makro-Mittelwerte im Zeitraum.
+ )}
diff --git a/frontend/src/pages/NutritionPage.jsx b/frontend/src/pages/NutritionPage.jsx index fc6c1ac..09ed0ba 100644 --- a/frontend/src/pages/NutritionPage.jsx +++ b/frontend/src/pages/NutritionPage.jsx @@ -5,6 +5,7 @@ import { ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter } from 'recharts' import { api as nutritionApi } from '../utils/api' +import { MACRO_CHART } from '../utils/macroChartTheme' import dayjs from 'dayjs' import isoWeek from 'dayjs/plugin/isoWeek' dayjs.extend(isoWeek) @@ -709,9 +710,9 @@ function WeeklyMacros({ weekly }) { [`${Math.round(v)} g`, n]}/> - - - + + + ) diff --git a/frontend/src/utils/macroChartTheme.js b/frontend/src/utils/macroChartTheme.js new file mode 100644 index 0000000..03ab9ad --- /dev/null +++ b/frontend/src/utils/macroChartTheme.js @@ -0,0 +1,21 @@ +/** + * Einheitliche Makro-Farben für Verlauf (Balken, Donut, E3). + * Reihenfolge gestapelter Balken (Recharts, unten zuerst): Protein → Fett → Kohlenhydrate. + */ +export const MACRO_CHART = { + protein: '#4a8f72', + fat: '#6e8eb8', + carbs: '#c17d45', +} + +/** Einheitliche Höhe Donut-Bereich / E3-Balken (Verlauf) */ +export const NUTRITION_MACRO_CHART_BLOCK_PX = 260 + +/** Farbe nach Segment-Name (Protein / KH / Fett / englische Keys). */ +export function macroFillByName(name) { + const n = String(name || '').toLowerCase() + if (n.includes('protein') || n === 'p') return MACRO_CHART.protein + if (n.includes('fett') || n.includes('fat')) return MACRO_CHART.fat + if (n.includes('kh') || n.includes('kohlenhydrat') || n.includes('carb')) return MACRO_CHART.carbs + return MACRO_CHART.carbs +} From fc816da335d9dd854cdaee6a746ae2c23a5338e6 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 19 Apr 2026 17:36:45 +0200 Subject: [PATCH 6/7] feat: enhance KPI tiles with contextual hints and improve chart legends - Added contextual hints to KPI tiles in the nutrition interpretation to provide users with actionable insights regarding protein intake and weight assessment. - Updated the KpiTilesOverview component to display these hints, improving user understanding of nutrition metrics. - Introduced a new KcalVsWeightLegend component to clarify chart data representation, enhancing the overall user experience in the history visualization. --- .../data_layer/nutrition_interpretation.py | 9 +++ frontend/src/components/KpiTilesOverview.jsx | 19 +++++ frontend/src/pages/History.jsx | 78 +++++++++++++++++-- 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/backend/data_layer/nutrition_interpretation.py b/backend/data_layer/nutrition_interpretation.py index 74e0283..a014576 100644 --- a/backend/data_layer/nutrition_interpretation.py +++ b/backend/data_layer/nutrition_interpretation.py @@ -103,6 +103,7 @@ def build_nutrition_history_kpi_tiles( "sublabel": "Referenzgewicht fehlt", "status": "warn", "verdict": _verdict("warn"), + "hint": "Ohne aktuelles Körpergewicht lässt sich das Protein-Ziel (g/kg) nicht bewerten.", "hoverTop": "Protein-Ziel nicht berechenbar", "hoverBody": "Für 1,6–2,2 g/kg wird ein aktuelles Körpergewicht benötigt.", "keys": ["protein_adequacy"], @@ -119,6 +120,10 @@ def build_nutrition_history_kpi_tiles( "sublabel": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)", "status": "bad", "verdict": _verdict("bad"), + "hint": ( + f"Es fehlen rund {miss} g Protein pro Tag – bei Kaloriendefizit " + "steigt das Risiko für Muskelerhalt." + ), "hoverTop": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)", "hoverBody": ( f"1,6–2,2g/kg KG. Fehlend: ~{miss}g täglich. " @@ -153,6 +158,10 @@ def build_nutrition_history_kpi_tiles( "sublabel": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien", "status": "warn", "verdict": _verdict("warn"), + "hint": ( + f"Viele Kalorien kommen aus KH/Fett; Proteinanteil oft sinnvoll bei 25–35 % " + f"(aktuell P {prot_pct} % / KH {kh_pct} % / F {fat_pct} %)." + ), "hoverTop": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien", "hoverBody": ( f"Empfehlung oft 25–35%. Aktuell: {prot_pct}% P / {kh_pct}% KH / {fat_pct}% F" diff --git a/frontend/src/components/KpiTilesOverview.jsx b/frontend/src/components/KpiTilesOverview.jsx index 96150f9..e6fead0 100644 --- a/frontend/src/components/KpiTilesOverview.jsx +++ b/frontend/src/components/KpiTilesOverview.jsx @@ -28,6 +28,7 @@ export function buildKpiTileTitleString(t) { * - `value` (ReactNode) — Hauptwert * - `status` — für Farbstreifen: `good` | `warn` | `bad` * - optional: `icon`, `sublabel`, `verdict`, `valueColor`, `hoverTop`, `hoverBody`, `keys` + * - optional: `hint` — Kurz-Hinweis/Warnung direkt auf der Kachel (z. B. Ernährung bei warn/bad) */ export default function KpiTilesOverview({ tiles, @@ -82,6 +83,7 @@ export default function KpiTilesOverview({ {tiles.map(t => { const accent = getStatusColor(t.status) const tip = buildKpiTileTitleString(t) + const cardHint = t.hint ? String(t.hint) : null return (
) : null}
+ {cardHint ? ( +
+ {cardHint} +
+ ) : null}
) })} diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 2587293..99be26c 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -700,6 +700,70 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl ) } +/** Legende unter dem Chart: Linien + ggf. TDEE-Referenz (gestrichelt). */ +function KcalVsWeightLegend({ showTdee }) { + const line = (color) => ({ + display: 'inline-block', + width: 22, + height: 3, + background: color, + borderRadius: 1, + verticalAlign: 'middle', + marginRight: 6, + }) + return ( +
+ + + Ø Kalorien (7-Tage-Mittel) + + + + Gewicht (kg) + + {showTdee ? ( + + + TDEE-Referenz (geschätzt) + + ) : null} +
+ ) +} + /** Kalorien (Ø 7T) vs. Gewicht — Daten aus Layer-2b-Bundle (nutrition_metrics / TDEE wie Data Layer). */ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffDate, allTime }) { if (vizKcalWeight?.points?.length >= 5) { @@ -716,7 +780,7 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD Kalorien (Ø 7 Tage) vs. Gewicht
- Gleitender 7-Tage-Mittelwert der Kalorien vs. tägliches Gewicht (gemeinsame Tage). Orange: kcal · Blau: Gewicht. + Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg.
@@ -735,9 +799,10 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD -
+ +
{tdeeLabel != null - ? `Referenz TDEE ~${tdeeLabel} kcal (Data Layer, gestrichelt) · ${n} gemeinsame Tage` + ? `TDEE ~${tdeeLabel} kcal · ${n} gemeinsame Tage` : `Keine TDEE-Referenz (Gewicht/Demografie) · ${n} gemeinsame Tage`}
@@ -765,7 +830,7 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD Kalorien (Ø 7 Tage) vs. Gewicht
- Gleitender 7-Tage-Mittelwert der Kalorien vs. tägliches Gewicht (gemeinsame Tage). Orange: kcal · Blau: Gewicht. + Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg.
@@ -782,8 +847,9 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD -
- Referenz TDEE ~{tdee} kcal (Fallback Mifflin ×1,4) · {raw.length} gemeinsame Tage + +
+ TDEE ~{tdee} kcal (Fallback Mifflin ×1,4) · {raw.length} gemeinsame Tage
) From d7304c1a441a7b0c20d438d3e47fd374c7c96c9f Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 19 Apr 2026 17:43:29 +0200 Subject: [PATCH 7/7] feat: implement energy availability warning and enhance nutrition visualization - Added `get_energy_availability_warning_payload` function to assess energy availability and provide contextual warnings based on multiple health indicators. - Integrated energy availability KPI tile into the nutrition history visualization, enhancing user insights on energy balance. - Updated frontend components to conditionally display the energy availability warning, improving user experience and data interpretation. - Refactored existing logic in `charts.py` to utilize the new energy availability functionality, streamlining data handling. --- .../data_layer/nutrition_interpretation.py | 38 ++++++++- backend/data_layer/nutrition_metrics.py | 64 +++++++++++++++ backend/data_layer/nutrition_viz.py | 15 +++- backend/routers/charts.py | 82 +------------------ frontend/src/app.css | 5 ++ frontend/src/components/KpiTilesOverview.jsx | 17 ++-- frontend/src/components/NutritionCharts.jsx | 15 +++- frontend/src/pages/History.jsx | 48 +++++++++-- 8 files changed, 182 insertions(+), 102 deletions(-) diff --git a/backend/data_layer/nutrition_interpretation.py b/backend/data_layer/nutrition_interpretation.py index a014576..4178f8e 100644 --- a/backend/data_layer/nutrition_interpretation.py +++ b/backend/data_layer/nutrition_interpretation.py @@ -121,8 +121,7 @@ def build_nutrition_history_kpi_tiles( "status": "bad", "verdict": _verdict("bad"), "hint": ( - f"Es fehlen rund {miss} g Protein pro Tag – bei Kaloriendefizit " - "steigt das Risiko für Muskelerhalt." + f"~{miss} g Protein/Tag fehlen – bei Defizit Muskelerhalt gefährdet." ), "hoverTop": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)", "hoverBody": ( @@ -159,8 +158,8 @@ def build_nutrition_history_kpi_tiles( "status": "warn", "verdict": _verdict("warn"), "hint": ( - f"Viele Kalorien kommen aus KH/Fett; Proteinanteil oft sinnvoll bei 25–35 % " - f"(aktuell P {prot_pct} % / KH {kh_pct} % / F {fat_pct} %)." + f"Protein-Kalorienanteil niedrig (P {prot_pct} % / KH {kh_pct} % / F {fat_pct} %); " + "Ziel oft 25–35 %." ), "hoverTop": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien", "hoverBody": ( @@ -173,6 +172,37 @@ def build_nutrition_history_kpi_tiles( return tiles +def build_energy_availability_kpi_tile(ea: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """E5: nur bei caution/warning — gleiche Daten wie /charts/energy-availability-warning.""" + level = str(ea.get("warning_level") or "none").strip().lower() + if level == "none": + return None + triggers: List[str] = list(ea.get("triggers") or []) + msg = str(ea.get("message") or "").strip() + st = "bad" if level == "warning" else "warn" + first = triggers[0] if triggers else msg + if len(first) > 90: + first = first[:87] + "…" + meta = ea.get("metadata") if isinstance(ea.get("metadata"), dict) else {} + note = str(meta.get("note") or "") + hover_lines = [msg] + [f"• {t}" for t in triggers] + if note: + hover_lines.append(note) + return { + "key": "energy-availability-e5", + "category": "Energieverfügbarkeit", + "icon": "⚡", + "value": "Achtung" if level == "warning" else "Hinweis", + "sublabel": first or "Signale prüfen", + "status": st, + "verdict": _verdict(st), + "hint": msg, + "hoverTop": "Energieverfügbarkeit (Heuristik)", + "hoverBody": "\n".join(hover_lines), + "keys": ["nutrition_score"], + } + + def build_macro_donut_from_averages(navg: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]: """Anteile in % der Makro-kcal + Gramm für Legende.""" p = float(navg.get("protein_avg") or 0) diff --git a/backend/data_layer/nutrition_metrics.py b/backend/data_layer/nutrition_metrics.py index 33c844a..b3865d8 100644 --- a/backend/data_layer/nutrition_metrics.py +++ b/backend/data_layer/nutrition_metrics.py @@ -699,6 +699,70 @@ def get_weekly_macro_distribution_chart_data(profile_id: str, weeks: int) -> Dic } +def get_energy_availability_warning_payload(profile_id: str, days: int = 14) -> Dict: + """ + E5 Energieverfügbarkeit — gleiche Heuristik wie GET /charts/energy-availability-warning. + """ + from data_layer.recovery_metrics import calculate_recovery_score_v2, calculate_sleep_quality_7d + from data_layer.body_metrics import calculate_lbm_28d_change + + triggers: List[str] = [] + warning_level = "none" + + energy_data = get_energy_balance_data(profile_id, days) + if energy_data.get("energy_balance", 0) < -500: + triggers.append("Großes Energiedefizit (>500 kcal/Tag)") + + try: + recovery_score = calculate_recovery_score_v2(profile_id) + if recovery_score and recovery_score < 50: + triggers.append("Recovery Score niedrig (<50)") + except Exception: + pass + + try: + sleep_quality = calculate_sleep_quality_7d(profile_id) + if sleep_quality and sleep_quality < 60: + triggers.append("Schlafqualität reduziert (<60%)") + except Exception: + pass + + try: + lbm_change = calculate_lbm_28d_change(profile_id) + if lbm_change and lbm_change < -1.0: + triggers.append("Magermasse sinkt (-{:.1f} kg)".format(abs(lbm_change))) + except Exception: + pass + + if len(triggers) >= 3: + warning_level = "warning" + message = ( + "⚠️ Hinweis auf mögliche Unterversorgung. Mehrere Indikatoren auffällig. " + "Erwäge Defizit-Anpassung oder Regenerationswoche." + ) + elif len(triggers) >= 2: + warning_level = "caution" + message = ( + "⚡ Beobachte folgende Signale genau. Aktuell noch kein Handlungsbedarf, aber Trend beachten." + ) + elif len(triggers) >= 1: + warning_level = "caution" + message = "💡 Ein Indikator auffällig. Weiter beobachten." + else: + message = "✅ Energieverfügbarkeit unauffällig." + + return { + "warning_level": warning_level, + "triggers": triggers, + "message": message, + "metadata": { + "days_analyzed": days, + "trigger_count": len(triggers), + "note": "Heuristische Einschätzung, keine medizinische Diagnose", + }, + } + + # ============================================================================ # Calculated Metrics (migrated from calculations/nutrition_metrics.py) # ============================================================================ diff --git a/backend/data_layer/nutrition_viz.py b/backend/data_layer/nutrition_viz.py index f05b0a5..8891cf6 100644 --- a/backend/data_layer/nutrition_viz.py +++ b/backend/data_layer/nutrition_viz.py @@ -11,11 +11,13 @@ from typing import Any, Dict, List, Optional from db import get_db, get_cursor, r2d from data_layer.nutrition_interpretation import ( + build_energy_availability_kpi_tile, build_macro_donut_from_averages, build_nutrition_history_kpi_tiles, ) from data_layer.nutrition_metrics import ( estimate_tdee_kcal_from_latest_weight, + get_energy_availability_warning_payload, get_energy_balance_data, get_nutrition_average_data, get_protein_targets_data, @@ -184,12 +186,14 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An "tdee_reference_kcal": None, "energy_balance_meta": {}, "interpretation_tiles": [], + "energy_availability_warning": None, "meta": {"layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz"}, } all_history = days >= 9999 eff_days = 3650 if all_history else max(7, min(int(days), 3650)) cutoff = _cutoff_sql(days) + chart_days_for_pipeline = 90 if all_history else max(7, min(eff_days, 365)) navg = get_nutrition_average_data(profile_id, eff_days, all_history=all_history) targets = get_protein_targets_data(profile_id) @@ -223,12 +227,18 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An navg, targets, date_span_label or "—", max(1, n_days) ) + ea_days = min(28, max(7, chart_days_for_pipeline)) + ea_payload = get_energy_availability_warning_payload(profile_id, ea_days) + ea_tile = build_energy_availability_kpi_tile(ea_payload) + kpi_tiles_out: List[Dict[str, Any]] = list(kpi_tiles) + if ea_tile: + kpi_tiles_out.append(ea_tile) + donut = build_macro_donut_from_averages(navg) kw_points = _kcal_weight_points_for_window(profile_id, cutoff) pt_low = round(float(targets.get("protein_target_low") or 0)) - chart_days_for_pipeline = 90 if all_history else max(7, min(eff_days, 365)) weeks_for_weekly = max(4, min(52, (chart_days_for_pipeline + 6) // 7)) weekly_chart = get_weekly_macro_distribution_chart_data(profile_id, weeks_for_weekly) @@ -255,8 +265,9 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An "protein_target_high": targets.get("protein_target_high"), "reference_weight_kg": targets.get("current_weight"), }, - "kpi_tiles": kpi_tiles, + "kpi_tiles": kpi_tiles_out, "interpretation_tiles": [], + "energy_availability_warning": ea_payload, "daily_macros": daily_macros, "donut_avg_pct": donut, "protein_reference_line_g": pt_low, diff --git a/backend/routers/charts.py b/backend/routers/charts.py index eee336a..fca7c36 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -40,6 +40,7 @@ from data_layer.nutrition_metrics import ( get_macro_consistency_data, get_energy_balance_data, get_weekly_macro_distribution_chart_data, + get_energy_availability_warning_payload, ) from data_layer.activity_metrics import ( get_activity_summary_data, @@ -1026,87 +1027,10 @@ def get_energy_availability_warning( """ Energy Availability Warning (E5) - Konzept-konform. - Heuristic warning for potential undernutrition/overtraining. - - Checks: - - Persistent large deficit - - Recovery score declining - - Sleep quality declining - - LBM declining - - Args: - days: Analysis window (7-28 days, default 14) - session: Auth session (injected) - - Returns: - { - "warning_level": "none" | "caution" | "warning", - "triggers": [...], - "message": "..." - } + Datenberechnung: data_layer.nutrition_metrics.get_energy_availability_warning_payload """ profile_id = session['profile_id'] - - from db import get_db, get_cursor - from data_layer.nutrition_metrics import get_energy_balance_data - from data_layer.recovery_metrics import calculate_recovery_score_v2, calculate_sleep_quality_7d - from data_layer.body_metrics import calculate_lbm_28d_change - - triggers = [] - warning_level = "none" - - # Check 1: Large energy deficit - energy_data = get_energy_balance_data(profile_id, days) - if energy_data.get('energy_balance', 0) < -500: - triggers.append("Großes Energiedefizit (>500 kcal/Tag)") - - # Check 2: Recovery declining - try: - recovery_score = calculate_recovery_score_v2(profile_id) - if recovery_score and recovery_score < 50: - triggers.append("Recovery Score niedrig (<50)") - except: - pass - - # Check 3: Sleep quality - try: - sleep_quality = calculate_sleep_quality_7d(profile_id) - if sleep_quality and sleep_quality < 60: - triggers.append("Schlafqualität reduziert (<60%)") - except: - pass - - # Check 4: LBM declining - try: - lbm_change = calculate_lbm_28d_change(profile_id) - if lbm_change and lbm_change < -1.0: - triggers.append("Magermasse sinkt (-{:.1f} kg)".format(abs(lbm_change))) - except: - pass - - # Determine warning level - if len(triggers) >= 3: - warning_level = "warning" - message = "⚠️ Hinweis auf mögliche Unterversorgung. Mehrere Indikatoren auffällig. Erwäge Defizit-Anpassung oder Regenerationswoche." - elif len(triggers) >= 2: - warning_level = "caution" - message = "⚡ Beobachte folgende Signale genau. Aktuell noch kein Handlungsbedarf, aber Trend beachten." - elif len(triggers) >= 1: - warning_level = "caution" - message = "💡 Ein Indikator auffällig. Weiter beobachten." - else: - message = "✅ Energieverfügbarkeit unauffällig." - - return { - "warning_level": warning_level, - "triggers": triggers, - "message": message, - "metadata": { - "days_analyzed": days, - "trigger_count": len(triggers), - "note": "Heuristische Einschätzung, keine medizinische Diagnose" - } - } + return get_energy_availability_warning_payload(profile_id, days) @router.get("/training-volume") diff --git a/frontend/src/app.css b/frontend/src/app.css index 9cccb52..f8092ee 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -350,6 +350,11 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we font-style: italic; } +/* KPI: Kurz-Hinweis max. 2 Zeilen — Details weiter per ℹ */ +.kpi-tiles-card__hint { + max-height: 2.8em; +} + /* Verlauf Ernährung: Donut (Ø-Quote) + wöchentliche Makro-Verteilung (E3) */ .nutrition-macro-pair { display: grid; diff --git a/frontend/src/components/KpiTilesOverview.jsx b/frontend/src/components/KpiTilesOverview.jsx index e6fead0..275e8a4 100644 --- a/frontend/src/components/KpiTilesOverview.jsx +++ b/frontend/src/components/KpiTilesOverview.jsx @@ -125,14 +125,17 @@ export default function KpiTilesOverview({
{cardHint} diff --git a/frontend/src/components/NutritionCharts.jsx b/frontend/src/components/NutritionCharts.jsx index acec389..cd3a7da 100644 --- a/frontend/src/components/NutritionCharts.jsx +++ b/frontend/src/components/NutritionCharts.jsx @@ -206,7 +206,12 @@ export function WeeklyMacroDistributionPanel({ macroWeeklyData, loading, error } /** * Nutrition Charts (E1–E5). Verlauf: `showWeeklyMacroDistribution={false}` wenn E3 separat (z. B. neben Donut) gerendert wird. */ -export default function NutritionCharts({ days = 28, showWeeklyMacroDistribution = true }) { +export default function NutritionCharts({ + days = 28, + showWeeklyMacroDistribution = true, + /** Verlauf: E5-Kachel liegt in nutrition-history-viz KPIs — doppelte Karte ausblenden */ + hideEnergyAvailabilityCard = false, +}) { const [energyData, setEnergyData] = useState(null) const [proteinData, setProteinData] = useState(null) const [macroWeeklyData, setMacroWeeklyData] = useState(null) @@ -221,15 +226,17 @@ export default function NutritionCharts({ days = 28, showWeeklyMacroDistribution useEffect(() => { loadCharts() - }, [days, showWeeklyMacroDistribution]) + }, [days, showWeeklyMacroDistribution, hideEnergyAvailabilityCard]) const loadCharts = async () => { const tasks = [ loadEnergyBalance(), loadProteinAdequacy(), loadAdherence(), - loadWarning(), ] + if (!hideEnergyAvailabilityCard) { + tasks.push(loadWarning()) + } if (showWeeklyMacroDistribution) { tasks.splice(2, 0, loadMacroWeekly()) } @@ -474,7 +481,7 @@ export default function NutritionCharts({ days = 28, showWeeklyMacroDistribution )} {!loading.adherence && !errors.adherence && renderAdherence()} - {!loading.warning && !errors.warning && renderWarning()} + {!hideEnergyAvailabilityCard && !loading.warning && !errors.warning && renderWarning()}
) } diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 99be26c..c6f4591 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -700,6 +700,26 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl ) } +/** TDEE-Linie muss in der kcal-Y-Domain liegen (sonst unsichtbar trotz Legende). */ +function kcalVsWeightKcalDomain(points, tdeeRef) { + const vals = (points || []) + .map(d => Number(d.kcal_avg)) + .filter(v => !Number.isNaN(v)) + if (!vals.length) return ['auto', 'auto'] + let lo = Math.min(...vals) + let hi = Math.max(...vals) + const t = tdeeRef != null ? Number(tdeeRef) : NaN + if (!Number.isNaN(t)) { + lo = Math.min(lo, t) + hi = Math.max(hi, t) + } + const span = hi - lo || 400 + const pad = Math.max(100, span * 0.1) + return [Math.max(0, Math.floor(lo - pad)), Math.ceil(hi + pad)] +} + +const TDEE_REF_LINE_COLOR = '#475569' + /** Legende unter dem Chart: Linien + ggf. TDEE-Referenz (gestrichelt). */ function KcalVsWeightLegend({ showTdee }) { const line = (color) => ({ @@ -753,7 +773,7 @@ function KcalVsWeightLegend({ showTdee }) { height: 0, verticalAlign: 'middle', marginRight: 6, - borderTop: '2px dashed #EA580C', + borderTop: `2px dashed ${TDEE_REF_LINE_COLOR}`, opacity: 0.95, }} /> @@ -774,6 +794,7 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD })) const n = vizKcalWeight.common_days_count ?? kcalVsW.length const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null + const kcalDomain = kcalVsWeightKcalDomain(kcalVsW, tdeeLabel) return (
@@ -786,14 +807,21 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD - + [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} /> {tdeeLabel != null && ( - + )} @@ -823,6 +851,7 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD const bmr = sex === 'm' ? 10 * latestW + 6.25 * height - 5 * age + 5 : 10 * latestW + 6.25 * height - 5 * age - 161 const tdee = Math.round(bmr * 1.4) const kcalVsW = rollingAvg(raw.map(d => ({ ...d, date: fmtDate(d.date) })), 'kcal') + const kcalDomainFb = kcalVsWeightKcalDomain(kcalVsW, tdee) return (
@@ -836,13 +865,20 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD - + [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} /> - + @@ -1054,7 +1090,7 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
Zeitverläufe (Energie & Protein)
- +