feat: add nutrition_history_viz widget and enhance configuration handling
Some checks failed
Deploy Development / deploy (push) Successful in 50s
Build Test / pytest-backend (push) Failing after 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 18s

- 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.
This commit is contained in:
Lars 2026-04-22 10:03:23 +02:00
parent 20f195aca1
commit db5557e4aa
12 changed files with 993 additions and 509 deletions

View File

@ -15,6 +15,7 @@ MAX_WIDGET_CONFIG_JSON_BYTES = 3072
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({ WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
"body_overview", "body_overview",
"body_history_viz", "body_history_viz",
"nutrition_history_viz",
"activity_overview", "activity_overview",
"kpi_board", "kpi_board",
"quick_capture", "quick_capture",
@ -59,6 +60,34 @@ _BODY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
"show_circumference_lines_chart": False, "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: def _config_json_size_bytes(config: dict[str, Any]) -> int:
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8")) 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 not raw:
if widget_id == "body_history_viz": if widget_id == "body_history_viz":
return _validate_body_history_viz_config({}) return _validate_body_history_viz_config({})
if widget_id == "nutrition_history_viz":
return _validate_nutrition_history_viz_config({})
return {} return {}
if widget_id == "body_overview": if widget_id == "body_overview":
return _validate_chart_days_only(raw, label="body_overview") return _validate_chart_days_only(raw, label="body_overview")
if widget_id == "body_history_viz": if widget_id == "body_history_viz":
return _validate_body_history_viz_config(raw) 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": if widget_id == "activity_overview":
return _validate_chart_days_only(raw, label="activity_overview") return _validate_chart_days_only(raw, label="activity_overview")
if widget_id == "kpi_board": if widget_id == "kpi_board":
@ -222,6 +255,46 @@ def _validate_body_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
return out 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]: def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]:
allowed = frozenset({"chart_days"}) allowed = frozenset({"chart_days"})
unknown = set(raw) - allowed unknown = set(raw) - allowed

View File

@ -44,6 +44,37 @@ def test_body_history_viz_unknown_key():
validate_widget_entry_config("body_history_viz", {"evil": True}) 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(): def test_welcome_config_rejected_unknown_key():
with pytest.raises(ValueError): with pytest.raises(ValueError):
validate_widget_entry_config("welcome", {"x": 1}) validate_widget_entry_config("welcome", {"x": 1})

View File

@ -30,7 +30,7 @@ MODULE_VERSIONS = {
"importdata": "1.0.0", "importdata": "1.0.0",
"membership": "2.1.0", "membership": "2.1.0",
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode) "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 "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) "admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response)
} }

View File

@ -100,6 +100,12 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
"description": "Phase-0c NutritionCharts (optional chart_days 790, Default 30); Feature nutrition_entries", "description": "Phase-0c NutritionCharts (optional chart_days 790, Default 30); Feature nutrition_entries",
"requires_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 790; Feature nutrition_entries",
"requires_feature": "nutrition_entries",
},
{ {
"id": "recovery_charts_panel", "id": "recovery_charts_panel",
"title": "Erholung — Charts R1R5", "title": "Erholung — Charts R1R5",

View File

@ -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<string, unknown> }} props
*/
export default function NutritionHistoryVizWidget({ refreshTick = 0, nutritionHistoryVizConfig }) {
const nav = useNavigate()
const cfg = normalizeNutritionHistoryVizConfig(nutritionHistoryVizConfig)
const days = normalizeBodyChartDays(cfg.chart_days)
return (
<div className="card section-gap" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Ernährung (Verlauf-Bundle)</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>nutrition-history-viz · {days} Tage</div>
</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => nav('/history', { state: { tab: 'nutrition' } })}>
Verlauf
</button>
</div>
<NutritionHistoryVizSection
key={`${refreshTick}-${days}`}
externalPeriod={days}
embedded
visibility={cfg}
/>
</div>
)
}

View File

