Merge pull request 'Fitness History + recovery' (#96) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s

Reviewed-on: #96
This commit is contained in:
Lars 2026-04-20 08:46:25 +02:00
commit 6743814904
5 changed files with 451 additions and 81 deletions

View File

@ -7,7 +7,7 @@ Ausgelagert aus routers/charts.py (Issue 53 / Layer 1).
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Dict
from typing import Any, Dict, Optional
from db import get_db, get_cursor
from data_layer.recovery_metrics import (
@ -19,6 +19,7 @@ from data_layer.recovery_metrics import (
get_sleep_quality_data,
)
from data_layer.utils import calculate_confidence, safe_float, serialize_dates
from data_layer.vital_signs_assessment import build_vital_items_from_rows
def build_recovery_score_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
@ -355,27 +356,87 @@ def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any]
}
VITAL_BASELINE_KEYS = ("resting_hr", "hrv", "vo2_max", "spo2", "respiratory_rate")
def _vitals_row_has_any_value(row: Any) -> bool:
if not row:
return False
for k in VITAL_BASELINE_KEYS:
if row.get(k) is not None:
return True
return False
def _merge_vitals_baseline_rows(rows: Any) -> tuple[Optional[Dict[str, Any]], Optional[Any]]:
"""
Pro Kennzahl den jeweils neuesten nicht-leeren Wert (Zeilen sortiert: date DESC).
So können KPIs (Aggregation über Zeilen) Daten haben, obwohl die jüngste Zeile leer ist.
"""
if not rows:
return None, None
merged: Dict[str, Any] = {k: None for k in VITAL_BASELINE_KEYS}
for row in rows:
for k in VITAL_BASELINE_KEYS:
if merged[k] is None and row.get(k) is not None:
merged[k] = row[k]
if all(merged[k] is not None for k in VITAL_BASELINE_KEYS):
break
if not _vitals_row_has_any_value(merged):
return None, None
newest_date = rows[0].get("date") if rows else None
return merged, newest_date
def _bp_row_complete(row: Any) -> bool:
return bool(row and row.get("systolic") is not None and row.get("diastolic") is not None)
def _tone_to_bar_value(tone: str) -> float:
return {"good": 88.0, "warn": 52.0, "bad": 22.0, "neutral": 62.0}.get(tone, 55.0)
def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
"""Letzte Messungen im Fenster; sonst Fallback auf jüngste Messung überhaupt (Issue 53 / Layer 1)."""
if days < 7:
days = 7
if days > 30:
days = 30
if days > 365:
days = 365
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
bp_row = None
vitals_measured_at = None
bp_measured_at = None
vitals_for_items: Optional[Dict[str, Any]] = None
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""SELECT resting_hr, hrv, vo2_max, spo2, respiratory_rate
"""SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate
FROM vitals_baseline
WHERE profile_id=%s AND date >= %s
ORDER BY date DESC
LIMIT 1""",
LIMIT 200""",
(profile_id, cutoff),
)
vitals_row = cur.fetchone()
vitals_merged, vitals_date = _merge_vitals_baseline_rows(cur.fetchall())
if vitals_merged is None:
cur.execute(
"""SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate
FROM vitals_baseline
WHERE profile_id=%s
ORDER BY date DESC
LIMIT 400""",
(profile_id,),
)
vitals_merged, vitals_date = _merge_vitals_baseline_rows(cur.fetchall())
if vitals_merged is not None:
vitals_for_items = dict(vitals_merged)
if vitals_date is not None:
vitals_measured_at = vitals_date.isoformat() if hasattr(vitals_date, "isoformat") else str(vitals_date)
cur.execute(
"""SELECT systolic, diastolic
"""SELECT measured_at, systolic, diastolic
FROM blood_pressure_log
WHERE profile_id=%s AND measured_at::date >= %s::date
ORDER BY measured_at DESC
@ -383,74 +444,85 @@ def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[s
(profile_id, cutoff),
)
bp_row = cur.fetchone()
if bp_row and bp_row.get("measured_at") is not None:
bp_measured_at = bp_row["measured_at"]
if not vitals_row and not bp_row:
return {
"chart_type": "bar",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Keine aktuellen Vitalwerte",
},
}
labels = []
values = []
if vitals_row:
if vitals_row["resting_hr"]:
labels.append("Ruhepuls (bpm)")
values.append(safe_float(vitals_row["resting_hr"]))
if vitals_row["hrv"]:
labels.append("HRV (ms)")
values.append(safe_float(vitals_row["hrv"]))
if vitals_row["vo2_max"]:
labels.append("VO2 Max")
values.append(safe_float(vitals_row["vo2_max"]))
if vitals_row["spo2"]:
labels.append("SpO2 (%)")
values.append(safe_float(vitals_row["spo2"]))
if vitals_row["respiratory_rate"]:
labels.append("Atemfrequenz")
values.append(safe_float(vitals_row["respiratory_rate"]))
if not _bp_row_complete(bp_row):
cur.execute(
"""SELECT measured_at, systolic, diastolic
FROM blood_pressure_log
WHERE profile_id=%s
ORDER BY measured_at DESC
LIMIT 1""",
(profile_id,),
)
bp_row = cur.fetchone()
if bp_row and bp_row.get("measured_at") is not None:
bp_measured_at = bp_row["measured_at"]
bp_for_items = None
if bp_row:
if bp_row["systolic"]:
labels.append("Blutdruck sys (mmHg)")
values.append(safe_float(bp_row["systolic"]))
if bp_row["diastolic"]:
labels.append("Blutdruck dia (mmHg)")
values.append(safe_float(bp_row["diastolic"]))
bp_for_items = {"systolic": bp_row.get("systolic"), "diastolic": bp_row.get("diastolic")}
if not labels:
items = build_vital_items_from_rows(vitals_for_items, bp_for_items)
if not items:
return {
"chart_type": "bar",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Keine Vitalwerte verfügbar",
"message": "Keine Vitalwerte mit Zahlenwerten — Baseline-Vitals und/oder Blutdruck erfassen.",
"vital_items": [],
"vitals_measured_at": vitals_measured_at,
"blood_pressure_measured_at": bp_measured_at.isoformat() if bp_measured_at and hasattr(bp_measured_at, "isoformat") else None,
},
}
for it in items:
it["bar_value"] = round(_tone_to_bar_value(it["tone"]), 1)
labels_short = [it["label_de"] for it in items]
bar_values = [it["bar_value"] for it in items]
colors = []
for it in items:
t = it["tone"]
if t == "good":
colors.append("#1D9E75")
elif t == "warn":
colors.append("#EF9F27")
elif t == "bad":
colors.append("#D85A30")
else:
colors.append("#6B7280")
return {
"chart_type": "bar",
"data": {
"labels": labels,
"labels": labels_short,
"datasets": [
{
"label": "Wert",
"data": values,
"backgroundColor": "#1D9E75",
"borderColor": "#085041",
"label": "Einschätzung (relativ)",
"data": bar_values,
"backgroundColor": colors,
"borderColor": colors,
"borderWidth": 1,
}
],
},
"metadata": {
"confidence": "medium",
"data_points": len(values),
"note": "Latest measurements within last " + str(days) + " days",
},
"metadata": serialize_dates(
{
"confidence": "medium",
"data_points": len(items),
"note": "Orientierende Zonen, keine Diagnose. Balken = relative Einordnung (nicht körperliche Einheit).",
"vital_items": items,
"bar_is_relative_score": True,
"vitals_measured_at": vitals_measured_at,
"blood_pressure_measured_at": bp_measured_at.isoformat()
if bp_measured_at and hasattr(bp_measured_at, "isoformat")
else (str(bp_measured_at) if bp_measured_at else None),
"disclaimer_de": "Hinweis: Nur Orientierung; bei Beschwerden oder auffälligen Werten ärztlich abklären.",
}
),
}

View File

@ -55,7 +55,8 @@ def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, A
all_history = days >= 9999
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
chart_days = min(90, max(7, min(eff_days, 365)))
vital_days = min(30, max(7, chart_days))
# Vital-Matrix: längeres Fenster + Fallback im Builder, damit nicht nur „letzte 30 Tage“
vital_days = min(365, max(30, min(eff_days, 365)))
recovery_score_val = calculate_recovery_score_v2(profile_id)
sleep_debt = calculate_sleep_debt_hours(profile_id)

View File

@ -0,0 +1,153 @@
"""
Orientierende Zonen-Einschätzungen für Vitalwerte (Layer 1, Issue 53).
Keine Diagnose typische Referenzbereiche für UI/Coaching.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from data_layer.utils import safe_float
Tone = str # good | warn | bad | neutral
def _item(
key: str,
label_de: str,
value_display: str,
tone: Tone,
zone_label_de: str,
hint_de: str,
sort_order: int,
) -> Dict[str, Any]:
return {
"key": key,
"label_de": label_de,
"value_display": value_display,
"tone": tone,
"zone_label_de": zone_label_de,
"hint_de": hint_de,
"sort_order": sort_order,
}
def assess_resting_hr(bpm: float) -> tuple:
if bpm < 50:
return (
"warn",
"Niedrig",
"Unter 50 bpm kann bei Sportlern normal sein — sonst ärztlich klären, wenn neu oder mit Beschwerden.",
)
if bpm < 60:
return ("good", "Günstig / athletisch", "Häufig bei gut trainierten Personen im unteren Normbereich.")
if bpm <= 100:
return ("good", "Im üblichen Normbereich", "Typischer Ruhepuls bei Erwachsenen oft ca. 60100 bpm.")
if bpm <= 110:
return ("warn", "Leicht erhöht", "Kann durch Stress, Krankheit, Koffein oder Untrainiertheit erhöht sein — Verlauf beobachten.")
return ("bad", "Deutlich erhöht", "Bei anhaltend hohem Ruhepuls medizinische Abklärung sinnvoll.")
def assess_hrv_ms(ms: float) -> tuple:
_ = ms
return (
"neutral",
"Individuell",
"HRV (ms) ist sehr personenabhängig; Aussagekraft vor allem im Vergleich zu deiner eigenen Basis/Trend.",
)
def assess_blood_pressure(systolic: float, diastolic: float) -> tuple:
sys_, dia = systolic, diastolic
if sys_ >= 180 or dia >= 110:
return ("bad", "Sehr hoch", "Sehr hohe Werte — bei Beschwerden oder neu aufgetreten ärztlich zeitnah abklären.")
if sys_ >= 140 or dia >= 90:
return (
"bad",
"Erhöht",
"Liegt in einem Bereich, der oft als Hypertonie eingestuft wird — Bestätigung und Beratung durch ärztliche Messung.",
)
if sys_ >= 130 or dia >= 85:
return ("warn", "Hochnormal", "Oberer Normal-/hochnormaler Bereich — Lebensstil und Verlauf beachten.")
if sys_ < 120 and dia < 80:
return ("good", "Optimal", "Liegt in einem oft als günstig beschriebenen Bereich (<120/80 mmHg).")
return ("good", "Normal", "Im gängigen Zielbereich für viele Erwachsene.")
def assess_spo2(pct: float) -> tuple:
if pct >= 97:
return ("good", "Günstig", "Sauerstoffsättigung im üblichen Zielbereich.")
if pct >= 95:
return ("good", "Unauffällig", "Häufig noch als normal eingestuft; Verlauf bei Atembeschwerden beobachten.")
if pct >= 90:
return ("warn", "Leicht vermindert", "Unter 95 % kann je nach Kontext relevant sein — bei Symptomen abklären.")
return ("bad", "Niedrig", "Niedrige SpO2 — bei anhaltend unter 90 % oder Beschwerden ärztlich vorstellen.")
def assess_respiratory_rate(rpm: float) -> tuple:
if 12 <= rpm <= 20:
return ("good", "Im üblichen Bereich", "Ruheatmung oft ca. 1220/min.")
if 10 <= rpm < 12 or 20 < rpm <= 24:
return ("warn", "Grenzbereich", "Leicht außerhalb des häufig zitierten Ruhebereichs — Kontext (Belastung, Stress) beachten.")
return ("bad", "Auffällig", "Deutlich außerhalb typischer Ruhewerte — bei Beschwerden medizinisch abklären.")
def assess_vo2_max(value: float) -> tuple:
_ = value
return (
"neutral",
"Orientativ",
"VO2max hängt stark von Alter, Geschlecht und Messmethode ab; Trends in der App sind aussagekräftiger als Einzelwerte.",
)
def build_vital_items_from_rows(
vitals_row: Optional[Dict[str, Any]],
bp_row: Optional[Dict[str, Any]],
) -> List[Dict[str, Any]]:
items: List[Dict[str, Any]] = []
order = 0
if vitals_row:
rhr = vitals_row.get("resting_hr")
if rhr is not None:
v = safe_float(rhr)
t, z, h = assess_resting_hr(v)
items.append(_item("resting_hr", "Ruhepuls", f"{v:.0f} bpm", t, z, h, order))
order += 1
hrv = vitals_row.get("hrv")
if hrv is not None:
v = safe_float(hrv)
t, z, h = assess_hrv_ms(v)
items.append(_item("hrv", "HRV", f"{v:.0f} ms", t, z, h, order))
order += 1
vo2 = vitals_row.get("vo2_max")
if vo2 is not None:
v = safe_float(vo2)
t, z, h = assess_vo2_max(v)
items.append(_item("vo2_max", "VO2max", f"{v:.1f} ml/kg/min", t, z, h, order))
order += 1
spo2 = vitals_row.get("spo2")
if spo2 is not None:
v = safe_float(spo2)
t, z, h = assess_spo2(v)
items.append(_item("spo2", "SpO2", f"{v:.0f} %", t, z, h, order))
order += 1
rr = vitals_row.get("respiratory_rate")
if rr is not None:
v = safe_float(rr)
t, z, h = assess_respiratory_rate(v)
items.append(_item("respiratory_rate", "Atemfrequenz", f"{v:.0f} /min", t, z, h, order))
order += 1
if bp_row and bp_row.get("systolic") is not None and bp_row.get("diastolic") is not None:
sys_v = safe_float(bp_row["systolic"])
dia_v = safe_float(bp_row["diastolic"])
t, z, h = assess_blood_pressure(sys_v, dia_v)
items.append(_item("blood_pressure", "Blutdruck", f"{sys_v:.0f}/{dia_v:.0f} mmHg", t, z, h, order))
return items

