feat: improve history overview visualization and data handling
- Added `safe_float` utility to enhance float handling in correlation calculations, preventing potential errors. - Refactored lag correlation logic in `get_history_overview_viz_bundle` to utilize absolute values safely, improving accuracy in metric comparisons. - Enhanced nutrition body merge logic to ensure proper date handling and data integrity, optimizing the retrieval of nutrition and weight logs. - Introduced new functions in the frontend for processing lag details, improving the visualization of correlation data in the History page.
This commit is contained in:
parent
3106ebedae
commit
0365d9eb52
|
|
@ -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.fitness_viz import get_fitness_dashboard_viz_bundle
|
||||||
from data_layer.nutrition_viz import get_nutrition_history_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.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]]:
|
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_rhr = calculate_lag_correlation(profile_id, "load", "rhr", 14)
|
||||||
c3 = None
|
c3 = None
|
||||||
if c3_hrv and c3_rhr:
|
if c3_hrv and c3_rhr:
|
||||||
c3 = (
|
a1 = abs(safe_float(c3_hrv.get("correlation"), 0.0))
|
||||||
c3_hrv
|
a2 = abs(safe_float(c3_rhr.get("correlation"), 0.0))
|
||||||
if abs(float(c3_hrv.get("correlation") or 0)) >= abs(float(c3_rhr.get("correlation") or 0))
|
c3 = c3_hrv if a1 >= a2 else c3_rhr
|
||||||
else c3_rhr
|
|
||||||
)
|
|
||||||
if c3 is c3_hrv:
|
if c3 is c3_hrv:
|
||||||
c3 = dict(c3)
|
c3 = dict(c3)
|
||||||
c3["metric"] = "HRV"
|
c3["metric"] = "HRV"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from __future__ import annotations
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
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]]:
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (profile_id,))
|
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,))
|
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(
|
cur.execute(
|
||||||
"SELECT date, lean_mass, body_fat_pct FROM caliper_log WHERE profile_id=%s ORDER BY date",
|
"SELECT date, lean_mass, body_fat_pct FROM caliper_log WHERE profile_id=%s ORDER BY date",
|
||||||
(profile_id,),
|
(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
|
mi = 0
|
||||||
last_cal: Dict[str, Any] = {}
|
last_cal: Dict[str, Any] = {}
|
||||||
cal_by_date: Dict[Any, Dict[str, Any]] = {}
|
cal_by_date: Dict[Any, Dict[str, Any]] = {}
|
||||||
for d in all_dates:
|
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]
|
last_cal = cals[mi]
|
||||||
mi += 1
|
mi += 1
|
||||||
if last_cal:
|
if last_cal:
|
||||||
|
|
|
||||||
|
|
@ -1205,6 +1205,34 @@ function chartJsScatterPoints(payload) {
|
||||||
return raw.map((p) => ({ x: Number(p.x), y: Number(p.y) }))
|
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) {
|
function driverBarFromStatus(st) {
|
||||||
const s = String(st || '').toLowerCase()
|
const s = String(st || '').toLowerCase()
|
||||||
if (s.includes('hinder')) return { v: -1, fill: 'var(--danger)' }
|
if (s.includes('hinder')) return { v: -1, fill: 'var(--danger)' }
|
||||||
|
|
@ -1240,10 +1268,13 @@ function chartJsBarRows(payload, fallbackDrivers) {
|
||||||
function CorrelationScatterTile({ title, accent, payload }) {
|
function CorrelationScatterTile({ title, accent, payload }) {
|
||||||
const meta = payload?.metadata || {}
|
const meta = payload?.metadata || {}
|
||||||
const pts = chartJsScatterPoints(payload)
|
const pts = chartJsScatterPoints(payload)
|
||||||
|
const curve = lagDetailsToCurve(meta)
|
||||||
const hasChart = pts.length > 0 && meta.correlation != null
|
const hasChart = pts.length > 0 && meta.correlation != null
|
||||||
const r = Number(meta.correlation)
|
const r = Number(meta.correlation)
|
||||||
const strength =
|
const strength =
|
||||||
!Number.isFinite(r) ? 'bad' : Math.abs(r) >= 0.35 ? 'good' : Math.abs(r) >= 0.15 ? 'warn' : 'bad'
|
!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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -1257,12 +1288,76 @@ function CorrelationScatterTile({ title, accent, payload }) {
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text1)', marginBottom: 4 }}>{title}</div>
|
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text1)', marginBottom: 4 }}>{title}</div>
|
||||||
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.35, marginBottom: 6 }}>
|
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.35, marginBottom: 6 }}>
|
||||||
r = {meta.correlation != null ? Number(meta.correlation).toFixed(3) : '—'}
|
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.metric ? ` · ${meta.metric}` : ''}
|
||||||
{meta.confidence ? ` · ${meta.confidence}` : ''}
|
{meta.confidence ? ` · ${meta.confidence}` : ''}
|
||||||
</div>
|
</div>
|
||||||
{!hasChart ? (
|
{!hasChart ? (
|
||||||
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{meta.message || 'Keine Daten für diese Korrelation.'}</div>
|
<>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: curve.length ? 8 : 0 }}>
|
||||||
|
{meta.message || 'Keine Daten für diese Korrelation.'}
|
||||||
|
</div>
|
||||||
|
{curve.length > 0 && (
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
|
Lag-Sweep (kein Lag mit ≥15 Paaren): r über Lags — nur zur Einordnung.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{curve.length > 0 && (
|
||||||
|
<ResponsiveContainer width="100%" height={120}>
|
||||||
|
<ComposedChart data={curve} margin={{ top: 4, right: 6, bottom: 4, left: -14 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis dataKey="lag" type="number" domain={[0, maxLagAxis]} tick={{ fontSize: 9, fill: 'var(--text3)' }} label={{ value: 'Lag (T)', fontSize: 9, fill: 'var(--text3)', offset: -2 }} />
|
||||||
|
<YAxis dataKey="r" domain={[-1, 1]} tick={{ fontSize: 9, fill: 'var(--text3)' }} width={36} label={{ value: 'r', fontSize: 9, fill: 'var(--text3)', angle: -90 }} />
|
||||||
|
<ReferenceLine y={0} stroke="var(--text3)" strokeDasharray="4 4" />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 10 }}
|
||||||
|
formatter={(v, _n, item) => [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="r" stroke={accent} strokeWidth={2} dot={{ r: 3, fill: accent }} isAnimationActive={false} />
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : curve.length >= 1 ? (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: 9, color: 'var(--text3)', marginBottom: 4 }}>
|
||||||
|
Kurve: Pearson-r je Lag (Tage); starker Punkt = gewähltes bestes Lag.
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={132}>
|
||||||
|
<ComposedChart data={curve} margin={{ top: 4, right: 6, bottom: 4, left: -14 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis dataKey="lag" type="number" domain={[0, maxLagAxis]} tick={{ fontSize: 9, fill: 'var(--text3)' }} />
|
||||||
|
<YAxis dataKey="r" domain={[-1, 1]} tick={{ fontSize: 9, fill: 'var(--text3)' }} width={36} />
|
||||||
|
<ReferenceLine y={0} stroke="var(--text3)" strokeDasharray="4 4" />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 10 }}
|
||||||
|
formatter={(v, _n, item) => [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="r"
|
||||||
|
stroke={accent}
|
||||||
|
strokeWidth={2}
|
||||||
|
isAnimationActive={false}
|
||||||
|
dot={(props) => {
|
||||||
|
const { cx, cy, payload: pl } = props
|
||||||
|
if (cx == null || cy == null || !pl) return null
|
||||||
|
const isBest = bestLag != null && Number(pl.lag) === bestLag
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={isBest ? 6 : 3.5}
|
||||||
|
fill={isBest ? 'var(--surface)' : accent}
|
||||||
|
stroke={isBest ? accent : 'none'}
|
||||||
|
strokeWidth={isBest ? 2.5 : 0}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={118}>
|
<ResponsiveContainer width="100%" height={118}>
|
||||||
<ScatterChart margin={{ top: 2, right: 4, bottom: 2, left: -18 }}>
|
<ScatterChart margin={{ top: 2, right: 4, bottom: 2, left: -18 }}>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user