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.
This commit is contained in:
parent
20f195aca1
commit
db5557e4aa
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
608
frontend/src/components/history/NutritionHistoryVizSection.jsx
Normal file
608
frontend/src/components/history/NutritionHistoryVizSection.jsx
Normal 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 (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 (
|
||||
<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 Mifflin–St 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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' && (
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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,6 +328,8 @@ export default function DashboardLabPage() {
|
|||
? 'Aktivität (Verteilung & Konsistenz)'
|
||||
: w.id === 'nutrition_detail_charts'
|
||||
? 'Ernährung — Charts'
|
||||
: w.id === 'nutrition_history_viz'
|
||||
? 'Ernährung (Verlauf-Bundle)'
|
||||
: 'Erholung — Charts'}{' '}
|
||||
— Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX}
|
||||
</label>
|
||||
|
|
@ -344,6 +348,8 @@ export default function DashboardLabPage() {
|
|||
? 'Aktivität Zeitraum in Tagen'
|
||||
: w.id === 'nutrition_detail_charts'
|
||||
? 'Ernährungs-Charts Zeitraum in Tagen'
|
||||
: w.id === 'nutrition_history_viz'
|
||||
? 'Ernährung Verlauf-Bundle Zeitraum in Tagen'
|
||||
: 'Erholungs-Charts Zeitraum in Tagen'
|
||||
}
|
||||
value={
|
||||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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 }) {
|
||||
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 (
|
||||
<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 Mifflin–St 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 ─
|
||||
function ActivitySection({ activityLastDate, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) {
|
||||
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==='photos' && <PhotoGrid/>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
79
frontend/src/widgetSystem/nutritionHistoryVizConfig.js
Normal file
79
frontend/src/widgetSystem/nutritionHistoryVizConfig.js
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user