View File

@ -1431,7 +1431,7 @@ def get_sleep_debt_chart(
@router.get("/vital-signs-matrix")
def get_vital_signs_matrix_chart(
days: int = Query(default=7, ge=7, le=30),
days: int = Query(default=7, ge=7, le=365),
session: dict = Depends(require_auth)
) -> Dict:
"""Vital signs matrix (R5)."""

View File

@ -5,6 +5,7 @@ import {
Line,
BarChart,
Bar,
Cell,
XAxis,
YAxis,
Tooltip,
@ -13,11 +14,26 @@ import {
} from 'recharts'
import { api } from '../utils/api'
import KpiTilesOverview from './KpiTilesOverview'
import { getStatusColor } from '../utils/interpret'
import { getStatusColor, getStatusBg } from '../utils/interpret'
import dayjs from 'dayjs'
const fmtDate = (d) => dayjs(d).format('DD.MM.')
function vitalToneToUi(tone) {
if (tone === 'good') return 'good'
if (tone === 'bad') return 'bad'
if (tone === 'neutral') return 'neutral'
return 'warn'
}
function barFillForTone(tone) {
const ui = vitalToneToUi(tone)
if (ui === 'good') return '#1D9E75'
if (ui === 'bad') return '#D85A30'
if (ui === 'neutral') return '#6B7280'
return '#EF9F27'
}
function ChartCard({ title, loading, error, children }) {
return (
<div className="card" style={{ marginBottom: 12 }}>
@ -300,38 +316,166 @@ export default function RecoveryDashboardOverview({
}
const renderVitalSigns = () => {
if (!vitalsData || vitalsData.metadata?.confidence === 'insufficient') {
if (!vitalsData) {
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
Keine aktuellen Vitalwerte
Keine Vital-Matrix-Daten
</div>
)
}
const chartData = vitalsData.data.labels.map((label, i) => ({
name: label,
value: vitalsData.data.datasets[0]?.data[i],
const meta = vitalsData.metadata || {}
const items = meta.vital_items || []
const ds0 = vitalsData.data?.datasets?.[0]
const hasRawChart =
Array.isArray(vitalsData.data?.labels) &&
vitalsData.data.labels.length > 0 &&
Array.isArray(ds0?.data) &&
ds0.data.length > 0
const ins = meta.confidence === 'insufficient'
if (ins && items.length === 0 && !hasRawChart) {
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
{meta.message || 'Keine aktuellen Vitalwerte'}
</div>
)
}
let chartRows = items.map((it) => ({
name: it.label_de,
value: Number(it.bar_value ?? 0),
fill: barFillForTone(it.tone),
tone: it.tone,
}))
if (chartRows.length === 0 && hasRawChart) {
const bg = ds0.backgroundColor
chartRows = vitalsData.data.labels.map((name, i) => ({
name,
value: Number(ds0.data[i] ?? 0),
fill: Array.isArray(bg) ? bg[i] || '#1D9E75' : bg || '#1D9E75',
tone: 'neutral',
}))
}
if (items.length === 0 && chartRows.length === 0) {
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
Keine Vitalwerte zur Anzeige (Server lieferte weder Kennzeilen noch Diagrammdaten).
</div>
)
}
const vitDate = meta.vitals_measured_at
const bpDate = meta.blood_pressure_measured_at
const disclaimer = meta.disclaimer_de
return (
<>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: 20 }} layout="horizontal">
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis type="number" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} width={120} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
<Bar dataKey="value" fill="#1D9E75" name="Wert" />
</BarChart>
</ResponsiveContainer>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
Letzte {vitalsData.metadata.data_points} Messwerte ({vDays} Tage)
{items.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 14 }}>
{items.map((it) => {
const stripe =
it.tone === 'good'
? getStatusColor('good')
: it.tone === 'bad'
? getStatusColor('bad')
: it.tone === 'warn'
? getStatusColor('warn')
: '#6B7280'
const bg =
it.tone === 'good'
? getStatusBg('good')
: it.tone === 'bad'
? getStatusBg('bad')
: it.tone === 'warn'
? getStatusBg('warn')
: 'var(--surface2)'
return (
<div
key={it.key}
style={{
borderRadius: 8,
padding: '10px 12px',
border: '1px solid var(--border)',
borderLeft: `4px solid ${stripe}`,
background: bg,
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text1)' }}>{it.label_de}</span>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text1)' }}>{it.value_display}</span>
<span
style={{
fontSize: 11,
fontWeight: 600,
padding: '2px 8px',
borderRadius: 6,
background: 'var(--surface)',
color: stripe,
border: `1px solid ${stripe}`,
}}
>
{it.zone_label_de}
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text2)', lineHeight: 1.45 }}>{it.hint_de}</div>
</div>
)
})}
</div>
) : null}
{items.length === 0 && chartRows.length > 0 ? (
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>
Diagramm aus Server-Daten (ohne Zonen-Detail bitte App aktualisieren oder Cache leeren).
</div>
) : null}
{chartRows.length > 0 ? (
<>
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 6 }}>
Relative Einordnung (0100, nur Übersicht keine körperliche Messgröße)
</div>
<ResponsiveContainer width="100%" height={Math.max(200, chartRows.length * 36)}>
<BarChart data={chartRows} margin={{ top: 4, right: 8, bottom: 0, left: 8 }} layout="horizontal">
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis type="number" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={[0, 100]} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} width={100} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
formatter={(v) => [`${Number(v).toFixed(0)} (relativ)`, 'Einordnung']}
/>
<Bar dataKey="value" name="Einordnung" radius={[0, 3, 3, 0]}>
{chartRows.map((row, i) => (
<Cell key={`c-${i}`} fill={row.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</>
) : null}
<div style={{ marginTop: 10, fontSize: 10, color: 'var(--text3)', lineHeight: 1.45 }}>
{vitDate ? (
<>
Baseline-Vitals Stand: <strong>{fmtDate(vitDate)}</strong>
</>
) : null}
{vitDate && bpDate ? ' · ' : null}
{bpDate ? (
<>
Blutdruck Stand: <strong>{fmtDate(bpDate)}</strong>
</>
) : null}
{!vitDate && !bpDate ? <>Anzeige-Zeitraum Vital-Matrix: {vDays} Tage</> : null}
</div>
{disclaimer ? (
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', fontStyle: 'italic' }}>{disclaimer}</div>
) : null}
</>
)
}