@ -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 (
<div style={{ width: '100%', minWidth: 0, height: heightPx }} className="nutrition-history-viz__chart-frame">
{children}
</div>
)
}
function NutritionGoalsStrip({ grouped }) {
const nav = useNavigate()
const goals = (grouped?.nutrition || []).filter((g) => g.status === 'active').slice(0, 4)
if (!goals.length) return null
return (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Ernährungsbezogene Ziele</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/goals')}>
Ziele <ChevronRight size={10} />
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{goals.map((g) => (
<div
key={g.id}
style={{
flex: '1 1 140px',
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
borderTop: `3px solid ${g.is_primary ? 'var(--accent)' : 'var(--border2)'}`,
}}
>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--text2)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{g.name || g.label_de || g.goal_type}
</div>
<div style={{ marginTop: 4, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
<div
style={{
width: `${Math.min(100, Math.max(0, g.progress_pct ?? 0))}%`,
height: '100%',
background: 'var(--accent)',
}}
/>
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}
{g.unit ? ` ${g.unit}` : ''}
</div>
</div>
))}
</div>
</div>
)
}
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 (
<div
className="kcal-vs-weight-legend"
style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'center',
gap: '12px 18px',
marginTop: 10,
fontSize: 10,
color: 'var(--text2)',
lineHeight: 1.35,
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<span style={line('#EA580C')} />
Ø Kalorien (7-Tage-Mittel)
</span>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<span
style={{
display: 'inline-block',
width: 9,
height: 9,
borderRadius: '50%',
background: '#2563EB',
marginRight: 6,
verticalAlign: 'middle',
}}
/>
Gewicht (kg)
</span>
{showTdee ? (
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<span
style={{
display: 'inline-block',
width: 22,
height: 0,
verticalAlign: 'middle',
marginRight: 6,
borderTop: `2px dashed ${TDEE_REF_LINE_COLOR}`,
opacity: 0.95,
}}
/>
TDEE-Referenz (geschätzt)
</span>
) : null}
</div>
)
}
/** 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 (
<div className="card" style={{ marginBottom: 12, padding: '12px 14px' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Kalorien (Ø 7 Tage) vs. Gewicht
</div>
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45 }}>
Für dieses Diagramm werden mindestens 5 Tage mit Kalorien- und Gewichtsdaten benötigt ({n} im Zeitraum).
</div>
</div>
)
}
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 (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Kalorien (Ø 7 Tage) vs. Gewicht
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg.
</div>
<ChartFrame heightPx={chartHeight}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={kcalVsW} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(kcalVsW.length / 6) - 1)} />
<YAxis yAxisId="kcal" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={kcalDomain} />
<YAxis yAxisId="weight" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, name) => [`${Math.round(v)} ${name === 'weight' ? 'kg' : 'kcal'}`, name === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']}
/>
{tdeeLabel != null && (
<ReferenceLine
yAxisId="kcal"
y={tdeeLabel}
stroke={TDEE_REF_LINE_COLOR}
strokeDasharray="6 5"
strokeWidth={2}
isFront
/>
)}
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EA580C" strokeWidth={2.5} dot={false} name="kcal_avg" />
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#2563EB" strokeWidth={2.5} dot={{ r: 2, fill: '#2563EB' }} name="weight" />
</LineChart>
</ResponsiveContainer>
</ChartFrame>
<KcalVsWeightLegend showTdee={tdeeLabel != null} />
<div style={{ fontSize: 10, color: 'var(--text3)', textAlign: 'center', marginTop: 8 }}>
{tdeeLabel != null
? `TDEE ~${tdeeLabel} kcal · ${commonDays} gemeinsame Tage`
: `Keine TDEE-Referenz (Gewicht/Demografie) · ${commonDays} gemeinsame Tage`}
</div>
</div>
)
}
/**
* 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 (790) 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 (
<div>
{!embedded && <SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" />}
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
<div className="spinner" style={{ margin: 24 }} />
</div>
)
}
if (vizErr) {
return (
<div>
{!embedded && <SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" />}
<div className="card" style={{ color: 'var(--danger)', marginTop: 8 }}>{vizErr}</div>
</div>
)
}
if (!viz?.has_nutrition_entries) {
return (
<div>
{!embedded && <SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" />}
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
<EmptySection text="Noch keine Ernährungsdaten." to="/nutrition" toLabel="FDDB importieren" />
</div>
)
}
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 (
<div>
{!embedded && <SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={viz.last_updated} />}
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
<EmptySection text="Keine Einträge im gewählten Zeitraum." />
</div>
)
}
return (
<div>
{!embedded && <SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={viz.last_updated} />}
{embedded && viz?.last_updated && (
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10 }}>
Stand {dayjs(viz.last_updated).format('DD.MM.YY')}
</div>
)}
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
{display.show_intro_blurb && (
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '}
<strong>Kalorien vs. Gewicht</strong> und TDEE-Referenz entsprechen MifflinSt Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg).
{' '}
<strong>Kalorienbilanz</strong>, <strong>Protein vs. Magermasse</strong> und den Block{' '}
<strong>«Kurz-Einordnung»</strong> finden Sie hier früher im eigenen Reiter «Korrelation» (jetzt Data-Layer-Bundle).
</p>
)}
{display.show_goals_strip && <NutritionGoalsStrip grouped={groupedGoals} />}
{kpiTilesShown.length > 0 && <KpiTilesOverview tiles={kpiTilesShown} />}
{display.show_kcal_vs_weight && <KcalVsWeightChart vizKcalWeight={viz.kcal_vs_weight} chartHeight={chartHMain} />}
{display.show_calorie_balance_chart && balDaily.length > 0 && tdeeRef != null && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Kalorienbilanz (Aufnahme TDEE ~{Math.round(tdeeRef)} kcal)
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Tagesbilanz und 7-Tage-Mittel gleiche TDEE-Quelle wie «Kalorien vs. Gewicht» (Data-Layer).
</div>
<ChartFrame heightPx={chartHBal}>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={balDaily.map((d) => ({ ...d, date: fmtDate(d.date) }))}
margin={{ top: 4, right: 8, bottom: 0, left: -16 }}
>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(balDaily.length / 6) - 1)} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<ReferenceLine y={0} stroke="var(--text3)" strokeWidth={1.5} />
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, name) => [`${v > 0 ? '+' : ''}${v} kcal`, name === 'balance_kcal_avg' ? 'Ø 7T Bilanz' : 'Tagesbilanz']}
/>
<Line type="monotone" dataKey="balance_kcal" stroke="#EF9F2744" strokeWidth={1} dot={false} name="balance_kcal" />
<Line type="monotone" dataKey="balance_kcal_avg" stroke="#EF9F27" strokeWidth={2.5} dot={false} name="balance_kcal_avg" />
</LineChart>
</ResponsiveContainer>
</ChartFrame>
</div>
)}
{display.show_protein_lean_chart && plmPts.length >= 3 && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Protein vs. Magermasse (Caliper, forward-filled)
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Zweitachsig; gestrichelte Linie = Protein-Minimum ({plm.protein_target_low_g || ptLow || '—'} g), wenn verfügbar.
</div>
<ChartFrame heightPx={chartHPlm}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={plmPts.map((d) => ({ ...d, date: fmtDate(d.date), protein: d.protein_g, lean: d.lean_mass_kg }))} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis yAxisId="prot" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<YAxis yAxisId="lean" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
{plm.protein_target_low_g > 0 && (
<ReferenceLine
yAxisId="prot"
y={plm.protein_target_low_g}
stroke="#1D9E75"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{ value: `${plm.protein_target_low_g}g`, fontSize: 9, fill: '#1D9E75', position: 'right' }}
/>
)}
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, name) => [`${v}${name === 'protein' ? 'g' : ' kg'}`, name === 'protein' ? 'Protein' : 'Mager']}
/>
<Line yAxisId="prot" type="monotone" dataKey="protein" stroke="#1D9E75" strokeWidth={2} dot={false} name="protein" />
<Line yAxisId="lean" type="monotone" dataKey="lean" stroke="#7F77DD" strokeWidth={2} dot={{ r: 3, fill: '#7F77DD' }} name="lean" />
</LineChart>
</ResponsiveContainer>
</ChartFrame>
</div>
)}
{display.show_heuristics && nutHeur.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Ernährung Kurz-Einordnung</div>
{nutHeur.map((item, i) => (
<div
key={i}
style={{
padding: '10px 12px',
borderRadius: 8,
marginBottom: 6,
background: item.status === 'good' ? 'var(--accent-light)' : 'var(--warn-bg)',
border: `1px solid ${item.status === 'good' ? 'var(--accent)' : 'var(--warn)'}33`,
}}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<span style={{ fontSize: 16 }}>{item.icon || '•'}</span>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{item.title}</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginTop: 3, lineHeight: 1.5 }}>{item.detail}</div>
</div>
</div>
</div>
))}
</div>
)}
{display.show_macro_daily_bars && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Makroverteilung täglich (g) · Fokus Protein
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow || '—'} g) nach 1,6 g/kg (Referenzgewicht).
</div>
<ChartFrame heightPx={chartHMain}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={cdMacro} margin={{ top: 6, right: 8, bottom: 0, left: -18 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(cdMacro.length / 6) - 1)} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
{ptLow > 0 && (
<ReferenceLine y={ptLow} stroke={MACRO_CHART.protein} strokeDasharray="5 4" strokeWidth={2} label={{ value: `${ptLow}g P`, fontSize: 9, fill: MACRO_CHART.protein, position: 'insideTopRight' }} />
)}
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, name) => [`${v}g`, name]} />
<Bar dataKey="Protein" stackId="a" fill={MACRO_CHART.protein} name="Protein" />
<Bar dataKey="Fett" stackId="a" fill={MACRO_CHART.fat} name="Fett" />
<Bar dataKey="KH" stackId="a" fill={MACRO_CHART.carbs} name="KH" radius={[5, 5, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartFrame>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 8, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
<span><span style={{ display: 'inline-block', width: 10, height: 10, background: MACRO_CHART.protein, borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />Protein (unten)</span>
<span><span style={{ display: 'inline-block', width: 10, height: 10, background: MACRO_CHART.fat, borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />Fett (Mitte)</span>
<span><span style={{ display: 'inline-block', width: 10, height: 10, background: MACRO_CHART.carbs, borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />KH (oben)</span>
</div>
</div>
)}
{display.show_macro_distribution_pair && (
<div className="nutrition-macro-pair">
<div className="card nutrition-macro-pair__donut">
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
Ø Makro-Quote ({n} Tage)
</div>
{pieData.length > 0 ? (
<div className="nutrition-macro-pair__donut-inner">
<div className="nutrition-macro-pair__donut-chart">
<ChartFrame heightPx={NUTRITION_MACRO_CHART_BLOCK_PX}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius="38%"
outerRadius="58%"
dataKey="value"
startAngle={90}
endAngle={-270}
paddingAngle={1}
>
{pieData.map((e, i) => (
<Cell key={i} fill={macroFillByName(e.name)} />
))}
</Pie>
<Tooltip formatter={(v, name) => [`${v}%`, name]} />
</PieChart>
</ResponsiveContainer>
</ChartFrame>
</div>
<div className="nutrition-macro-pair__legend">
{pieData.map((p) => {
const fill = macroFillByName(p.name)
return (
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div style={{ width: 10, height: 10, borderRadius: 2, background: fill, flexShrink: 0 }} />
<div style={{ flex: 1, fontSize: 13 }}>{p.name}</div>
<div style={{ fontSize: 13, fontWeight: 600, color: fill }}>{p.value}%</div>
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
{p.grams != null ? `${p.grams}g` : '—'}
</div>
</div>
)
})}
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--text3)', borderTop: '1px solid var(--border)', paddingTop: 8 }}>
Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz
</div>
</div>
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Makro-Mittelwerte im Zeitraum.</div>
)}
</div>
<div className="card nutrition-macro-pair__weekly">
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 4 }}>
Wöchentliche Makro-Verteilung (Backend)
</div>
<WeeklyMacroDistributionPanel macroWeeklyData={weeklyMacro} loading={false} error={null} />
</div>
</div>
)}
{display.show_energy_protein_charts && (
<>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 4 }}>
Zeitverläufe (Energie & Protein)
</div>
<NutritionCharts
days={chartDays}
showWeeklyMacroDistribution={false}
hideEnergyAvailabilityCard
prefetchedChartPayloads={viz.chart_payloads}
/>
</>
)}
{footer}
</div>
)
}

View File

@ -12,6 +12,7 @@ import {
import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor' import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor'
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
import { import {
moveWidget, moveWidget,
moveWidgetToIndex, moveWidgetToIndex,
@ -24,6 +25,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([
'body_history_viz', 'body_history_viz',
'activity_overview', 'activity_overview',
'nutrition_detail_charts', 'nutrition_detail_charts',
'nutrition_history_viz',
'recovery_charts_panel', 'recovery_charts_panel',
]) ])
@ -514,6 +516,23 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) {
} }
/> />
)} )}
{w.id === 'nutrition_history_viz' && (
<NutritionHistoryVizConfigEditor
config={w.config || {}}
onChange={(next) =>
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 } }
}),
})
)
}
/>
)}
</li> </li>
) )
})} })}

View File

@ -13,6 +13,7 @@ import {
import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor' import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor'
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor' import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
/** Widgets mit optionalem config.chart_days (790), gleiche UX im Editor */ /** Widgets mit optionalem config.chart_days (790), gleiche UX im Editor */
@ -21,6 +22,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([
'body_history_viz', 'body_history_viz',
'activity_overview', 'activity_overview',
'nutrition_detail_charts', 'nutrition_detail_charts',
'nutrition_history_viz',
'recovery_charts_panel', 'recovery_charts_panel',
]) ])
@ -326,7 +328,9 @@ export default function DashboardLabPage() {
? 'Aktivität (Verteilung & Konsistenz)' ? 'Aktivität (Verteilung & Konsistenz)'
: w.id === 'nutrition_detail_charts' : w.id === 'nutrition_detail_charts'
? 'Ernährung — 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} Zeitraum (Tage): {BODY_CHART_DAYS_MIN}{BODY_CHART_DAYS_MAX}
</label> </label>
<input <input
@ -344,7 +348,9 @@ export default function DashboardLabPage() {
? 'Aktivität Zeitraum in Tagen' ? 'Aktivität Zeitraum in Tagen'
: w.id === 'nutrition_detail_charts' : w.id === 'nutrition_detail_charts'
? 'Ernährungs-Charts Zeitraum in Tagen' ? 'Ernährungs-Charts Zeitraum in Tagen'
: 'Erholungs-Charts Zeitraum in Tagen' : w.id === 'nutrition_history_viz'
? 'Ernährung Verlauf-Bundle Zeitraum in Tagen'
: 'Erholungs-Charts Zeitraum in Tagen'
} }
value={ value={
chartDaysDraftByWidgetId[w.id] !== undefined chartDaysDraftByWidgetId[w.id] !== undefined
@ -397,6 +403,23 @@ export default function DashboardLabPage() {
} }
/> />
)} )}
{w.id === 'nutrition_history_viz' && (
<NutritionHistoryVizConfigEditor
config={w.config || {}}
onChange={(next) =>
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 } }
}),
})
)
}
/>
)}
</li> </li>
) )
})} })}

