From db5557e4aa01353213783a3b10ba4c6b16304daf Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 22 Apr 2026 10:03:23 +0200 Subject: [PATCH] feat: add nutrition_history_viz widget and enhance configuration handling - Introduced the `nutrition_history_viz` widget to the dashboard, allowing users to visualize nutrition history data. - Updated widget configuration to include `nutrition_history_viz` in the allowed widgets and added validation for its configuration. - Enhanced the widget catalog with details for the new `nutrition_history_viz` entry. - Implemented default values and validation logic for the widget's configuration, ensuring proper handling of user inputs. - Added tests to ensure proper validation of the `nutrition_history_viz` widget configuration. - Bumped application version to reflect the addition of the new widget. --- backend/dashboard_widget_config.py | 73 +++ backend/tests/test_dashboard_widget_config.py | 31 + backend/version.py | 2 +- backend/widget_catalog.py | 6 + .../NutritionHistoryVizWidget.jsx | 34 + .../history/NutritionHistoryVizSection.jsx | 608 ++++++++++++++++++ frontend/src/pages/DashboardConfigurePage.jsx | 19 + frontend/src/pages/DashboardLabPage.jsx | 27 +- frontend/src/pages/History.jsx | 521 +-------------- .../NutritionHistoryVizConfigEditor.jsx | 92 +++ .../widgetSystem/nutritionHistoryVizConfig.js | 79 +++ .../widgetSystem/registerPilotLabWidgets.js | 10 + 12 files changed, 993 insertions(+), 509 deletions(-) create mode 100644 frontend/src/components/dashboard-widgets/NutritionHistoryVizWidget.jsx create mode 100644 frontend/src/components/history/NutritionHistoryVizSection.jsx create mode 100644 frontend/src/widgetSystem/NutritionHistoryVizConfigEditor.jsx create mode 100644 frontend/src/widgetSystem/nutritionHistoryVizConfig.js diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py index b14e9ea..fb97e31 100644 --- a/backend/dashboard_widget_config.py +++ b/backend/dashboard_widget_config.py @@ -15,6 +15,7 @@ MAX_WIDGET_CONFIG_JSON_BYTES = 3072 WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({ "body_overview", "body_history_viz", + "nutrition_history_viz", "activity_overview", "kpi_board", "quick_capture", @@ -59,6 +60,34 @@ _BODY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = { "show_circumference_lines_chart": False, } +_NUTRITION_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({ + "show_goals_strip", + "show_intro_blurb", + "show_kpis", + "show_kcal_vs_weight", + "show_calorie_balance_chart", + "show_protein_lean_chart", + "show_heuristics", + "show_macro_daily_bars", + "show_macro_distribution_pair", + "show_energy_protein_charts", +}) + +_NUTRITION_HISTORY_VIZ_DEFAULTS: dict[str, Any] = { + "chart_days": 30, + "show_goals_strip": False, + "show_intro_blurb": False, + "show_kpis": True, + "kpi_detail": "compact", + "show_kcal_vs_weight": True, + "show_calorie_balance_chart": False, + "show_protein_lean_chart": False, + "show_heuristics": False, + "show_macro_daily_bars": True, + "show_macro_distribution_pair": True, + "show_energy_protein_charts": False, +} + def _config_json_size_bytes(config: dict[str, Any]) -> int: return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8")) @@ -80,12 +109,16 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: if not raw: if widget_id == "body_history_viz": return _validate_body_history_viz_config({}) + if widget_id == "nutrition_history_viz": + return _validate_nutrition_history_viz_config({}) return {} if widget_id == "body_overview": return _validate_chart_days_only(raw, label="body_overview") if widget_id == "body_history_viz": return _validate_body_history_viz_config(raw) + if widget_id == "nutrition_history_viz": + return _validate_nutrition_history_viz_config(raw) if widget_id == "activity_overview": return _validate_chart_days_only(raw, label="activity_overview") if widget_id == "kpi_board": @@ -222,6 +255,46 @@ def _validate_body_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]: return out +def _validate_nutrition_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]: + label = "nutrition_history_viz" + allowed = _NUTRITION_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"}) + unknown = set(raw) - allowed + if unknown: + raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}") + out: dict[str, Any] = dict(_NUTRITION_HISTORY_VIZ_DEFAULTS) + for k in _NUTRITION_HISTORY_VIZ_BOOL_KEYS: + if k not in raw: + continue + v = raw[k] + if not isinstance(v, bool): + raise ValueError(f"{label}: {k} muss boolean sein") + out[k] = v + if "kpi_detail" in raw: + kd = raw["kpi_detail"] + if kd not in ("compact", "full"): + raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein") + out["kpi_detail"] = kd + if "chart_days" in raw: + v = _parse_chart_days(raw["chart_days"], label) + if v < 7 or v > 90: + raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen") + out["chart_days"] = v + if not out["show_kpis"] and not any( + out[k] + for k in ( + "show_kcal_vs_weight", + "show_calorie_balance_chart", + "show_protein_lean_chart", + "show_heuristics", + "show_macro_daily_bars", + "show_macro_distribution_pair", + "show_energy_protein_charts", + ) + ): + raise ValueError(f"{label}: mindestens KPIs oder ein Chart-Bereich muss sichtbar sein") + return out + + def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]: allowed = frozenset({"chart_days"}) unknown = set(raw) - allowed diff --git a/backend/tests/test_dashboard_widget_config.py b/backend/tests/test_dashboard_widget_config.py index 42b57d9..1a1244a 100644 --- a/backend/tests/test_dashboard_widget_config.py +++ b/backend/tests/test_dashboard_widget_config.py @@ -44,6 +44,37 @@ def test_body_history_viz_unknown_key(): validate_widget_entry_config("body_history_viz", {"evil": True}) +def test_nutrition_history_viz_empty_expands_defaults(): + d = validate_widget_entry_config("nutrition_history_viz", {}) + assert d["chart_days"] == 30 + assert d["show_kpis"] is True + assert d["show_kcal_vs_weight"] is True + assert d["kpi_detail"] == "compact" + assert d["show_calorie_balance_chart"] is False + assert d["show_energy_protein_charts"] is False + + +def test_nutrition_history_viz_chart_days_and_merge(): + d = validate_widget_entry_config("nutrition_history_viz", {"chart_days": 45}) + assert d["chart_days"] == 45 + assert d["show_goals_strip"] is False + with pytest.raises(ValueError): + validate_widget_entry_config("nutrition_history_viz", {"chart_days": 5}) + + +def test_nutrition_history_viz_requires_visible_block(): + with pytest.raises(ValueError): + validate_widget_entry_config( + "nutrition_history_viz", + {"show_kpis": False, "show_kcal_vs_weight": False, "show_macro_daily_bars": False, "show_macro_distribution_pair": False}, + ) + + +def test_nutrition_history_viz_unknown_key(): + with pytest.raises(ValueError): + validate_widget_entry_config("nutrition_history_viz", {"evil": True}) + + def test_welcome_config_rejected_unknown_key(): with pytest.raises(ValueError): validate_widget_entry_config("welcome", {"x": 1}) diff --git a/backend/version.py b/backend/version.py index d1b2bb0..1e2cb34 100644 --- a/backend/version.py +++ b/backend/version.py @@ -30,7 +30,7 @@ MODULE_VERSIONS = { "importdata": "1.0.0", "membership": "2.1.0", "workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode) - "app_dashboard": "1.13.0", # body_history_viz: Sichtbarkeits-Config + Defaults schlank + "app_dashboard": "1.14.0", # nutrition_history_viz: Verlauf-Bundle-Widget + Config wie Körper "csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise "admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response) } diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py index e6007b2..5b6f0c8 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -100,6 +100,12 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [ "description": "Phase-0c NutritionCharts (optional chart_days 7–90, Default 30); Feature nutrition_entries", "requires_feature": "nutrition_entries", }, + { + "id": "nutrition_history_viz", + "title": "Ernährung (Verlauf-Bundle)", + "description": "Layer-2b nutrition-history-viz: schlanker Standard; Blöcke per show_* / kpi_detail; chart_days 7–90; Feature nutrition_entries", + "requires_feature": "nutrition_entries", + }, { "id": "recovery_charts_panel", "title": "Erholung — Charts R1–R5", diff --git a/frontend/src/components/dashboard-widgets/NutritionHistoryVizWidget.jsx b/frontend/src/components/dashboard-widgets/NutritionHistoryVizWidget.jsx new file mode 100644 index 0000000..655a440 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/NutritionHistoryVizWidget.jsx @@ -0,0 +1,34 @@ +import { useNavigate } from 'react-router-dom' +import NutritionHistoryVizSection from '../history/NutritionHistoryVizSection' +import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays' +import { normalizeNutritionHistoryVizConfig } from '../../widgetSystem/nutritionHistoryVizConfig' + +/** + * Verlauf → Ernährung als Dashboard-Widget: GET /charts/nutrition-history-viz (Layer 2b), Umfang über Layout-Config. + * @param {{ refreshTick?: number, nutritionHistoryVizConfig?: Record }} props + */ +export default function NutritionHistoryVizWidget({ refreshTick = 0, nutritionHistoryVizConfig }) { + const nav = useNavigate() + const cfg = normalizeNutritionHistoryVizConfig(nutritionHistoryVizConfig) + const days = normalizeBodyChartDays(cfg.chart_days) + + return ( +
+
+
+
Ernährung (Verlauf-Bundle)
+
nutrition-history-viz · {days} Tage
+
+ +
+ +
+ ) +} diff --git a/frontend/src/components/history/NutritionHistoryVizSection.jsx b/frontend/src/components/history/NutritionHistoryVizSection.jsx new file mode 100644 index 0000000..aedd450 --- /dev/null +++ b/frontend/src/components/history/NutritionHistoryVizSection.jsx @@ -0,0 +1,608 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, + ReferenceLine, + BarChart, + Bar, + PieChart, + Pie, + Cell, +} from 'recharts' +import { ChevronRight } from 'lucide-react' +import dayjs from 'dayjs' +import 'dayjs/locale/de' +import { api } from '../../utils/api' +import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../../utils/macroChartTheme' +import NutritionCharts, { WeeklyMacroDistributionPanel } from '../NutritionCharts' +import KpiTilesOverview from '../KpiTilesOverview' +import { + NUTRITION_HISTORY_VIZ_HISTORY_FULL, + filterNutritionHistoryKpiTiles, +} from '../../widgetSystem/nutritionHistoryVizConfig' +import { EmptySection, PeriodSelector, SectionHeader } from './historyPageChrome' + +dayjs.locale('de') +const fmtDate = (d) => dayjs(d).format('DD.MM') + +function ChartFrame({ heightPx, children }) { + return ( +
+ {children} +
+ ) +} + +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 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' + +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 — nur Layer-2b-Bundle (nutrition_metrics). */ +function KcalVsWeightChart({ vizKcalWeight, chartHeight = 200 }) { + const n = vizKcalWeight?.points?.length ?? 0 + if (n < 5) { + if (n === 0) return null + return ( +
+
+ Kalorien (Ø 7 Tage) vs. Gewicht +
+
+ Für dieses Diagramm werden mindestens 5 Tage mit Kalorien- und Gewichtsdaten benötigt ({n} im Zeitraum). +
+
+ ) + } + + const tdee = vizKcalWeight.tdee_reference_kcal + const kcalVsW = vizKcalWeight.points.map((d) => ({ + ...d, + date: fmtDate(d.date), + })) + const commonDays = vizKcalWeight.common_days_count ?? kcalVsW.length + const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null + const kcalDomain = kcalVsWeightKcalDomain(kcalVsW, tdeeLabel) + return ( +
+
+ Kalorien (Ø 7 Tage) vs. Gewicht +
+
+ Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg. +
+ + + + + + + + [`${Math.round(v)} ${name === 'weight' ? 'kg' : 'kcal'}`, name === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} + /> + {tdeeLabel != null && ( + + )} + + + + + + +
+ {tdeeLabel != null + ? `TDEE ~${tdeeLabel} kcal · ${commonDays} gemeinsame Tage` + : `Keine TDEE-Referenz (Gewicht/Demografie) · ${commonDays} gemeinsame Tage`} +
+
+ ) +} + +/** + * Verlauf → Ernährung: GET /charts/nutrition-history-viz (Layer 2b) + optional NutritionCharts mit Bundle-Payloads. + * @param {object} [props.visibility] — Dashboard-Config; undefined = voller Verlauf + * @param {number} [props.externalPeriod] — feste Tage (7–90) im Widget + * @param {boolean} [props.embedded] + * @param {import('react').ReactNode} [props.footer] + */ +export default function NutritionHistoryVizSection({ externalPeriod, embedded = false, visibility, footer = null }) { + const display = visibility === undefined ? NUTRITION_HISTORY_VIZ_HISTORY_FULL : visibility + const chartHMain = embedded ? 176 : 200 + const chartHBal = embedded ? 160 : 180 + const chartHPlm = embedded ? 160 : 180 + + const [internalPeriod, setInternalPeriod] = useState(30) + const period = externalPeriod !== undefined ? externalPeriod : internalPeriod + const showPeriodSelector = externalPeriod === undefined + + const [groupedGoals, setGroupedGoals] = useState(null) + const [viz, setViz] = useState(null) + const [vizLoad, setVizLoad] = useState(true) + const [vizErr, setVizErr] = useState(null) + + useEffect(() => { + if (!display.show_goals_strip) { + setGroupedGoals({}) + return undefined + } + let cancelled = false + api.listGoalsGrouped() + .then((g) => { + if (!cancelled) setGroupedGoals(g) + }) + .catch(() => { + if (!cancelled) setGroupedGoals({}) + }) + return () => { + cancelled = true + } + }, [display.show_goals_strip]) + + useEffect(() => { + let cancelled = 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 + } + }, [period]) + + if (vizLoad) { + return ( +
+ {!embedded && } + {showPeriodSelector && } +
+
+ ) + } + + if (vizErr) { + return ( +
+ {!embedded && } +
{vizErr}
+
+ ) + } + + if (!viz?.has_nutrition_entries) { + return ( +
+ {!embedded && } + {showPeriodSelector && } + +
+ ) + } + + 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 kpiTilesRaw = (viz.kpi_tiles || []).map((t) => ({ + ...t, + sublabel: typeof t.sublabel === 'string' && t.sublabel.length > 36 ? `${t.sublabel.slice(0, 34)}…` : t.sublabel, + })) + const kpiTilesShown = display.show_kpis + ? filterNutritionHistoryKpiTiles(kpiTilesRaw, display.kpi_detail || 'full') + : [] + 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 balDaily = viz.calorie_balance_daily || [] + const plm = viz.protein_vs_lean_mass || {} + const plmPts = plm.points || [] + const nutHeur = viz.nutrition_correlation_heuristics || [] + const tdeeRef = viz.tdee_reference_kcal + + if (!cdMacro.length || n === 0) { + return ( +
+ {!embedded && } + {showPeriodSelector && } + +
+ ) + } + + return ( +
+ {!embedded && } + {embedded && viz?.last_updated && ( +
+ Stand {dayjs(viz.last_updated).format('DD.MM.YY')} +
+ )} + {showPeriodSelector && } + + {display.show_intro_blurb && ( +

+ 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). + {' '} + Kalorienbilanz, Protein vs. Magermasse und den Block{' '} + «Kurz-Einordnung» finden Sie hier — früher im eigenen Reiter «Korrelation» (jetzt Data-Layer-Bundle). +

+ )} + + {display.show_goals_strip && } + + {kpiTilesShown.length > 0 && } + + {display.show_kcal_vs_weight && } + + {display.show_calorie_balance_chart && balDaily.length > 0 && tdeeRef != null && ( +
+
+ Kalorienbilanz (Aufnahme − TDEE ~{Math.round(tdeeRef)} kcal) +
+
+ Tagesbilanz und 7-Tage-Mittel — gleiche TDEE-Quelle wie «Kalorien vs. Gewicht» (Data-Layer). +
+ + + ({ ...d, date: fmtDate(d.date) }))} + margin={{ top: 4, right: 8, bottom: 0, left: -16 }} + > + + + + + [`${v > 0 ? '+' : ''}${v} kcal`, name === 'balance_kcal_avg' ? 'Ø 7T Bilanz' : 'Tagesbilanz']} + /> + + + + + +
+ )} + + {display.show_protein_lean_chart && plmPts.length >= 3 && ( +
+
+ Protein vs. Magermasse (Caliper, forward-filled) +
+
+ Zweitachsig; gestrichelte Linie = Protein-Minimum ({plm.protein_target_low_g || ptLow || '—'} g), wenn verfügbar. +
+ + + ({ ...d, date: fmtDate(d.date), protein: d.protein_g, lean: d.lean_mass_kg }))} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}> + + + + + {plm.protein_target_low_g > 0 && ( + + )} + [`${v}${name === 'protein' ? 'g' : ' kg'}`, name === 'protein' ? 'Protein' : 'Mager']} + /> + + + + + +
+ )} + + {display.show_heuristics && nutHeur.length > 0 && ( +
+
Ernährung — Kurz-Einordnung
+ {nutHeur.map((item, i) => ( +
+
+ {item.icon || '•'} +
+
{item.title}
+
{item.detail}
+
+
+
+ ))} +
+ )} + + {display.show_macro_daily_bars && ( +
+
+ Makroverteilung täglich (g) · Fokus Protein +
+
+ Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow || '—'} g) nach 1,6 g/kg (Referenzgewicht). +
+ + + + + + + {ptLow > 0 && ( + + )} + [`${v}g`, name]} /> + + + + + + +
+ Protein (unten) + Fett (Mitte) + KH (oben) +
+
+ )} + + {display.show_macro_distribution_pair && ( +
+
+
+ Ø Makro-Quote ({n} Tage) +
+ {pieData.length > 0 ? ( +
+
+ + + + + {pieData.map((e, i) => ( + + ))} + + [`${v}%`, name]} /> + + + +
+
+ {pieData.map((p) => { + const fill = macroFillByName(p.name) + return ( +
+
+
{p.name}
+
{p.value}%
+
+ {p.grams != null ? `${p.grams}g` : '—'} +
+
+ ) + })} +
+ Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz +
+
+
+ ) : ( +
Keine Makro-Mittelwerte im Zeitraum.
+ )} +
+
+
+ Wöchentliche Makro-Verteilung (Backend) +
+ +
+
+ )} + + {display.show_energy_protein_charts && ( + <> +
+ Zeitverläufe (Energie & Protein) +
+ + + )} + + {footer} +
+ ) +} diff --git a/frontend/src/pages/DashboardConfigurePage.jsx b/frontend/src/pages/DashboardConfigurePage.jsx index 5776033..3ec05a8 100644 --- a/frontend/src/pages/DashboardConfigurePage.jsx +++ b/frontend/src/pages/DashboardConfigurePage.jsx @@ -12,6 +12,7 @@ import { import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor' +import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor' import { moveWidget, moveWidgetToIndex, @@ -24,6 +25,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([ 'body_history_viz', 'activity_overview', 'nutrition_detail_charts', + 'nutrition_history_viz', 'recovery_charts_panel', ]) @@ -514,6 +516,23 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) { } /> )} + {w.id === 'nutrition_history_viz' && ( + + setLayout((L) => + normalizeLayoutForEditor({ + ...L, + widgets: L.widgets.map((x, j) => { + if (j !== i) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }), + }) + ) + } + /> + )} ) })} diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx index 26720f3..7c3a0ee 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -13,6 +13,7 @@ import { import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor' +import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor' import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor' /** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */ @@ -21,6 +22,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([ 'body_history_viz', 'activity_overview', 'nutrition_detail_charts', + 'nutrition_history_viz', 'recovery_charts_panel', ]) @@ -326,7 +328,9 @@ export default function DashboardLabPage() { ? 'Aktivität (Verteilung & Konsistenz)' : w.id === 'nutrition_detail_charts' ? 'Ernährung — Charts' - : 'Erholung — Charts'}{' '} + : w.id === 'nutrition_history_viz' + ? 'Ernährung (Verlauf-Bundle)' + : 'Erholung — Charts'}{' '} — Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX} )} + {w.id === 'nutrition_history_viz' && ( + + setLayout((L) => + normalizeLayoutForEditor({ + ...L, + widgets: L.widgets.map((x, j) => { + if (j !== i) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }), + }) + ) + } + /> + )} ) })} diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 098ab03..5ba9ed8 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -4,27 +4,23 @@ import { useProfile } from '../context/ProfileContext' import { LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, - ReferenceLine, PieChart, Pie, Cell, ComposedChart, + ReferenceLine, Cell, ComposedChart, ScatterChart, Scatter, } from 'recharts' -import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' +import { Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' import { api } from '../utils/api' import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay' import { getStatusColor, getStatusBg } from '../utils/interpret' -import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme' import Markdown from '../utils/Markdown' import FitnessDashboardOverview from '../components/FitnessDashboardOverview' -import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts' import RecoveryDashboardOverview from '../components/RecoveryDashboardOverview' -import KpiTilesOverview from '../components/KpiTilesOverview' import BodyHistoryVizSection from '../components/history/BodyHistoryVizSection' +import NutritionHistoryVizSection from '../components/history/NutritionHistoryVizSection' import { EmptySection, NavToCaliper, NavToCircum, PeriodSelector, SectionHeader } from '../components/history/historyPageChrome' import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') -const fmtDate = d => dayjs(d).format('DD.MM') - function RuleCard({ item }) { const [open, setOpen] = useState(false) const color = getStatusColor(item.status) @@ -46,51 +42,6 @@ function RuleCard({ item }) { ) } -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 InsightBox({ insights, slugs, onRequest, loading }) { const [expanded, setExpanded] = useState(null) const relevant = insights?.filter(i=>slugs.includes(i.scope))||[] @@ -138,459 +89,6 @@ function InsightBox({ insights, slugs, onRequest, loading }) { ) } -/** 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) => ({ - 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 — nur Layer-2b-Bundle (nutrition_metrics); kein Frontend-TDEE-Fallback. */ -function KcalVsWeightChart({ vizKcalWeight }) { - const n = vizKcalWeight?.points?.length ?? 0 - if (n < 5) { - if (n === 0) return null - return ( -
-
- Kalorien (Ø 7 Tage) vs. Gewicht -
-
- Für dieses Diagramm werden mindestens 5 Tage mit Kalorien- und Gewichtsdaten benötigt ({n} im Zeitraum). -
-
- ) - } - - const tdee = vizKcalWeight.tdee_reference_kcal - const kcalVsW = vizKcalWeight.points.map(d => ({ - ...d, - date: fmtDate(d.date), - })) - const commonDays = vizKcalWeight.common_days_count ?? kcalVsW.length - const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null - const kcalDomain = kcalVsWeightKcalDomain(kcalVsW, tdeeLabel) - return ( -
-
- Kalorien (Ø 7 Tage) vs. Gewicht -
-
- Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg. -
- - - - - - - [`${Math.round(v)} ${name === 'weight' ? 'kg' : 'kcal'}`, name === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} - /> - {tdeeLabel != null && ( - - )} - - - - - -
- {tdeeLabel != null - ? `TDEE ~${tdeeLabel} kcal · ${commonDays} gemeinsame Tage` - : `Keine TDEE-Referenz (Gewicht/Demografie) · ${commonDays} gemeinsame Tage`} -
-
- ) -} - -// ── Nutrition Section ───────────────────────────────────────────────────────── -/** Layer 2b: Kennzahlen und Reihen nur aus GET /charts/nutrition-history-viz (nutrition_metrics). */ -function NutritionSection({ insights, onRequest, loadingSlug, filterActiveSlugs }) { - const [period, setPeriod] = useState(30) - const [groupedGoals, setGroupedGoals] = useState(null) - const [viz, setViz] = useState(null) - const [vizLoad, setVizLoad] = useState(true) - const [vizErr, setVizErr] = 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 - 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 } - }, [period]) - - if (vizLoad) { - return ( -
- - -
-
- ) - } - - if (vizErr) { - return ( -
- -
{vizErr}
-
- ) - } - - if (!viz?.has_nutrition_entries) { - return ( - - ) - } - - 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 - const balDaily = viz.calorie_balance_daily || [] - const plm = viz.protein_vs_lean_mass || {} - const plmPts = plm.points || [] - const nutHeur = viz.nutrition_correlation_heuristics || [] - const tdeeRef = viz.tdee_reference_kcal - - if (!cdMacro.length || n === 0) { - return ( -
- - - -
- ) - } - - return ( -
- - - -

- 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). - {' '} - Kalorienbilanz, Protein vs. Magermasse und den Block{' '} - «Kurz-Einordnung» finden Sie hier — früher im eigenen Reiter «Korrelation» (jetzt Data-Layer-Bundle). -

