feat: implement energy availability warning and enhance nutrition visualization
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- Added `get_energy_availability_warning_payload` function to assess energy availability and provide contextual warnings based on multiple health indicators.
- Integrated energy availability KPI tile into the nutrition history visualization, enhancing user insights on energy balance.
- Updated frontend components to conditionally display the energy availability warning, improving user experience and data interpretation.
- Refactored existing logic in `charts.py` to utilize the new energy availability functionality, streamlining data handling.
This commit is contained in:
Lars 2026-04-19 17:43:29 +02:00
parent fc816da335
commit d7304c1a44
8 changed files with 182 additions and 102 deletions

View File

@ -121,8 +121,7 @@ def build_nutrition_history_kpi_tiles(
"status": "bad",
"verdict": _verdict("bad"),
"hint": (
f"Es fehlen rund {miss} g Protein pro Tag bei Kaloriendefizit "
"steigt das Risiko für Muskelerhalt."
f"~{miss} g Protein/Tag fehlen bei Defizit Muskelerhalt gefährdet."
),
"hoverTop": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}{pt_high}g)",
"hoverBody": (
@ -159,8 +158,8 @@ def build_nutrition_history_kpi_tiles(
"status": "warn",
"verdict": _verdict("warn"),
"hint": (
f"Viele Kalorien kommen aus KH/Fett; Proteinanteil oft sinnvoll bei 2535 % "
f"(aktuell P {prot_pct} % / KH {kh_pct} % / F {fat_pct} %)."
f"Protein-Kalorienanteil niedrig (P {prot_pct} % / KH {kh_pct} % / F {fat_pct} %); "
"Ziel oft 2535 %."
),
"hoverTop": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien",
"hoverBody": (
@ -173,6 +172,37 @@ def build_nutrition_history_kpi_tiles(
return tiles
def build_energy_availability_kpi_tile(ea: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""E5: nur bei caution/warning — gleiche Daten wie /charts/energy-availability-warning."""
level = str(ea.get("warning_level") or "none").strip().lower()
if level == "none":
return None
triggers: List[str] = list(ea.get("triggers") or [])
msg = str(ea.get("message") or "").strip()
st = "bad" if level == "warning" else "warn"
first = triggers[0] if triggers else msg
if len(first) > 90:
first = first[:87] + ""
meta = ea.get("metadata") if isinstance(ea.get("metadata"), dict) else {}
note = str(meta.get("note") or "")
hover_lines = [msg] + [f"{t}" for t in triggers]
if note:
hover_lines.append(note)
return {
"key": "energy-availability-e5",
"category": "Energieverfügbarkeit",
"icon": "",
"value": "Achtung" if level == "warning" else "Hinweis",
"sublabel": first or "Signale prüfen",
"status": st,
"verdict": _verdict(st),
"hint": msg,
"hoverTop": "Energieverfügbarkeit (Heuristik)",
"hoverBody": "\n".join(hover_lines),
"keys": ["nutrition_score"],
}
def build_macro_donut_from_averages(navg: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
"""Anteile in % der Makro-kcal + Gramm für Legende."""
p = float(navg.get("protein_avg") or 0)

View File

@ -699,6 +699,70 @@ def get_weekly_macro_distribution_chart_data(profile_id: str, weeks: int) -> Dic
}
def get_energy_availability_warning_payload(profile_id: str, days: int = 14) -> Dict:
"""
E5 Energieverfügbarkeit gleiche Heuristik wie GET /charts/energy-availability-warning.
"""
from data_layer.recovery_metrics import calculate_recovery_score_v2, calculate_sleep_quality_7d
from data_layer.body_metrics import calculate_lbm_28d_change
triggers: List[str] = []
warning_level = "none"
energy_data = get_energy_balance_data(profile_id, days)
if energy_data.get("energy_balance", 0) < -500:
triggers.append("Großes Energiedefizit (>500 kcal/Tag)")
try:
recovery_score = calculate_recovery_score_v2(profile_id)
if recovery_score and recovery_score < 50:
triggers.append("Recovery Score niedrig (<50)")
except Exception:
pass
try:
sleep_quality = calculate_sleep_quality_7d(profile_id)
if sleep_quality and sleep_quality < 60:
triggers.append("Schlafqualität reduziert (<60%)")
except Exception:
pass
try:
lbm_change = calculate_lbm_28d_change(profile_id)
if lbm_change and lbm_change < -1.0:
triggers.append("Magermasse sinkt (-{:.1f} kg)".format(abs(lbm_change)))
except Exception:
pass
if len(triggers) >= 3:
warning_level = "warning"
message = (
"⚠️ Hinweis auf mögliche Unterversorgung. Mehrere Indikatoren auffällig. "
"Erwäge Defizit-Anpassung oder Regenerationswoche."
)
elif len(triggers) >= 2:
warning_level = "caution"
message = (
"⚡ Beobachte folgende Signale genau. Aktuell noch kein Handlungsbedarf, aber Trend beachten."
)
elif len(triggers) >= 1:
warning_level = "caution"
message = "💡 Ein Indikator auffällig. Weiter beobachten."
else:
message = "✅ Energieverfügbarkeit unauffällig."
return {
"warning_level": warning_level,
"triggers": triggers,
"message": message,
"metadata": {
"days_analyzed": days,
"trigger_count": len(triggers),
"note": "Heuristische Einschätzung, keine medizinische Diagnose",
},
}
# ============================================================================
# Calculated Metrics (migrated from calculations/nutrition_metrics.py)
# ============================================================================

View File

@ -11,11 +11,13 @@ from typing import Any, Dict, List, Optional
from db import get_db, get_cursor, r2d
from data_layer.nutrition_interpretation import (
build_energy_availability_kpi_tile,
build_macro_donut_from_averages,
build_nutrition_history_kpi_tiles,
)
from data_layer.nutrition_metrics import (
estimate_tdee_kcal_from_latest_weight,
get_energy_availability_warning_payload,
get_energy_balance_data,
get_nutrition_average_data,
get_protein_targets_data,
@ -184,12 +186,14 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An
"tdee_reference_kcal": None,
"energy_balance_meta": {},
"interpretation_tiles": [],
"energy_availability_warning": None,
"meta": {"layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz"},
}
all_history = days >= 9999
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
cutoff = _cutoff_sql(days)
chart_days_for_pipeline = 90 if all_history else max(7, min(eff_days, 365))
navg = get_nutrition_average_data(profile_id, eff_days, all_history=all_history)
targets = get_protein_targets_data(profile_id)
@ -223,12 +227,18 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An
navg, targets, date_span_label or "", max(1, n_days)
)
ea_days = min(28, max(7, chart_days_for_pipeline))
ea_payload = get_energy_availability_warning_payload(profile_id, ea_days)
ea_tile = build_energy_availability_kpi_tile(ea_payload)
kpi_tiles_out: List[Dict[str, Any]] = list(kpi_tiles)
if ea_tile:
kpi_tiles_out.append(ea_tile)
donut = build_macro_donut_from_averages(navg)
kw_points = _kcal_weight_points_for_window(profile_id, cutoff)
pt_low = round(float(targets.get("protein_target_low") or 0))
chart_days_for_pipeline = 90 if all_history else max(7, min(eff_days, 365))
weeks_for_weekly = max(4, min(52, (chart_days_for_pipeline + 6) // 7))
weekly_chart = get_weekly_macro_distribution_chart_data(profile_id, weeks_for_weekly)
@ -255,8 +265,9 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An
"protein_target_high": targets.get("protein_target_high"),
"reference_weight_kg": targets.get("current_weight"),
},
"kpi_tiles": kpi_tiles,
"kpi_tiles": kpi_tiles_out,
"interpretation_tiles": [],
"energy_availability_warning": ea_payload,
"daily_macros": daily_macros,
"donut_avg_pct": donut,
"protein_reference_line_g": pt_low,

View File

@ -40,6 +40,7 @@ from data_layer.nutrition_metrics import (
get_macro_consistency_data,
get_energy_balance_data,
get_weekly_macro_distribution_chart_data,
get_energy_availability_warning_payload,
)
from data_layer.activity_metrics import (
get_activity_summary_data,
@ -1026,87 +1027,10 @@ def get_energy_availability_warning(
"""
Energy Availability Warning (E5) - Konzept-konform.
Heuristic warning for potential undernutrition/overtraining.
Checks:
- Persistent large deficit
- Recovery score declining
- Sleep quality declining
- LBM declining
Args:
days: Analysis window (7-28 days, default 14)
session: Auth session (injected)
Returns:
{
"warning_level": "none" | "caution" | "warning",
"triggers": [...],
"message": "..."
}
Datenberechnung: data_layer.nutrition_metrics.get_energy_availability_warning_payload
"""
profile_id = session['profile_id']
from db import get_db, get_cursor
from data_layer.nutrition_metrics import get_energy_balance_data
from data_layer.recovery_metrics import calculate_recovery_score_v2, calculate_sleep_quality_7d
from data_layer.body_metrics import calculate_lbm_28d_change
triggers = []
warning_level = "none"
# Check 1: Large energy deficit
energy_data = get_energy_balance_data(profile_id, days)
if energy_data.get('energy_balance', 0) < -500:
triggers.append("Großes Energiedefizit (>500 kcal/Tag)")
# Check 2: Recovery declining
try:
recovery_score = calculate_recovery_score_v2(profile_id)
if recovery_score and recovery_score < 50:
triggers.append("Recovery Score niedrig (<50)")
except:
pass
# Check 3: Sleep quality
try:
sleep_quality = calculate_sleep_quality_7d(profile_id)
if sleep_quality and sleep_quality < 60:
triggers.append("Schlafqualität reduziert (<60%)")
except:
pass
# Check 4: LBM declining
try:
lbm_change = calculate_lbm_28d_change(profile_id)
if lbm_change and lbm_change < -1.0:
triggers.append("Magermasse sinkt (-{:.1f} kg)".format(abs(lbm_change)))
except:
pass
# Determine warning level
if len(triggers) >= 3:
warning_level = "warning"
message = "⚠️ Hinweis auf mögliche Unterversorgung. Mehrere Indikatoren auffällig. Erwäge Defizit-Anpassung oder Regenerationswoche."
elif len(triggers) >= 2:
warning_level = "caution"
message = "⚡ Beobachte folgende Signale genau. Aktuell noch kein Handlungsbedarf, aber Trend beachten."
elif len(triggers) >= 1:
warning_level = "caution"
message = "💡 Ein Indikator auffällig. Weiter beobachten."
else:
message = "✅ Energieverfügbarkeit unauffällig."
return {
"warning_level": warning_level,
"triggers": triggers,
"message": message,
"metadata": {
"days_analyzed": days,
"trigger_count": len(triggers),
"note": "Heuristische Einschätzung, keine medizinische Diagnose"
}
}
return get_energy_availability_warning_payload(profile_id, days)
@router.get("/training-volume")

View File

@ -350,6 +350,11 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
font-style: italic;
}
/* KPI: Kurz-Hinweis max. 2 Zeilen — Details weiter per */
.kpi-tiles-card__hint {
max-height: 2.8em;
}
/* Verlauf Ernährung: Donut (Ø-Quote) + wöchentliche Makro-Verteilung (E3) */
.nutrition-macro-pair {
display: grid;

View File

@ -125,14 +125,17 @@ export default function KpiTilesOverview({
<div
className="kpi-tiles-card__hint"
style={{
marginTop: 10,
padding: '8px 10px',
borderRadius: 8,
fontSize: 10,
lineHeight: 1.45,
marginTop: 6,
paddingLeft: 8,
borderLeft: `2px solid ${accent}`,
fontSize: 9,
lineHeight: 1.35,
color: 'var(--text2)',
background: 'var(--surface2)',
borderLeft: `3px solid ${accent}`,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-word',
}}
>
{cardHint}

View File

@ -206,7 +206,12 @@ export function WeeklyMacroDistributionPanel({ macroWeeklyData, loading, error }
/**
* Nutrition Charts (E1E5). Verlauf: `showWeeklyMacroDistribution={false}` wenn E3 separat (z.B. neben Donut) gerendert wird.
*/
export default function NutritionCharts({ days = 28, showWeeklyMacroDistribution = true }) {
export default function NutritionCharts({
days = 28,
showWeeklyMacroDistribution = true,
/** Verlauf: E5-Kachel liegt in nutrition-history-viz KPIs — doppelte Karte ausblenden */
hideEnergyAvailabilityCard = false,
}) {
const [energyData, setEnergyData] = useState(null)
const [proteinData, setProteinData] = useState(null)
const [macroWeeklyData, setMacroWeeklyData] = useState(null)
@ -221,15 +226,17 @@ export default function NutritionCharts({ days = 28, showWeeklyMacroDistribution
useEffect(() => {
loadCharts()
}, [days, showWeeklyMacroDistribution])
}, [days, showWeeklyMacroDistribution, hideEnergyAvailabilityCard])
const loadCharts = async () => {
const tasks = [
loadEnergyBalance(),
loadProteinAdequacy(),
loadAdherence(),
loadWarning(),
]
if (!hideEnergyAvailabilityCard) {
tasks.push(loadWarning())
}
if (showWeeklyMacroDistribution) {
tasks.splice(2, 0, loadMacroWeekly())
}
@ -474,7 +481,7 @@ export default function NutritionCharts({ days = 28, showWeeklyMacroDistribution
)}
{!loading.adherence && !errors.adherence && renderAdherence()}
{!loading.warning && !errors.warning && renderWarning()}
{!hideEnergyAvailabilityCard && !loading.warning && !errors.warning && renderWarning()}
</div>
)
}

View File

@ -700,6 +700,26 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
)
}
/** TDEE-Linie muss in der kcal-Y-Domain liegen (sonst unsichtbar trotz Legende). */
function kcalVsWeightKcalDomain(points, tdeeRef) {
const vals = (points || [])
.map(d => Number(d.kcal_avg))
.filter(v => !Number.isNaN(v))
if (!vals.length) return ['auto', 'auto']
let lo = Math.min(...vals)
let hi = Math.max(...vals)
const t = tdeeRef != null ? Number(tdeeRef) : NaN
if (!Number.isNaN(t)) {
lo = Math.min(lo, t)
hi = Math.max(hi, t)
}
const span = hi - lo || 400
const pad = Math.max(100, span * 0.1)
return [Math.max(0, Math.floor(lo - pad)), Math.ceil(hi + pad)]
}
const TDEE_REF_LINE_COLOR = '#475569'
/** Legende unter dem Chart: Linien + ggf. TDEE-Referenz (gestrichelt). */
function KcalVsWeightLegend({ showTdee }) {
const line = (color) => ({
@ -753,7 +773,7 @@ function KcalVsWeightLegend({ showTdee }) {
height: 0,
verticalAlign: 'middle',
marginRight: 6,
borderTop: '2px dashed #EA580C',
borderTop: `2px dashed ${TDEE_REF_LINE_COLOR}`,
opacity: 0.95,
}}
/>
@ -774,6 +794,7 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD
}))
const n = vizKcalWeight.common_days_count ?? kcalVsW.length
const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null
const kcalDomain = kcalVsWeightKcalDomain(kcalVsW, tdeeLabel)
return (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
@ -786,14 +807,21 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD
<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={['auto', 'auto']} />
<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, n) => [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']}
/>
{tdeeLabel != null && (
<ReferenceLine yAxisId="kcal" y={tdeeLabel} stroke="#EA580C" strokeDasharray="6 4" strokeWidth={1.2} />
<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" />
@ -823,6 +851,7 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD
const bmr = sex === 'm' ? 10 * latestW + 6.25 * height - 5 * age + 5 : 10 * latestW + 6.25 * height - 5 * age - 161
const tdee = Math.round(bmr * 1.4)
const kcalVsW = rollingAvg(raw.map(d => ({ ...d, date: fmtDate(d.date) })), 'kcal')
const kcalDomainFb = kcalVsWeightKcalDomain(kcalVsW, tdee)
return (
<div className="card" style={{ marginBottom: 12 }}>
@ -836,13 +865,20 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD
<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={['auto', 'auto']} />
<YAxis yAxisId="kcal" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={kcalDomainFb} />
<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, n) => [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']}
/>
<ReferenceLine yAxisId="kcal" y={tdee} stroke="#EA580C" strokeDasharray="6 4" strokeWidth={1.2} />
<ReferenceLine
yAxisId="kcal"
y={tdee}
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>
@ -1054,7 +1090,7 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 4 }}>
Zeitverläufe (Energie & Protein)
</div>
<NutritionCharts days={chartDays} showWeeklyMacroDistribution={false} />
<NutritionCharts days={chartDays} showWeeklyMacroDistribution={false} hideEnergyAvailabilityCard />
<InsightBox insights={insights} slugs={filterActiveSlugs(['ernaehrung'])} onRequest={onRequest} loading={loadingSlug}/>
</div>