View File

@ -4,27 +4,23 @@ import { useProfile } from '../context/ProfileContext'
import { import {
LineChart, Line, BarChart, Bar, LineChart, Line, BarChart, Bar,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
ReferenceLine, PieChart, Pie, Cell, ComposedChart, ReferenceLine, Cell, ComposedChart,
ScatterChart, Scatter, ScatterChart, Scatter,
} from 'recharts' } 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 { api } from '../utils/api'
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay' import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
import { getStatusColor, getStatusBg } from '../utils/interpret' import { getStatusColor, getStatusBg } from '../utils/interpret'
import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme'
import Markdown from '../utils/Markdown' import Markdown from '../utils/Markdown'
import FitnessDashboardOverview from '../components/FitnessDashboardOverview' import FitnessDashboardOverview from '../components/FitnessDashboardOverview'
import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts'
import RecoveryDashboardOverview from '../components/RecoveryDashboardOverview' import RecoveryDashboardOverview from '../components/RecoveryDashboardOverview'
import KpiTilesOverview from '../components/KpiTilesOverview'
import BodyHistoryVizSection from '../components/history/BodyHistoryVizSection' import BodyHistoryVizSection from '../components/history/BodyHistoryVizSection'
import NutritionHistoryVizSection from '../components/history/NutritionHistoryVizSection'
import { EmptySection, NavToCaliper, NavToCircum, PeriodSelector, SectionHeader } from '../components/history/historyPageChrome' import { EmptySection, NavToCaliper, NavToCircum, PeriodSelector, SectionHeader } from '../components/history/historyPageChrome'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import 'dayjs/locale/de' import 'dayjs/locale/de'
dayjs.locale('de') dayjs.locale('de')
const fmtDate = d => dayjs(d).format('DD.MM')
function RuleCard({ item }) { function RuleCard({ item }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const color = getStatusColor(item.status) 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 (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Ernährungsbezogene Ziele</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/goals')}>
Ziele <ChevronRight size={10} />
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{goals.map(g => (
<div
key={g.id}
style={{
flex: '1 1 140px',
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
borderTop: `3px solid ${g.is_primary ? 'var(--accent)' : 'var(--border2)'}`,
}}
>
<div style={{
fontSize: 11, fontWeight: 600, color: 'var(--text2)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{g.name || g.label_de || g.goal_type}</div>
<div style={{ marginTop: 4, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{
width: `${Math.min(100, Math.max(0, g.progress_pct ?? 0))}%`,
height: '100%',
background: 'var(--accent)',
}} />
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''}
</div>
</div>
))}
</div>
</div>
)
}
function InsightBox({ insights, slugs, onRequest, loading }) { function InsightBox({ insights, slugs, onRequest, loading }) {
const [expanded, setExpanded] = useState(null) const [expanded, setExpanded] = useState(null)
const relevant = insights?.filter(i=>slugs.includes(i.scope))||[] 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 (
<div
className="kcal-vs-weight-legend"
style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'center',
gap: '12px 18px',
marginTop: 10,
fontSize: 10,
color: 'var(--text2)',
lineHeight: 1.35,
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<span style={line('#EA580C')} />
Ø Kalorien (7-Tage-Mittel)
</span>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<span
style={{
display: 'inline-block',
width: 9,
height: 9,
borderRadius: '50%',
background: '#2563EB',
marginRight: 6,
verticalAlign: 'middle',
}}
/>
Gewicht (kg)
</span>
{showTdee ? (
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<span
style={{
display: 'inline-block',
width: 22,
height: 0,
verticalAlign: 'middle',
marginRight: 6,
borderTop: `2px dashed ${TDEE_REF_LINE_COLOR}`,
opacity: 0.95,
}}
/>
TDEE-Referenz (geschätzt)
</span>
) : null}
</div>
)
}
/** 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 (
<div className="card" style={{ marginBottom: 12, padding: '12px 14px' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Kalorien (Ø 7 Tage) vs. Gewicht
</div>
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45 }}>
Für dieses Diagramm werden mindestens 5 Tage mit Kalorien- und Gewichtsdaten benötigt ({n} im Zeitraum).
</div>
</div>
)
}
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 (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Kalorien (Ø 7 Tage) vs. Gewicht
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg.
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={kcalVsW} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(kcalVsW.length / 6) - 1)} />
<YAxis yAxisId="kcal" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={kcalDomain} />
<YAxis yAxisId="weight" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, name) => [`${Math.round(v)} ${name === 'weight' ? 'kg' : 'kcal'}`, name === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']}
/>
{tdeeLabel != null && (
<ReferenceLine
yAxisId="kcal"
y={tdeeLabel}
stroke={TDEE_REF_LINE_COLOR}
strokeDasharray="6 5"
strokeWidth={2}
isFront
/>
)}
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EA580C" strokeWidth={2.5} dot={false} name="kcal_avg" />
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#2563EB" strokeWidth={2.5} dot={{ r: 2, fill: '#2563EB' }} name="weight" />
</LineChart>
</ResponsiveContainer>
<KcalVsWeightLegend showTdee={tdeeLabel != null} />
<div style={{ fontSize: 10, color: 'var(--text3)', textAlign: 'center', marginTop: 8 }}>
{tdeeLabel != null
? `TDEE ~${tdeeLabel} kcal · ${commonDays} gemeinsame Tage`
: `Keine TDEE-Referenz (Gewicht/Demografie) · ${commonDays} gemeinsame Tage`}
</div>
</div>
)
}
// 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 (
<div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" />
<PeriodSelector value={period} onChange={setPeriod} />
<div className="spinner" style={{ margin: 24 }} />
</div>
)
}
if (vizErr) {
return (
<div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" />
<div className="card" style={{ color: 'var(--danger)', marginTop: 8 }}>{vizErr}</div>
</div>
)
}
if (!viz?.has_nutrition_entries) {
return (
<EmptySection text="Noch keine Ernährungsdaten." to="/nutrition" toLabel="FDDB importieren"/>
)
}
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 (
<div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={viz.last_updated} />
<PeriodSelector value={period} onChange={setPeriod}/>
<EmptySection text="Keine Einträge im gewählten Zeitraum."/>
</div>
)
}
return (
<div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={viz.last_updated}/>
<PeriodSelector value={period} onChange={setPeriod}/>
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '}
<strong>Kalorien vs. Gewicht</strong> und TDEE-Referenz entsprechen MifflinSt Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg).
{' '}
<strong>Kalorienbilanz</strong>, <strong>Protein vs. Magermasse</strong> und den Block{' '}
<strong>«Kurz-Einordnung»</strong> finden Sie hier früher im eigenen Reiter «Korrelation» (jetzt Data-Layer-Bundle).
</p>
<NutritionGoalsStrip grouped={groupedGoals} />
<KpiTilesOverview tiles={kpiTiles} />
<KcalVsWeightChart vizKcalWeight={viz.kcal_vs_weight} />
{balDaily.length > 0 && tdeeRef != null && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Kalorienbilanz (Aufnahme TDEE ~{Math.round(tdeeRef)} kcal)
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Tagesbilanz und 7-Tage-Mittel gleiche TDEE-Quelle wie «Kalorien vs. Gewicht» (Data-Layer).
</div>
<ResponsiveContainer width="100%" height={180}>
<LineChart
data={balDaily.map((d) => ({ ...d, date: fmtDate(d.date) }))}
margin={{ top: 4, right: 8, bottom: 0, left: -16 }}
>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(balDaily.length / 6) - 1)} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<ReferenceLine y={0} stroke="var(--text3)" strokeWidth={1.5} />
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, n) => [`${v > 0 ? '+' : ''}${v} kcal`, n === 'balance_kcal_avg' ? 'Ø 7T Bilanz' : 'Tagesbilanz']}
/>
<Line type="monotone" dataKey="balance_kcal" stroke="#EF9F2744" strokeWidth={1} dot={false} name="balance_kcal" />
<Line type="monotone" dataKey="balance_kcal_avg" stroke="#EF9F27" strokeWidth={2.5} dot={false} name="balance_kcal_avg" />
</LineChart>
</ResponsiveContainer>
</div>
)}
{plmPts.length >= 3 && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Protein vs. Magermasse (Caliper, forward-filled)
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Zweitachsig; gestrichelte Linie = Protein-Minimum ({plm.protein_target_low_g || ptLow || '—'} g), wenn verfügbar.
</div>
<ResponsiveContainer width="100%" height={180}>
<LineChart data={plmPts.map((d) => ({ ...d, date: fmtDate(d.date), protein: d.protein_g, lean: d.lean_mass_kg }))} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis yAxisId="prot" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<YAxis yAxisId="lean" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
{plm.protein_target_low_g > 0 && (
<ReferenceLine
yAxisId="prot"
y={plm.protein_target_low_g}
stroke="#1D9E75"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{ value: `${plm.protein_target_low_g}g`, fontSize: 9, fill: '#1D9E75', position: 'right' }}
/>
)}
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, n) => [`${v}${n === 'protein' ? 'g' : ' kg'}`, n === 'protein' ? 'Protein' : 'Mager']}
/>
<Line yAxisId="prot" type="monotone" dataKey="protein" stroke="#1D9E75" strokeWidth={2} dot={false} name="protein" />
<Line yAxisId="lean" type="monotone" dataKey="lean" stroke="#7F77DD" strokeWidth={2} dot={{ r: 3, fill: '#7F77DD' }} name="lean" />
</LineChart>
</ResponsiveContainer>
</div>
)}
{nutHeur.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Ernährung Kurz-Einordnung</div>
{nutHeur.map((item, i) => (
<div
key={i}
style={{
padding: '10px 12px',
borderRadius: 8,
marginBottom: 6,
background: item.status === 'good' ? 'var(--accent-light)' : 'var(--warn-bg)',
border: `1px solid ${item.status === 'good' ? 'var(--accent)' : 'var(--warn)'}33`,
}}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<span style={{ fontSize: 16 }}>{item.icon || '•'}</span>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{item.title}</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginTop: 3, lineHeight: 1.5 }}>{item.detail}</div>
</div>
</div>
</div>
))}
</div>
)}
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Makroverteilung täglich (g) · Fokus Protein
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow || '—'} g) nach 1,6 g/kg (Referenzgewicht).
</div>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={cdMacro} margin={{ top: 6, right: 8, bottom: 0, left: -18 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(cdMacro.length / 6) - 1)} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
{ptLow > 0 && (
<ReferenceLine y={ptLow} stroke={MACRO_CHART.protein} strokeDasharray="5 4" strokeWidth={2} label={{ value: `${ptLow}g P`, fontSize: 9, fill: MACRO_CHART.protein, position: 'insideTopRight' }} />
)}
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, name) => [`${v}g`, name]} />
<Bar dataKey="Protein" stackId="a" fill={MACRO_CHART.protein} name="Protein" />
<Bar dataKey="Fett" stackId="a" fill={MACRO_CHART.fat} name="Fett" />
<Bar dataKey="KH" stackId="a" fill={MACRO_CHART.carbs} name="KH" radius={[5, 5, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 8, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
<span><span style={{ display: 'inline-block', width: 10, height: 10, background: MACRO_CHART.protein, borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />Protein (unten)</span>
<span><span style={{ display: 'inline-block', width: 10, height: 10, background: MACRO_CHART.fat, borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />Fett (Mitte)</span>
<span><span style={{ display: 'inline-block', width: 10, height: 10, background: MACRO_CHART.carbs, borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />KH (oben)</span>
</div>
</div>
<div className="nutrition-macro-pair">
<div className="card nutrition-macro-pair__donut">
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
Ø Makro-Quote ({n} Tage)
</div>
{pieData.length > 0 ? (
<div className="nutrition-macro-pair__donut-inner">
<div className="nutrition-macro-pair__donut-chart">
<ResponsiveContainer width="100%" height={NUTRITION_MACRO_CHART_BLOCK_PX}>
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius="38%"
outerRadius="58%"
dataKey="value"
startAngle={90}
endAngle={-270}
paddingAngle={1}
>
{pieData.map((e, i) => (
<Cell key={i} fill={macroFillByName(e.name)} />
))}
</Pie>
<Tooltip formatter={(v, name) => [`${v}%`, name]} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="nutrition-macro-pair__legend">
{pieData.map(p => {
const fill = macroFillByName(p.name)
return (
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div style={{ width: 10, height: 10, borderRadius: 2, background: fill, flexShrink: 0 }} />
<div style={{ flex: 1, fontSize: 13 }}>{p.name}</div>
<div style={{ fontSize: 13, fontWeight: 600, color: fill }}>{p.value}%</div>
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
{p.grams != null ? `${p.grams}g` : '—'}
</div>
</div>
)
})}
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--text3)', borderTop: '1px solid var(--border)', paddingTop: 8 }}>
Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz
</div>
</div>
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Makro-Mittelwerte im Zeitraum.</div>
)}
</div>
<div className="card nutrition-macro-pair__weekly">
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 4 }}>
Wöchentliche Makro-Verteilung (Backend)
</div>
<WeeklyMacroDistributionPanel macroWeeklyData={weeklyMacro} loading={wmLoading} error={wmError} />
</div>
</div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 4 }}>
Zeitverläufe (Energie & Protein)
</div>
<NutritionCharts
days={chartDays}
showWeeklyMacroDistribution={false}
hideEnergyAvailabilityCard
prefetchedChartPayloads={viz.chart_payloads}
/>
<InsightBox insights={insights} slugs={filterActiveSlugs(['ernaehrung'])} onRequest={onRequest} loading={loadingSlug}/>
</div>
)
}
// Activity Section nur Layer-2b-Bundle (+ KI-Insights), keine parallelen Client-Charts // Activity Section nur Layer-2b-Bundle (+ KI-Insights), keine parallelen Client-Charts
function ActivitySection({ activityLastDate, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) { function ActivitySection({ activityLastDate, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) {
const [period, setPeriod] = useState(30) const [period, setPeriod] = useState(30)
@ -1316,7 +814,18 @@ export default function History() {
)} )}
/> />
)} )}
{tab==='nutrition' && <NutritionSection {...sp}/>} {tab === 'nutrition' && (
<NutritionHistoryVizSection
footer={(
<InsightBox
insights={insights}
slugs={filterActiveSlugs(['ernaehrung'])}
onRequest={requestInsight}
loading={loadingSlug}
/>
)}
/>
)}
{tab==='activity' && <ActivitySection activityLastDate={activityLastDate} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>} {tab==='activity' && <ActivitySection activityLastDate={activityLastDate} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
{tab==='photos' && <PhotoGrid/>} {tab==='photos' && <PhotoGrid/>}
</div> </div>

View File

@ -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<string, unknown>, onChange: (next: Record<string, unknown>) => 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 (
<div style={{ marginTop: 10, marginLeft: 28 }}>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8, lineHeight: 1.5 }}>
<strong>Ernährung (Verlauf-Bundle):</strong> welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker
Standard (KPI kompakt, kcal vs. Gewicht, Makro-Balken + Donut/Woche).
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>KPI-Umfang</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 10, cursor: 'pointer' }}>
<input
type="radio"
name="nutrition_hist_kpi_detail"
checked={merged.kpi_detail === 'compact'}
onChange={() => patch({ kpi_detail: 'compact' })}
/>
<span>Kompakt (erste 4 Kacheln)</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 12, cursor: 'pointer' }}>
<input
type="radio"
name="nutrition_hist_kpi_detail"
checked={merged.kpi_detail === 'full'}
onChange={() => patch({ kpi_detail: 'full' })}
/>
<span>Voll (wie Verlauf alle Kacheln)</span>
</label>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>Bereiche</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{OTHER_TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
<span>{label}</span>
</label>
))}
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', margin: '12px 0 6px' }}>Charts</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{CHART_TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
<span>{label}</span>
</label>
))}
</div>
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: 10, fontSize: 12, padding: '6px 12px' }}
onClick={() => onChange({})}
>
Auf schlanken Standard zurück
</button>
</div>
)
}

View File

@ -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<string, unknown>|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<object>} kpiTiles
* @param {'compact'|'full'} detail
*/
export function filterNutritionHistoryKpiTiles(kpiTiles, detail) {
if (detail === 'full' || !Array.isArray(kpiTiles)) return kpiTiles
return kpiTiles.slice(0, 4)
}

View File

@ -15,7 +15,9 @@ import TrendKcalWeightWidget from '../components/dashboard-widgets/TrendKcalWeig
import NutritionActivitySummaryWidget from '../components/dashboard-widgets/NutritionActivitySummaryWidget' import NutritionActivitySummaryWidget from '../components/dashboard-widgets/NutritionActivitySummaryWidget'
import NutritionDetailChartsWidget from '../components/dashboard-widgets/NutritionDetailChartsWidget' import NutritionDetailChartsWidget from '../components/dashboard-widgets/NutritionDetailChartsWidget'
import BodyHistoryVizWidget from '../components/dashboard-widgets/BodyHistoryVizWidget' import BodyHistoryVizWidget from '../components/dashboard-widgets/BodyHistoryVizWidget'
import NutritionHistoryVizWidget from '../components/dashboard-widgets/NutritionHistoryVizWidget'
import { normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig' import { normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig'
import { normalizeNutritionHistoryVizConfig } from './nutritionHistoryVizConfig'
import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget' import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget'
import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget' import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget'
import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget' import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget'
@ -122,6 +124,14 @@ export function ensurePilotLabWidgetsRegistered() {
chartDays: ctx.layoutEntry?.config?.chart_days, chartDays: ctx.layoutEntry?.config?.chart_days,
}), }),
}) })
registerDashboardWidget({
id: 'nutrition_history_viz',
Component: NutritionHistoryVizWidget,
mapProps: (ctx) => ({
refreshTick: ctx.refreshTick,
nutritionHistoryVizConfig: normalizeNutritionHistoryVizConfig(ctx.layoutEntry?.config),
}),
})
registerDashboardWidget({ registerDashboardWidget({
id: 'recovery_charts_panel', id: 'recovery_charts_panel',
Component: RecoveryChartsPanelWidget, Component: RecoveryChartsPanelWidget,