- - - - - - - - {balDaily.length > 0 && tdeeRef != null && ( -
-
- Kalorienbilanz (Aufnahme − TDEE ~{Math.round(tdeeRef)} kcal) -
-
- Tagesbilanz und 7-Tage-Mittel — gleiche TDEE-Quelle wie «Kalorien vs. Gewicht» (Data-Layer). -
- - ({ ...d, date: fmtDate(d.date) }))} - margin={{ top: 4, right: 8, bottom: 0, left: -16 }} - > - - - - - [`${v > 0 ? '+' : ''}${v} kcal`, n === 'balance_kcal_avg' ? 'Ø 7T Bilanz' : 'Tagesbilanz']} - /> - - - - -
- )} - - {plmPts.length >= 3 && ( -
-
- Protein vs. Magermasse (Caliper, forward-filled) -
-
- Zweitachsig; gestrichelte Linie = Protein-Minimum ({plm.protein_target_low_g || ptLow || '—'} g), wenn verfügbar. -
- - ({ ...d, date: fmtDate(d.date), protein: d.protein_g, lean: d.lean_mass_kg }))} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}> - - - - - {plm.protein_target_low_g > 0 && ( - - )} - [`${v}${n === 'protein' ? 'g' : ' kg'}`, n === 'protein' ? 'Protein' : 'Mager']} - /> - - - - -
- )} - - {nutHeur.length > 0 && ( -
-
Ernährung — Kurz-Einordnung
- {nutHeur.map((item, i) => ( -
-
- {item.icon || '•'} -
-
{item.title}
-
{item.detail}
-
-
-
- ))} -
- )} - -
-
- Makroverteilung täglich (g) · Fokus Protein -
-
- Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow || '—'} g) nach 1,6 g/kg (Referenzgewicht). -
- - - - - - {ptLow > 0 && ( - - )} - [`${v}g`, name]} /> - - - - - -
- Protein (unten) - Fett (Mitte) - KH (oben) -
-
- -
-
-
- Ø Makro-Quote ({n} Tage) -
- {pieData.length > 0 ? ( -
-
- - - - {pieData.map((e, i) => ( - - ))} - - [`${v}%`, name]} /> - - -
-
- {pieData.map(p => { - const fill = macroFillByName(p.name) - return ( -
-
-
{p.name}
-
{p.value}%
-
- {p.grams != null ? `${p.grams}g` : '—'} -
-
- ) - })} -
- Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz -
-
-
- ) : ( -
Keine Makro-Mittelwerte im Zeitraum.
- )} -
-
-
- Wöchentliche Makro-Verteilung (Backend) -
- -
-
- -
- Zeitverläufe (Energie & Protein) -
- - - -
- ) -} - // ── Activity Section — nur Layer-2b-Bundle (+ KI-Insights), keine parallelen Client-Charts ─ function ActivitySection({ activityLastDate, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) { const [period, setPeriod] = useState(30) @@ -1316,7 +814,18 @@ export default function History() { )} /> )} - {tab==='nutrition' && } + {tab === 'nutrition' && ( + + )} + /> + )} {tab==='activity' && } {tab==='photos' && }
diff --git a/frontend/src/widgetSystem/NutritionHistoryVizConfigEditor.jsx b/frontend/src/widgetSystem/NutritionHistoryVizConfigEditor.jsx new file mode 100644 index 0000000..b2c2057 --- /dev/null +++ b/frontend/src/widgetSystem/NutritionHistoryVizConfigEditor.jsx @@ -0,0 +1,92 @@ +import { NUTRITION_HISTORY_VIZ_WIDGET_DEFAULTS, normalizeNutritionHistoryVizConfig } from './nutritionHistoryVizConfig' + +const CHART_TOGGLES = [ + { key: 'show_kcal_vs_weight', label: 'Kalorien vs. Gewicht' }, + { key: 'show_calorie_balance_chart', label: 'Kalorienbilanz' }, + { key: 'show_protein_lean_chart', label: 'Protein vs. Magermasse' }, + { key: 'show_heuristics', label: 'Kurz-Einordnung (Heuristiken)' }, + { key: 'show_macro_daily_bars', label: 'Makros täglich (Balken)' }, + { key: 'show_macro_distribution_pair', label: 'Donut + Wochen-Makros' }, + { key: 'show_energy_protein_charts', label: 'Zeitverläufe (NutritionCharts, Bundle-Payloads)' }, +] + +const OTHER_TOGGLES = [ + { key: 'show_goals_strip', label: 'Ernährungs-Ziele (Strip)' }, + { key: 'show_intro_blurb', label: 'Hinweistext (Data-Layer)' }, + { key: 'show_kpis', label: 'KPI-Kacheln' }, +] + +/** + * @param {{ config: Record, onChange: (next: Record) => void }} props + */ +export default function NutritionHistoryVizConfigEditor({ config, onChange }) { + const merged = normalizeNutritionHistoryVizConfig(config) + + const patch = (partial) => { + const next = { ...merged, ...partial } + const def = NUTRITION_HISTORY_VIZ_WIDGET_DEFAULTS + const stored = {} + for (const k of Object.keys(def)) { + if (next[k] !== def[k]) stored[k] = next[k] + } + onChange(stored) + } + + const setBool = (key, checked) => { + patch({ [key]: checked }) + } + + return ( +
+
+ Ernährung (Verlauf-Bundle): welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker + Standard (KPI kompakt, kcal vs. Gewicht, Makro-Balken + Donut/Woche). +
+
KPI-Umfang
+ + +
Bereiche
+
+ {OTHER_TOGGLES.map(({ key, label }) => ( + + ))} +
+
Charts
+
+ {CHART_TOGGLES.map(({ key, label }) => ( + + ))} +
+ +
+ ) +} diff --git a/frontend/src/widgetSystem/nutritionHistoryVizConfig.js b/frontend/src/widgetSystem/nutritionHistoryVizConfig.js new file mode 100644 index 0000000..eda8b3a --- /dev/null +++ b/frontend/src/widgetSystem/nutritionHistoryVizConfig.js @@ -0,0 +1,79 @@ +/** + * Sichtbarkeit für nutrition_history_viz (sync mit backend dashboard_widget_config). + * `visibility === undefined` → Verlauf-Tab: alles an. + */ + +export const NUTRITION_HISTORY_VIZ_HISTORY_FULL = { + chart_days: 30, + show_goals_strip: true, + show_intro_blurb: true, + show_kpis: true, + kpi_detail: 'full', + show_kcal_vs_weight: true, + show_calorie_balance_chart: true, + show_protein_lean_chart: true, + show_heuristics: true, + show_macro_daily_bars: true, + show_macro_distribution_pair: true, + show_energy_protein_charts: true, +} + +export const NUTRITION_HISTORY_VIZ_WIDGET_DEFAULTS = { + chart_days: 30, + show_goals_strip: false, + show_intro_blurb: false, + show_kpis: true, + kpi_detail: 'compact', + show_kcal_vs_weight: true, + show_calorie_balance_chart: false, + show_protein_lean_chart: false, + show_heuristics: false, + show_macro_daily_bars: true, + show_macro_distribution_pair: true, + show_energy_protein_charts: false, +} + +const BOOL_KEYS = [ + 'show_goals_strip', + 'show_intro_blurb', + 'show_kpis', + 'show_kcal_vs_weight', + 'show_calorie_balance_chart', + 'show_protein_lean_chart', + 'show_heuristics', + 'show_macro_daily_bars', + 'show_macro_distribution_pair', + 'show_energy_protein_charts', +] + +/** + * @param {Record|null|undefined} raw + */ +export function normalizeNutritionHistoryVizConfig(raw) { + const base = { ...NUTRITION_HISTORY_VIZ_WIDGET_DEFAULTS } + if (!raw || typeof raw !== 'object') return base + for (const k of BOOL_KEYS) { + if (Object.prototype.hasOwnProperty.call(raw, k)) { + base[k] = raw[k] === true + } + } + if (raw.kpi_detail === 'full' || raw.kpi_detail === 'compact') { + base.kpi_detail = raw.kpi_detail + } + if (raw.chart_days != null) { + const n = Number(raw.chart_days) + if (Number.isFinite(n)) { + base.chart_days = Math.min(90, Math.max(7, Math.round(n))) + } + } + return base +} + +/** + * @param {Array} kpiTiles + * @param {'compact'|'full'} detail + */ +export function filterNutritionHistoryKpiTiles(kpiTiles, detail) { + if (detail === 'full' || !Array.isArray(kpiTiles)) return kpiTiles + return kpiTiles.slice(0, 4) +} diff --git a/frontend/src/widgetSystem/registerPilotLabWidgets.js b/frontend/src/widgetSystem/registerPilotLabWidgets.js index 96ad498..0992c6b 100644 --- a/frontend/src/widgetSystem/registerPilotLabWidgets.js +++ b/frontend/src/widgetSystem/registerPilotLabWidgets.js @@ -15,7 +15,9 @@ import TrendKcalWeightWidget from '../components/dashboard-widgets/TrendKcalWeig import NutritionActivitySummaryWidget from '../components/dashboard-widgets/NutritionActivitySummaryWidget' import NutritionDetailChartsWidget from '../components/dashboard-widgets/NutritionDetailChartsWidget' import BodyHistoryVizWidget from '../components/dashboard-widgets/BodyHistoryVizWidget' +import NutritionHistoryVizWidget from '../components/dashboard-widgets/NutritionHistoryVizWidget' import { normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig' +import { normalizeNutritionHistoryVizConfig } from './nutritionHistoryVizConfig' import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget' import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget' import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget' @@ -122,6 +124,14 @@ export function ensurePilotLabWidgetsRegistered() { chartDays: ctx.layoutEntry?.config?.chart_days, }), }) + registerDashboardWidget({ + id: 'nutrition_history_viz', + Component: NutritionHistoryVizWidget, + mapProps: (ctx) => ({ + refreshTick: ctx.refreshTick, + nutritionHistoryVizConfig: normalizeNutritionHistoryVizConfig(ctx.layoutEntry?.config), + }), + }) registerDashboardWidget({ id: 'recovery_charts_panel', Component: RecoveryChartsPanelWidget,