feat: add vitals history analytics to recovery dashboard
- Integrated the `build_vitals_history_and_analytics` function into the recovery dashboard to provide historical insights on vital signs. - Updated the `get_recovery_dashboard_viz_bundle` function to include a new chart for vitals history, enhancing the data visualization capabilities. - Enhanced the `RecoveryDashboardOverview` component to render vitals history, including improved messaging for insufficient data and visual representation of trends.
This commit is contained in:
parent
819914b7cc
commit
e7bcdc3228
|
|
@ -14,6 +14,7 @@ from data_layer.recovery_chart_payloads import (
|
|||
build_sleep_duration_quality_chart_payload,
|
||||
build_vital_signs_matrix_chart_payload,
|
||||
)
|
||||
from data_layer.vitals_fitness_insights import build_vitals_history_and_analytics
|
||||
from data_layer.recovery_interpretation import (
|
||||
build_recovery_dashboard_kpi_tiles,
|
||||
build_recovery_progress_insights,
|
||||
|
|
@ -88,6 +89,7 @@ def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, A
|
|||
"sleep_duration_quality": build_sleep_duration_quality_chart_payload(profile_id, chart_days),
|
||||
"sleep_debt": build_sleep_debt_chart_payload(profile_id, chart_days),
|
||||
"vital_signs_matrix": build_vital_signs_matrix_chart_payload(profile_id, vital_days),
|
||||
"vitals_history": build_vitals_history_and_analytics(profile_id, vital_days),
|
||||
}
|
||||
|
||||
conf = "medium"
|
||||
|
|
|
|||
280
backend/data_layer/vitals_fitness_insights.py
Normal file
280
backend/data_layer/vitals_fitness_insights.py
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
"""
|
||||
Vitalwerte: Zeitreihen + einfache Fitness-/Recovery-Einordnung (Layer 1, Issue 53).
|
||||
|
||||
Keine Diagnose — deskriptive Trends, Korrelationen und Varianz-Hinweise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from db import get_db, get_cursor
|
||||
from data_layer.utils import safe_float, serialize_dates
|
||||
|
||||
SERIES_CONFIG = (
|
||||
("resting_hr", "Ruhepuls", "bpm", "#3B82F6"),
|
||||
("hrv", "HRV", "ms", "#1D9E75"),
|
||||
("vo2_max", "VO2max", "ml/kg/min", "#8B5CF6"),
|
||||
("spo2", "SpO2", "%", "#0EA5E9"),
|
||||
("respiratory_rate", "Atemfrequenz", "/min", "#F59E0B"),
|
||||
)
|
||||
|
||||
|
||||
def _date_to_ord(d: Any) -> float:
|
||||
if hasattr(d, "toordinal"):
|
||||
return float(d.toordinal())
|
||||
if isinstance(d, str):
|
||||
return float(datetime.fromisoformat(d[:10]).date().toordinal())
|
||||
return 0.0
|
||||
|
||||
|
||||
def _linear_slope(dates: Sequence[Any], values: Sequence[float]) -> float:
|
||||
if len(values) < 3 or len(dates) != len(values):
|
||||
return 0.0
|
||||
xs = [_date_to_ord(d) for d in dates]
|
||||
ys = list(values)
|
||||
n = len(xs)
|
||||
mx = sum(xs) / n
|
||||
my = sum(ys) / n
|
||||
den = sum((x - mx) ** 2 for x in xs)
|
||||
if den < 1e-9:
|
||||
return 0.0
|
||||
return sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / den
|
||||
|
||||
|
||||
def _pearson(xs: Sequence[float], ys: Sequence[float]) -> Optional[float]:
|
||||
n = len(xs)
|
||||
if n < 5 or len(ys) != n:
|
||||
return None
|
||||
mx = statistics.mean(xs)
|
||||
my = statistics.mean(ys)
|
||||
sx = statistics.pstdev(xs) if n > 1 else 0.0
|
||||
sy = statistics.pstdev(ys) if n > 1 else 0.0
|
||||
if sx < 1e-9 or sy < 1e-9:
|
||||
return None
|
||||
cov = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / n
|
||||
return cov / (sx * sy)
|
||||
|
||||
|
||||
def _daily_training_load(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]:
|
||||
"""Summe Trainingsminuten pro Kalendertag als Belastungs-Proxy."""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT date::text AS d, COALESCE(SUM(duration_min), 0)::float AS minutes
|
||||
FROM activity_log
|
||||
WHERE profile_id = %s AND date >= %s::date AND duration_min IS NOT NULL AND duration_min > 0
|
||||
GROUP BY date
|
||||
ORDER BY date
|
||||
""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return {r["d"]: float(r["minutes"]) for r in rows}
|
||||
|
||||
|
||||
def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT date::text AS d, resting_hr::float AS rhr
|
||||
FROM vitals_baseline
|
||||
WHERE profile_id = %s AND date >= %s::date AND resting_hr IS NOT NULL
|
||||
ORDER BY date
|
||||
""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
return {r["d"]: float(r["rhr"]) for r in cur.fetchall()}
|
||||
|
||||
|
||||
def build_vitals_history_and_analytics(profile_id: str, days: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Zeitreihen pro Kennzahl (eigene Einheit / eigene Skala im Frontend) + Kurz-Analytik.
|
||||
"""
|
||||
if days < 7:
|
||||
days = 7
|
||||
if days > 365:
|
||||
days = 365
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate
|
||||
FROM vitals_baseline
|
||||
WHERE profile_id = %s AND date >= %s
|
||||
ORDER BY date ASC
|
||||
""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
series: Dict[str, Any] = {}
|
||||
for key, label_de, unit, color in SERIES_CONFIG:
|
||||
pts: List[Dict[str, Any]] = []
|
||||
dates: List[Any] = []
|
||||
vals: List[float] = []
|
||||
for r in rows:
|
||||
v = r.get(key)
|
||||
if v is None:
|
||||
continue
|
||||
fv = safe_float(v)
|
||||
d = r["date"]
|
||||
d_iso = d.isoformat() if hasattr(d, "isoformat") else str(d)[:10]
|
||||
pts.append({"date": d_iso, "value": round(fv, 2)})
|
||||
dates.append(d)
|
||||
vals.append(fv)
|
||||
if pts:
|
||||
series[key] = {
|
||||
"key": key,
|
||||
"label_de": label_de,
|
||||
"unit": unit,
|
||||
"color": color,
|
||||
"points": pts,
|
||||
"n": len(pts),
|
||||
"last": vals[-1] if vals else None,
|
||||
"mean": round(statistics.mean(vals), 2) if len(vals) >= 1 else None,
|
||||
"stdev": round(statistics.pstdev(vals), 2) if len(vals) >= 2 else None,
|
||||
"slope_per_day": round(_linear_slope(dates, vals), 6) if len(vals) >= 3 else None,
|
||||
}
|
||||
|
||||
bullets: List[Dict[str, Any]] = []
|
||||
|
||||
# VO2max-Trend
|
||||
vo2 = series.get("vo2_max")
|
||||
if vo2 and vo2.get("n", 0) >= 4 and vo2.get("slope_per_day") is not None:
|
||||
s = vo2["slope_per_day"]
|
||||
if s > 0.002:
|
||||
bullets.append(
|
||||
{
|
||||
"key": "vo2_trend_up",
|
||||
"tone": "good",
|
||||
"title": "VO2max-Verlauf",
|
||||
"body": "Im gewählten Fenster steigt der erfasste VO2max tendenziell — häufig mit Trainingsreiz oder besserer Datenlage vereinbar.",
|
||||
}
|
||||
)
|
||||
elif s < -0.002:
|
||||
bullets.append(
|
||||
{
|
||||
"key": "vo2_trend_down",
|
||||
"tone": "warn",
|
||||
"title": "VO2max-Verlauf",
|
||||
"body": "VO2max zeigt im Fenster einen fallenden Trend — kann z. B. durch Pause, Krankheit oder Messrauschen entstehen; Verlauf beobachten.",
|
||||
}
|
||||
)
|
||||
|
||||
# Ruhepuls: letzte 7 vs davor (wenn genug Punkte)
|
||||
rhr = series.get("resting_hr")
|
||||
if rhr and rhr.get("points"):
|
||||
pts = rhr["points"]
|
||||
if len(pts) >= 10:
|
||||
last7 = [p["value"] for p in pts[-7:]]
|
||||
before = [p["value"] for p in pts[:-7][-14:]] if len(pts) > 7 else []
|
||||
if before:
|
||||
m7 = statistics.mean(last7)
|
||||
mb = statistics.mean(before)
|
||||
diff = m7 - mb
|
||||
if diff > 3:
|
||||
bullets.append(
|
||||
{
|
||||
"key": "rhr_short_high",
|
||||
"tone": "warn",
|
||||
"title": "Ruhepuls zuletzt höher",
|
||||
"body": f"Die letzten 7 Messungen liegen im Mittel ca. {diff:.1f} bpm über dem vorangehenden Fenster — kann mit Belastung, Stress, Schlaf oder Infekt zusammenhängen.",
|
||||
}
|
||||
)
|
||||
elif diff < -3:
|
||||
bullets.append(
|
||||
{
|
||||
"key": "rhr_short_low",
|
||||
"tone": "good",
|
||||
"title": "Ruhepuls zuletzt niedriger",
|
||||
"body": "Der Ruhepuls liegt im kurzen Vergleich unter dem vorherigen Mittel — oft mit Entlastung oder besserer Regeneration vereinbar (individuell).",
|
||||
}
|
||||
)
|
||||
if rhr.get("stdev") is not None and rhr["n"] >= 6:
|
||||
bullets.append(
|
||||
{
|
||||
"key": "rhr_var",
|
||||
"tone": "neutral",
|
||||
"title": "Schwankung Ruhepuls",
|
||||
"body": f"Standardabweichung im Fenster ca. {rhr['stdev']} bpm — kurzfristige Schwankungen sind normal; extreme Sprünge mit Kontext (Training, Schlaf) betrachten.",
|
||||
}
|
||||
)
|
||||
|
||||
# HRV: Varianz-Hinweis
|
||||
hrv_s = series.get("hrv")
|
||||
if hrv_s and hrv_s.get("stdev") and hrv_s["n"] >= 6:
|
||||
bullets.append(
|
||||
{
|
||||
"key": "hrv_var",
|
||||
"tone": "neutral",
|
||||
"title": "HRV-Schwankung",
|
||||
"body": f"HRV schwankt im Fenster (σ ≈ {hrv_s['stdev']} ms). Vergleich mit der eigenen Basis ist aussagekräftiger als Einzelwerte.",
|
||||
}
|
||||
)
|
||||
|
||||
# Belastung (Activity) vs Ruhepuls am Folgetag
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
load_by_d = _daily_training_load(cur, profile_id, cutoff)
|
||||
rhr_by_d = _rhr_by_date(cur, profile_id, cutoff)
|
||||
|
||||
pairs_load: List[float] = []
|
||||
pairs_rhr: List[float] = []
|
||||
for d_str, load_min in load_by_d.items():
|
||||
try:
|
||||
d0 = datetime.fromisoformat(d_str[:10]).date()
|
||||
except ValueError:
|
||||
continue
|
||||
d1 = (d0 + timedelta(days=1)).isoformat()
|
||||
if d1 in rhr_by_d and load_min > 0:
|
||||
pairs_load.append(load_min)
|
||||
pairs_rhr.append(rhr_by_d[d1])
|
||||
|
||||
r_pearson = _pearson(pairs_load, pairs_rhr) if len(pairs_load) >= 8 else None
|
||||
if r_pearson is not None:
|
||||
if r_pearson > 0.35:
|
||||
bullets.append(
|
||||
{
|
||||
"key": "load_rhr_pos",
|
||||
"tone": "warn",
|
||||
"title": "Belastung und Ruhepuls (Folgetag)",
|
||||
"body": "An Tagen nach höherer Trainingsdauer (Minuten-Summe) steigt der Ruhepuls am nächsten Morgen in deinen Daten tendenziell — typisches Muster während Erholungsreaktion (kein Kausalbeweis).",
|
||||
}
|
||||
)
|
||||
elif r_pearson < -0.25:
|
||||
bullets.append(
|
||||
{
|
||||
"key": "load_rhr_neg",
|
||||
"tone": "neutral",
|
||||
"title": "Belastung und Ruhepuls",
|
||||
"body": "Es zeigt sich ein leicht negatives Zusammenspiel zwischen Tages-Belastung und Folge-Ruhepuls in diesem Fenster — stark von Datenlage und Ausreißern abhängig.",
|
||||
}
|
||||
)
|
||||
|
||||
if not series:
|
||||
return {
|
||||
"chart_type": "vitals_dashboard",
|
||||
"window_days": days,
|
||||
"series": {},
|
||||
"analytics": {"bullets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"message": "Keine Vital-Zeitreihen im Fenster",
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"chart_type": "vitals_dashboard",
|
||||
"window_days": days,
|
||||
"series": serialize_dates(series),
|
||||
"analytics": {"bullets": bullets},
|
||||
"metadata": {
|
||||
"confidence": "medium",
|
||||
"note": "Deskriptive Auswertung; keine medizinische Diagnose.",
|
||||
"load_rhr_pairs_n": len(pairs_load),
|
||||
"load_rhr_correlation": round(r_pearson, 3) if r_pearson is not None else None,
|
||||
},
|
||||
}
|
||||
|
|
@ -1,17 +1,6 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
} from 'recharts'
|
||||
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
|
||||
import { api } from '../utils/api'
|
||||
import KpiTilesOverview from './KpiTilesOverview'
|
||||
import { getStatusColor, getStatusBg } from '../utils/interpret'
|
||||
|
|
@ -19,19 +8,11 @@ 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 insightBulletStripe(tone) {
|
||||
if (tone === 'good') return getStatusColor('good')
|
||||
if (tone === 'bad') return getStatusColor('bad')
|
||||
if (tone === 'neutral') return '#6B7280'
|
||||
return getStatusColor('warn')
|
||||
}
|
||||
|
||||
function ChartCard({ title, loading, error, children }) {
|
||||
|
|
@ -127,6 +108,7 @@ export default function RecoveryDashboardOverview({
|
|||
const sleepData = viz.charts?.sleep_duration_quality
|
||||
const debtData = viz.charts?.sleep_debt
|
||||
const vitalsData = viz.charts?.vital_signs_matrix
|
||||
const vitalsHistory = viz.charts?.vitals_history
|
||||
|
||||
const kpiTiles = (viz.kpi_tiles || []).map((t) => ({
|
||||
...t,
|
||||
|
|
@ -315,51 +297,146 @@ export default function RecoveryDashboardOverview({
|
|||
)
|
||||
}
|
||||
|
||||
const renderVitalsHistory = () => {
|
||||
const vh = vitalsHistory
|
||||
if (!vh) {
|
||||
return <div style={{ padding: 16, fontSize: 12, color: 'var(--text3)' }}>Keine Verlaufs-Daten (Bundle).</div>
|
||||
}
|
||||
if (vh.metadata?.confidence === 'insufficient') {
|
||||
return (
|
||||
<div style={{ padding: 16, fontSize: 12, color: 'var(--text3)', lineHeight: 1.5 }}>
|
||||
{vh.metadata?.message || 'Zu wenige Vitaldaten im gewählten Fenster für Verläufe.'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const series = vh.series || {}
|
||||
const keys = Object.keys(series)
|
||||
const bullets = vh.analytics?.bullets || []
|
||||
const corrNote = vh.metadata?.load_rhr_correlation
|
||||
const pairsN = vh.metadata?.load_rhr_pairs_n
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', minWidth: 0 }}>
|
||||
{bullets.length > 0 ? (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
|
||||
Einordnung (Vital & Belastung)
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{bullets.map((b) => (
|
||||
<div
|
||||
key={b.key}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderLeft: `4px solid ${insightBulletStripe(b.tone)}`,
|
||||
background: 'var(--surface2)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4 }}>{b.title}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', lineHeight: 1.45 }}>{b.body}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{corrNote != null && pairsN != null ? (
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)' }}>
|
||||
Korrelation Trainingsminuten (Tag) ↔ Ruhepuls (Folgetag): r ≈ {corrNote} (n = {pairsN} Paare)
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>
|
||||
Je Kennzahl eigene Skala (physische Einheit). Verlauf sinnvoll ab ca. 2–3 Messpunkten.
|
||||
</div>
|
||||
|
||||
{keys.map((k) => {
|
||||
const m = series[k]
|
||||
const pts = m.points || []
|
||||
if (pts.length === 0) return null
|
||||
const chartData = pts.map((p) => ({
|
||||
...p,
|
||||
d: fmtDate(p.date),
|
||||
}))
|
||||
const vals = pts.map((p) => p.value)
|
||||
const mn = Math.min(...vals)
|
||||
const mx = Math.max(...vals)
|
||||
const span = mx - mn
|
||||
const pad = span < 1e-9 ? Math.max(Math.abs(mn) * 0.05, 0.5) : span * 0.12
|
||||
|
||||
return (
|
||||
<div key={k} style={{ marginBottom: 16, width: '100%' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text2)', marginBottom: 4 }}>
|
||||
{m.label_de} ({m.unit})
|
||||
{m.n != null ? (
|
||||
<span style={{ fontWeight: 400, color: 'var(--text3)' }}> · n = {m.n}</span>
|
||||
) : null}
|
||||
{m.mean != null ? (
|
||||
<span style={{ fontWeight: 400, color: 'var(--text3)' }}> · Ø {m.mean}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{pts.length === 1 ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)', padding: '8px 0' }}>
|
||||
Ein Messpunkt ({m.last}) — weiter erfassen, um einen Verlauf zu sehen.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ width: '100%', height: 200, minHeight: 200 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="d" tick={{ fontSize: 9, fill: 'var(--text3)' }} interval="preserveStartEnd" />
|
||||
<YAxis
|
||||
domain={[mn - pad, mx + pad]}
|
||||
tick={{ fontSize: 9, fill: 'var(--text3)' }}
|
||||
width={44}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={m.color || '#1D9E75'}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
name={m.label_de}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<div style={{ padding: 12, fontSize: 12, color: 'var(--text3)' }}>Keine Vital-Zeitreihen im Fenster.</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderVitalSigns = () => {
|
||||
if (!vitalsData) {
|
||||
return (
|
||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
|
||||
Keine Vital-Matrix-Daten
|
||||
Keine Snapshot-Daten zur Vital-Matrix.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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) {
|
||||
if (ins && items.length === 0) {
|
||||
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).
|
||||
{meta.message || 'Keine zusammengefassten Vitalwerte für die Einordnung.'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -424,54 +501,19 @@ export default function RecoveryDashboardOverview({
|
|||
</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 (0–100, 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>
|
||||
Baseline-Vitals (Snapshot): <strong>{fmtDate(vitDate)}</strong>
|
||||
</>
|
||||
) : null}
|
||||
{vitDate && bpDate ? ' · ' : null}
|
||||
{bpDate ? (
|
||||
<>
|
||||
Blutdruck Stand: <strong>{fmtDate(bpDate)}</strong>
|
||||
Blutdruck: <strong>{fmtDate(bpDate)}</strong>
|
||||
</>
|
||||
) : null}
|
||||
{!vitDate && !bpDate ? <>Anzeige-Zeitraum Vital-Matrix: {vDays} Tage</> : null}
|
||||
{!vitDate && !bpDate ? <>Bezug: Vital-Matrix {vDays} Tage</> : null}
|
||||
</div>
|
||||
{disclaimer ? (
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', fontStyle: 'italic' }}>{disclaimer}</div>
|
||||
|
|
@ -538,9 +580,10 @@ export default function RecoveryDashboardOverview({
|
|||
|
||||
<ChartCard title="📊 Recovery Score">{renderRecoveryScore()}</ChartCard>
|
||||
<ChartCard title="📊 HRV & Ruhepuls">{renderHrvRhr()}</ChartCard>
|
||||
<ChartCard title="📈 Vitalwerte — Verlauf (je Kennzahl)">{renderVitalsHistory()}</ChartCard>
|
||||
<ChartCard title="📊 Schlaf: Dauer & Qualität">{renderSleepQuality()}</ChartCard>
|
||||
<ChartCard title="📊 Schlafschuld">{renderSleepDebt()}</ChartCard>
|
||||
<ChartCard title="📊 Vitalwerte Überblick">{renderVitalSigns()}</ChartCard>
|
||||
<ChartCard title="📋 Vitalwerte — aktuelle Einordnung">{renderVitalSigns()}</ChartCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user