diff --git a/backend/data_layer/history_overview_viz.py b/backend/data_layer/history_overview_viz.py index 40627f9..4d278c2 100644 --- a/backend/data_layer/history_overview_viz.py +++ b/backend/data_layer/history_overview_viz.py @@ -13,6 +13,7 @@ from data_layer.correlations import calculate_lag_correlation, calculate_top_dri from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle from data_layer.nutrition_viz import get_nutrition_history_viz_bundle from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle +from data_layer.utils import safe_float def _take_kpis(tiles: Any, max_n: int = 4) -> List[Dict[str, Any]]: @@ -90,11 +91,9 @@ def get_history_overview_viz_bundle(profile_id: str, days: int) -> Dict[str, Any c3_rhr = calculate_lag_correlation(profile_id, "load", "rhr", 14) c3 = None if c3_hrv and c3_rhr: - c3 = ( - c3_hrv - if abs(float(c3_hrv.get("correlation") or 0)) >= abs(float(c3_rhr.get("correlation") or 0)) - else c3_rhr - ) + a1 = abs(safe_float(c3_hrv.get("correlation"), 0.0)) + a2 = abs(safe_float(c3_rhr.get("correlation"), 0.0)) + c3 = c3_hrv if a1 >= a2 else c3_rhr if c3 is c3_hrv: c3 = dict(c3) c3["metric"] = "HRV" diff --git a/backend/data_layer/nutrition_body_merge.py b/backend/data_layer/nutrition_body_merge.py index 3263c45..ac8768f 100644 --- a/backend/data_layer/nutrition_body_merge.py +++ b/backend/data_layer/nutrition_body_merge.py @@ -9,7 +9,7 @@ from __future__ import annotations from typing import Any, Dict, List, Optional from db import get_db, get_cursor, r2d -from caliper_composition import compute_lean_fat_kg, nearest_weight_kg_from_map +from caliper_composition import as_date, compute_lean_fat_kg, nearest_weight_kg_from_map def build_merged_daily_nutrition_body_rows(profile_id: str) -> List[Dict[str, Any]]: @@ -20,21 +20,42 @@ def build_merged_daily_nutrition_body_rows(profile_id: str) -> List[Dict[str, An with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (profile_id,)) - nutr = {r["date"]: r2d(r) for r in cur.fetchall()} + nutr: Dict[Any, Dict[str, Any]] = {} + for r in cur.fetchall(): + rd = r2d(r) + dk = as_date(rd.get("date")) + if dk is not None: + nutr[dk] = rd cur.execute("SELECT date, weight FROM weight_log WHERE profile_id=%s ORDER BY date", (profile_id,)) - wlog = {r["date"]: r["weight"] for r in cur.fetchall()} + wlog: Dict[Any, Any] = {} + for r in cur.fetchall(): + rd = r2d(r) + dk = as_date(rd.get("date")) + if dk is not None: + wlog[dk] = rd["weight"] cur.execute( "SELECT date, lean_mass, body_fat_pct FROM caliper_log WHERE profile_id=%s ORDER BY date", (profile_id,), ) - cals = sorted([r2d(r) for r in cur.fetchall()], key=lambda x: x["date"]) + cals = [r2d(r) for r in cur.fetchall()] + cals = sorted( + [c for c in cals if as_date(c.get("date")) is not None], + key=lambda x: as_date(x["date"]), + ) - all_dates = sorted(set(list(nutr.keys()) + list(wlog.keys()))) + # Alle Keys sind datetime.date — vermeidet TypeError bei Vergleichen (str vs date) + all_dates = sorted(set(nutr.keys()) | set(wlog.keys())) mi = 0 last_cal: Dict[str, Any] = {} cal_by_date: Dict[Any, Dict[str, Any]] = {} for d in all_dates: - while mi < len(cals) and cals[mi]["date"] <= d: + while mi < len(cals): + cd = as_date(cals[mi].get("date")) + if cd is None: + mi += 1 + continue + if cd > d: + break last_cal = cals[mi] mi += 1 if last_cal: diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 95b76db..d33d09c 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -1205,6 +1205,34 @@ function chartJsScatterPoints(payload) { return raw.map((p) => ({ x: Number(p.x), y: Number(p.y) })) } +/** Backend metadata.lag_details: [{ lag, n_pairs, r }] — für Lag-Kurve L → r (C3: ggf. lag_details_hrv / lag_details_rhr) */ +function lagDetailsToCurve(meta) { + let ld = meta?.lag_details + if (!Array.isArray(ld) || ld.length === 0) { + const m = String(meta?.metric || '').toUpperCase() + if (m === 'HRV' && Array.isArray(meta?.lag_details_hrv)) ld = meta.lag_details_hrv + else if (m === 'RHR' && Array.isArray(meta?.lag_details_rhr)) ld = meta.lag_details_rhr + else { + const h = meta?.lag_details_hrv + const r = meta?.lag_details_rhr + const hl = Array.isArray(h) ? h.length : 0 + const rl = Array.isArray(r) ? r.length : 0 + if (hl >= rl && hl > 0) ld = h + else if (rl > 0) ld = r + else ld = [] + } + } + if (!Array.isArray(ld) || ld.length === 0) return [] + return ld + .map((d) => ({ + lag: Number(d?.lag), + r: d?.r == null || d?.r === '' ? null : Number(d.r), + n_pairs: d?.n_pairs != null ? Number(d.n_pairs) : null, + })) + .filter((d) => Number.isFinite(d.lag) && d.r != null && Number.isFinite(d.r)) + .sort((a, b) => a.lag - b.lag) +} + function driverBarFromStatus(st) { const s = String(st || '').toLowerCase() if (s.includes('hinder')) return { v: -1, fill: 'var(--danger)' } @@ -1240,10 +1268,13 @@ function chartJsBarRows(payload, fallbackDrivers) { function CorrelationScatterTile({ title, accent, payload }) { const meta = payload?.metadata || {} const pts = chartJsScatterPoints(payload) + const curve = lagDetailsToCurve(meta) const hasChart = pts.length > 0 && meta.correlation != null const r = Number(meta.correlation) const strength = !Number.isFinite(r) ? 'bad' : Math.abs(r) >= 0.35 ? 'good' : Math.abs(r) >= 0.15 ? 'warn' : 'bad' + const bestLag = meta.best_lag_days != null ? Number(meta.best_lag_days) : null + const maxLagAxis = curve.length ? Math.max(14, ...curve.map((d) => d.lag), bestLag || 0) : 28 return (
{title}
r = {meta.correlation != null ? Number(meta.correlation).toFixed(3) : '—'} - {meta.best_lag_days != null ? ` · Lag ${meta.best_lag_days} T` : ''} + {meta.best_lag_days != null ? ` · bestes Lag ${meta.best_lag_days} T` : ''} {meta.metric ? ` · ${meta.metric}` : ''} {meta.confidence ? ` · ${meta.confidence}` : ''}
{!hasChart ? ( -
{meta.message || 'Keine Daten für diese Korrelation.'}
+ <> +
+ {meta.message || 'Keine Daten für diese Korrelation.'} +
+ {curve.length > 0 && ( +
+ Lag-Sweep (kein Lag mit ≥15 Paaren): r über Lags — nur zur Einordnung. +
+ )} + {curve.length > 0 && ( + + + + + + + [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]} + /> + + + + )} + + ) : curve.length >= 1 ? ( + <> +
+ Kurve: Pearson-r je Lag (Tage); starker Punkt = gewähltes bestes Lag. +
+ + + + + + + [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]} + /> + { + const { cx, cy, payload: pl } = props + if (cx == null || cy == null || !pl) return null + const isBest = bestLag != null && Number(pl.lag) === bestLag + return ( + + ) + }} + /> + + + ) : (