feat: implement energy availability warning and enhance nutrition visualization
- 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:
parent
fc816da335
commit
d7304c1a44
|
|
@ -121,8 +121,7 @@ def build_nutrition_history_kpi_tiles(
|
||||||
"status": "bad",
|
"status": "bad",
|
||||||
"verdict": _verdict("bad"),
|
"verdict": _verdict("bad"),
|
||||||
"hint": (
|
"hint": (
|
||||||
f"Es fehlen rund {miss} g Protein pro Tag – bei Kaloriendefizit "
|
f"~{miss} g Protein/Tag fehlen – bei Defizit Muskelerhalt gefährdet."
|
||||||
"steigt das Risiko für Muskelerhalt."
|
|
||||||
),
|
),
|
||||||
"hoverTop": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
|
"hoverTop": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
|
||||||
"hoverBody": (
|
"hoverBody": (
|
||||||
|
|
@ -159,8 +158,8 @@ def build_nutrition_history_kpi_tiles(
|
||||||
"status": "warn",
|
"status": "warn",
|
||||||
"verdict": _verdict("warn"),
|
"verdict": _verdict("warn"),
|
||||||
"hint": (
|
"hint": (
|
||||||
f"Viele Kalorien kommen aus KH/Fett; Proteinanteil oft sinnvoll bei 25–35 % "
|
f"Protein-Kalorienanteil niedrig (P {prot_pct} % / KH {kh_pct} % / F {fat_pct} %); "
|
||||||
f"(aktuell P {prot_pct} % / KH {kh_pct} % / F {fat_pct} %)."
|
"Ziel oft 25–35 %."
|
||||||
),
|
),
|
||||||
"hoverTop": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien",
|
"hoverTop": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien",
|
||||||
"hoverBody": (
|
"hoverBody": (
|
||||||
|
|
@ -173,6 +172,37 @@ def build_nutrition_history_kpi_tiles(
|
||||||
return 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]]]:
|
def build_macro_donut_from_averages(navg: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
|
||||||
"""Anteile in % der Makro-kcal + Gramm für Legende."""
|
"""Anteile in % der Makro-kcal + Gramm für Legende."""
|
||||||
p = float(navg.get("protein_avg") or 0)
|
p = float(navg.get("protein_avg") or 0)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
# Calculated Metrics (migrated from calculations/nutrition_metrics.py)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,13 @@ from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from data_layer.nutrition_interpretation import (
|
from data_layer.nutrition_interpretation import (
|
||||||
|
build_energy_availability_kpi_tile,
|
||||||
build_macro_donut_from_averages,
|
build_macro_donut_from_averages,
|
||||||
build_nutrition_history_kpi_tiles,
|
build_nutrition_history_kpi_tiles,
|
||||||
)
|
)
|
||||||
from data_layer.nutrition_metrics import (
|
from data_layer.nutrition_metrics import (
|
||||||
estimate_tdee_kcal_from_latest_weight,
|
estimate_tdee_kcal_from_latest_weight,
|
||||||
|
get_energy_availability_warning_payload,
|
||||||
get_energy_balance_data,
|
get_energy_balance_data,
|
||||||
get_nutrition_average_data,
|
get_nutrition_average_data,
|
||||||
get_protein_targets_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,
|
"tdee_reference_kcal": None,
|
||||||
"energy_balance_meta": {},
|
"energy_balance_meta": {},
|
||||||
"interpretation_tiles": [],
|
"interpretation_tiles": [],
|
||||||
|
"energy_availability_warning": None,
|
||||||
"meta": {"layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz"},
|
"meta": {"layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz"},
|
||||||
}
|
}
|
||||||
|
|
||||||
all_history = days >= 9999
|
all_history = days >= 9999
|
||||||
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
|
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
|
||||||
cutoff = _cutoff_sql(days)
|
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)
|
navg = get_nutrition_average_data(profile_id, eff_days, all_history=all_history)
|
||||||
targets = get_protein_targets_data(profile_id)
|
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)
|
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)
|
donut = build_macro_donut_from_averages(navg)
|
||||||
|
|
||||||
kw_points = _kcal_weight_points_for_window(profile_id, cutoff)
|
kw_points = _kcal_weight_points_for_window(profile_id, cutoff)
|
||||||
pt_low = round(float(targets.get("protein_target_low") or 0))
|
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))
|
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)
|
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"),
|
"protein_target_high": targets.get("protein_target_high"),
|
||||||
"reference_weight_kg": targets.get("current_weight"),
|
"reference_weight_kg": targets.get("current_weight"),
|
||||||
},
|
},
|
||||||
"kpi_tiles": kpi_tiles,
|
"kpi_tiles": kpi_tiles_out,
|
||||||
"interpretation_tiles": [],
|
"interpretation_tiles": [],
|
||||||
|
"energy_availability_warning": ea_payload,
|
||||||
"daily_macros": daily_macros,
|
"daily_macros": daily_macros,
|
||||||
"donut_avg_pct": donut,
|
"donut_avg_pct": donut,
|
||||||
"protein_reference_line_g": pt_low,
|
"protein_reference_line_g": pt_low,
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ from data_layer.nutrition_metrics import (
|
||||||
get_macro_consistency_data,
|
get_macro_consistency_data,
|
||||||
get_energy_balance_data,
|
get_energy_balance_data,
|
||||||
get_weekly_macro_distribution_chart_data,
|
get_weekly_macro_distribution_chart_data,
|
||||||
|
get_energy_availability_warning_payload,
|
||||||
)
|
)
|
||||||
from data_layer.activity_metrics import (
|
from data_layer.activity_metrics import (
|
||||||
get_activity_summary_data,
|
get_activity_summary_data,
|
||||||
|
|
@ -1026,87 +1027,10 @@ def get_energy_availability_warning(
|
||||||
"""
|
"""
|
||||||
Energy Availability Warning (E5) - Konzept-konform.
|
Energy Availability Warning (E5) - Konzept-konform.
|
||||||
|
|
||||||
Heuristic warning for potential undernutrition/overtraining.
|
Datenberechnung: data_layer.nutrition_metrics.get_energy_availability_warning_payload
|
||||||
|
|
||||||
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": "..."
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
profile_id = session['profile_id']
|
profile_id = session['profile_id']
|
||||||
|
return get_energy_availability_warning_payload(profile_id, days)
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/training-volume")
|
@router.get("/training-volume")
|
||||||
|
|
|
||||||
|
|
@ -350,6 +350,11 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
|
||||||
font-style: italic;
|
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) */
|
/* Verlauf Ernährung: Donut (Ø-Quote) + wöchentliche Makro-Verteilung (E3) */
|
||||||
.nutrition-macro-pair {
|
.nutrition-macro-pair {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -125,14 +125,17 @@ export default function KpiTilesOverview({
|
||||||
<div
|
<div
|
||||||
className="kpi-tiles-card__hint"
|
className="kpi-tiles-card__hint"
|
||||||
style={{
|
style={{
|
||||||
marginTop: 10,
|
marginTop: 6,
|
||||||
padding: '8px 10px',
|
paddingLeft: 8,
|
||||||
borderRadius: 8,
|
borderLeft: `2px solid ${accent}`,
|
||||||
fontSize: 10,
|
fontSize: 9,
|
||||||
lineHeight: 1.45,
|
lineHeight: 1.35,
|
||||||
color: 'var(--text2)',
|
color: 'var(--text2)',
|
||||||
background: 'var(--surface2)',
|
display: '-webkit-box',
|
||||||
borderLeft: `3px solid ${accent}`,
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
wordBreak: 'break-word',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cardHint}
|
{cardHint}
|
||||||
|
|
|
||||||
|
|
@ -206,7 +206,12 @@ export function WeeklyMacroDistributionPanel({ macroWeeklyData, loading, error }
|
||||||
/**
|
/**
|
||||||
* Nutrition Charts (E1–E5). Verlauf: `showWeeklyMacroDistribution={false}` wenn E3 separat (z. B. neben Donut) gerendert wird.
|
* Nutrition Charts (E1–E5). 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 [energyData, setEnergyData] = useState(null)
|
||||||
const [proteinData, setProteinData] = useState(null)
|
const [proteinData, setProteinData] = useState(null)
|
||||||
const [macroWeeklyData, setMacroWeeklyData] = useState(null)
|
const [macroWeeklyData, setMacroWeeklyData] = useState(null)
|
||||||
|
|
@ -221,15 +226,17 @@ export default function NutritionCharts({ days = 28, showWeeklyMacroDistribution
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCharts()
|
loadCharts()
|
||||||
}, [days, showWeeklyMacroDistribution])
|
}, [days, showWeeklyMacroDistribution, hideEnergyAvailabilityCard])
|
||||||
|
|
||||||
const loadCharts = async () => {
|
const loadCharts = async () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
loadEnergyBalance(),
|
loadEnergyBalance(),
|
||||||
loadProteinAdequacy(),
|
loadProteinAdequacy(),
|
||||||
loadAdherence(),
|
loadAdherence(),
|
||||||
loadWarning(),
|
|
||||||
]
|
]
|
||||||
|
if (!hideEnergyAvailabilityCard) {
|
||||||
|
tasks.push(loadWarning())
|
||||||
|
}
|
||||||
if (showWeeklyMacroDistribution) {
|
if (showWeeklyMacroDistribution) {
|
||||||
tasks.splice(2, 0, loadMacroWeekly())
|
tasks.splice(2, 0, loadMacroWeekly())
|
||||||
}
|
}
|
||||||
|
|
@ -474,7 +481,7 @@ export default function NutritionCharts({ days = 28, showWeeklyMacroDistribution
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading.adherence && !errors.adherence && renderAdherence()}
|
{!loading.adherence && !errors.adherence && renderAdherence()}
|
||||||
{!loading.warning && !errors.warning && renderWarning()}
|
{!hideEnergyAvailabilityCard && !loading.warning && !errors.warning && renderWarning()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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). */
|
/** Legende unter dem Chart: Linien + ggf. TDEE-Referenz (gestrichelt). */
|
||||||
function KcalVsWeightLegend({ showTdee }) {
|
function KcalVsWeightLegend({ showTdee }) {
|
||||||
const line = (color) => ({
|
const line = (color) => ({
|
||||||
|
|
@ -753,7 +773,7 @@ function KcalVsWeightLegend({ showTdee }) {
|
||||||
height: 0,
|
height: 0,
|
||||||
verticalAlign: 'middle',
|
verticalAlign: 'middle',
|
||||||
marginRight: 6,
|
marginRight: 6,
|
||||||
borderTop: '2px dashed #EA580C',
|
borderTop: `2px dashed ${TDEE_REF_LINE_COLOR}`,
|
||||||
opacity: 0.95,
|
opacity: 0.95,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -774,6 +794,7 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD
|
||||||
}))
|
}))
|
||||||
const n = vizKcalWeight.common_days_count ?? kcalVsW.length
|
const n = vizKcalWeight.common_days_count ?? kcalVsW.length
|
||||||
const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null
|
const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null
|
||||||
|
const kcalDomain = kcalVsWeightKcalDomain(kcalVsW, tdeeLabel)
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ marginBottom: 12 }}>
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
<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 }}>
|
<LineChart data={kcalVsW} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
<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)} />
|
<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']} />
|
<YAxis yAxisId="weight" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
|
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']}
|
formatter={(v, n) => [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']}
|
||||||
/>
|
/>
|
||||||
{tdeeLabel != null && (
|
{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="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" />
|
<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 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 tdee = Math.round(bmr * 1.4)
|
||||||
const kcalVsW = rollingAvg(raw.map(d => ({ ...d, date: fmtDate(d.date) })), 'kcal')
|
const kcalVsW = rollingAvg(raw.map(d => ({ ...d, date: fmtDate(d.date) })), 'kcal')
|
||||||
|
const kcalDomainFb = kcalVsWeightKcalDomain(kcalVsW, tdee)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ marginBottom: 12 }}>
|
<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 }}>
|
<LineChart data={kcalVsW} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
<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)} />
|
<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']} />
|
<YAxis yAxisId="weight" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
|
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']}
|
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="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" />
|
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#2563EB" strokeWidth={2.5} dot={{ r: 2, fill: '#2563EB' }} name="weight" />
|
||||||
</LineChart>
|
</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 }}>
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 4 }}>
|
||||||
Zeitverläufe (Energie & Protein)
|
Zeitverläufe (Energie & Protein)
|
||||||
</div>
|
</div>
|
||||||
<NutritionCharts days={chartDays} showWeeklyMacroDistribution={false} />
|
<NutritionCharts days={chartDays} showWeeklyMacroDistribution={false} hideEnergyAvailabilityCard />
|
||||||
|
|
||||||
<InsightBox insights={insights} slugs={filterActiveSlugs(['ernaehrung'])} onRequest={onRequest} loading={loadingSlug}/>
|
<InsightBox insights={insights} slugs={filterActiveSlugs(['ernaehrung'])} onRequest={onRequest} loading={loadingSlug}/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user