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({
|
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
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,12 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
||||||
"description": "Phase-0c NutritionCharts (optional chart_days 7–90, Default 30); Feature nutrition_entries",
|
"description": "Phase-0c NutritionCharts (optional chart_days 7–90, 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 7–90; Feature nutrition_entries",
|
||||||
|
"requires_feature": "nutrition_entries",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "recovery_charts_panel",
|
"id": "recovery_charts_panel",
|
||||||
"title": "Erholung — Charts R1–R5",
|
"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 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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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 (7–90), gleiche UX im Editor */
|
/** 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',
|
'body_history_viz',
|
||||||
'activity_overview',
|
'activity_overview',
|
||||||
'nutrition_detail_charts',
|
'nutrition_detail_charts',
|
||||||
|
'nutrition_history_viz',
|
||||||
'recovery_charts_panel',
|
'recovery_charts_panel',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
@ -326,6 +328,8 @@ 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'
|
||||||
|
: w.id === 'nutrition_history_viz'
|
||||||
|
? 'Ernährung (Verlauf-Bundle)'
|
||||||
: 'Erholung — Charts'}{' '}
|
: 'Erholung — Charts'}{' '}
|
||||||
— Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX}
|
— Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -344,6 +348,8 @@ 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'
|
||||||
|
: w.id === 'nutrition_history_viz'
|
||||||
|
? 'Ernährung Verlauf-Bundle Zeitraum in Tagen'
|
||||||
: 'Erholungs-Charts Zeitraum in Tagen'
|
: 'Erholungs-Charts Zeitraum in Tagen'
|
||||||
}
|
}
|
||||||
value={
|
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>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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 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 ─
|
// ── 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>
|
||||||
|
|
|
||||||
|
|
@ -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 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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user