feat: enhance recovery dashboard with optional average sleep KPI and structured insights
- Added an `include_avg_sleep_kpi` parameter to the `build_recovery_dashboard_kpi_tiles` function to conditionally include average sleep data in the dashboard. - Updated the `get_recovery_dashboard_viz_bundle` function to pass the new parameter, ensuring flexibility in data presentation. - Refactored the insights generation in the `vitals_fitness_insights.py` file to utilize a new structured approach for better organization of heart and VO2 insights. - Introduced new components in the frontend for displaying insights, improving the user experience and clarity of vital metrics.
This commit is contained in:
parent
857cc1043a
commit
61738cecb7
|
|
@ -42,6 +42,7 @@ def build_recovery_dashboard_kpi_tiles(
|
|||
hrv_vs_baseline_pct: Optional[float],
|
||||
rhr_vs_baseline_pct: Optional[float],
|
||||
merge_heart_autonomic_tiles: bool = True,
|
||||
include_avg_sleep_kpi: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
tiles: List[Dict[str, Any]] = []
|
||||
|
||||
|
|
@ -79,6 +80,7 @@ def build_recovery_dashboard_kpi_tiles(
|
|||
}
|
||||
)
|
||||
|
||||
if include_avg_sleep_kpi:
|
||||
tiles.append(
|
||||
{
|
||||
"key": "avg_sleep",
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, A
|
|||
avg_sleep,
|
||||
float(hrv_dev) if hrv_dev is not None else None,
|
||||
float(rhr_dev) if rhr_dev is not None else None,
|
||||
include_avg_sleep_kpi=False,
|
||||
)
|
||||
|
||||
insights = build_recovery_progress_insights(
|
||||
|
|
|
|||
|
|
@ -93,40 +93,56 @@ def _de_num_signed(x: float) -> str:
|
|||
return f"{x:+.1f}".replace(".", ",")
|
||||
|
||||
|
||||
def _build_consolidated_paragraphs(
|
||||
def _ins(
|
||||
key: str,
|
||||
section: str,
|
||||
title_de: str,
|
||||
body: str,
|
||||
tone: str = "neutral",
|
||||
) -> Dict[str, Any]:
|
||||
"""Ein strukturierter Hinweis für UI-Platzierung (section: heart | vo2)."""
|
||||
return {"key": key, "section": section, "title_de": title_de, "body": body, "tone": tone}
|
||||
|
||||
|
||||
def _build_section_insights(
|
||||
series: Dict[str, Any],
|
||||
hrv_vs_baseline_pct: Optional[float],
|
||||
rhr_vs_baseline_pct: Optional[float],
|
||||
r_pearson: Optional[float],
|
||||
pairs_n: int,
|
||||
) -> List[str]:
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Thematisch zusammengeführte Absätze — inhaltlich alle früheren Einzel-Karten (Bullets),
|
||||
ohne die Aussagen zu streichen (Redundanz nur bei wörtlicher Doppelung vermeiden).
|
||||
Gleiche Inhalte wie früher konsolidierter Fließtext, aber nach UI-Bereich getrennt.
|
||||
section: heart = Herz/Kreislauf/Training-Folge; vo2 = VO2max-Verlauf.
|
||||
"""
|
||||
paras: List[str] = []
|
||||
out: List[Dict[str, Any]] = []
|
||||
|
||||
# ── Referenzlage (HRV/Ruhepuls vs. ältere Basis), wie zuvor in KPI/Narrativ genutzt
|
||||
basis_bits: List[str] = []
|
||||
if hrv_vs_baseline_pct is not None:
|
||||
basis_bits.append(
|
||||
f"HRV liegt gegenüber der älteren Referenz bei {_de_num_signed(float(hrv_vs_baseline_pct))} %"
|
||||
f"HRV gegenüber älterer Referenz: {_de_num_signed(float(hrv_vs_baseline_pct))} %"
|
||||
)
|
||||
if rhr_vs_baseline_pct is not None:
|
||||
basis_bits.append(
|
||||
f"Ruhepuls relativ zur Referenz bei {_de_num_signed(float(rhr_vs_baseline_pct))} %"
|
||||
f"Ruhepuls relativ zur Referenz: {_de_num_signed(float(rhr_vs_baseline_pct))} %"
|
||||
)
|
||||
if basis_bits:
|
||||
paras.append(
|
||||
out.append(
|
||||
_ins(
|
||||
"heart_baseline",
|
||||
"heart",
|
||||
"Kurzfristiges Mittel vs. ältere Basis",
|
||||
" ".join(basis_bits)
|
||||
+ " — Vergleich kurzfristiges Mittel gegenüber älterer Basis; individuell interpretieren."
|
||||
+ " — Vergleich letzter Tage zum älteren Referenzmittel; individuell interpretieren (keine Diagnose).",
|
||||
"neutral",
|
||||
)
|
||||
)
|
||||
|
||||
rhr = series.get("resting_hr")
|
||||
hrv_s = series.get("hrv")
|
||||
|
||||
# ── Ruhepuls: letzte 7 Messungen vs. vorangehendes Fenster (wie frühere Karten)
|
||||
rhr_short_compare = ""
|
||||
rhr_short_body = ""
|
||||
r_short_tone = "neutral"
|
||||
if rhr and rhr.get("points") and len(rhr["points"]) >= 10:
|
||||
pts = rhr["points"]
|
||||
last7 = [p["value"] for p in pts[-7:]]
|
||||
|
|
@ -136,88 +152,110 @@ def _build_consolidated_paragraphs(
|
|||
mb = statistics.mean(before)
|
||||
diff = m7 - mb
|
||||
if diff > 3:
|
||||
rhr_short_compare = (
|
||||
rhr_short_body = (
|
||||
f"Die letzten 7 Messungen liegen im Mittel ca. {_de_num(diff)} bpm über dem vorangehenden Fenster — "
|
||||
"kann mit Belastung, Stress, Schlaf oder Infekt zusammenhängen."
|
||||
)
|
||||
r_short_tone = "warn"
|
||||
elif diff < -3:
|
||||
rhr_short_compare = (
|
||||
rhr_short_body = (
|
||||
"Der Ruhepuls liegt im kurzen Vergleich unter dem vorherigen Mittel — oft mit Entlastung oder "
|
||||
"besserer Regeneration vereinbar (individuell)."
|
||||
)
|
||||
r_short_tone = "good"
|
||||
|
||||
# ── Streuung: frühere Schwellen n ≥ 6 für die ausführlichen Varianz-Hinweise
|
||||
rhr_var_sentence = ""
|
||||
if (
|
||||
rhr
|
||||
and rhr.get("stdev") is not None
|
||||
and rhr.get("n", 0) >= 6
|
||||
):
|
||||
if rhr and rhr.get("stdev") is not None and rhr.get("n", 0) >= 6:
|
||||
rhr_var_sentence = (
|
||||
f"Standardabweichung im Fenster ca. {_de_num(float(rhr['stdev']))} bpm — kurzfristige Schwankungen sind normal; "
|
||||
"extreme Sprünge mit Kontext (Training, Schlaf) betrachten."
|
||||
f"Ruhepuls: Standardabweichung im Fenster ca. {_de_num(float(rhr['stdev']))} bpm — kurzfristige Schwankungen "
|
||||
"sind normal; extreme Sprünge mit Kontext (Training, Schlaf) betrachten."
|
||||
)
|
||||
|
||||
hrv_var_sentence = ""
|
||||
if (
|
||||
hrv_s
|
||||
and hrv_s.get("stdev") is not None
|
||||
and hrv_s.get("n", 0) >= 6
|
||||
):
|
||||
if hrv_s and hrv_s.get("stdev") is not None and hrv_s.get("n", 0) >= 6:
|
||||
hrv_var_sentence = (
|
||||
f"HRV schwankt im Fenster (σ ≈ {_de_num(float(hrv_s['stdev']))} ms). "
|
||||
f"HRV: σ im Fenster ca. {_de_num(float(hrv_s['stdev']))} ms — "
|
||||
"Vergleich mit der eigenen Basis ist aussagekräftiger als Einzelwerte."
|
||||
)
|
||||
|
||||
# Gestrichelte Linie = gleitender Mittelwert (neuer Kontext, ergänzt nicht ersetzt)
|
||||
ma_hint = (
|
||||
"Einzelwerte können stark springen; die gestrichelte Linie im Diagramm zeigt einen gleitenden Mittelwert "
|
||||
"Einzelwerte können stark springen; die gestrichelte Linie in den Verläufen zeigt einen gleitenden Mittelwert "
|
||||
"über bis zu sieben aufeinanderfolgende Messungen (nicht Kalendertage)."
|
||||
)
|
||||
|
||||
block_b_parts: List[str] = []
|
||||
if rhr_short_compare:
|
||||
block_b_parts.append(rhr_short_compare)
|
||||
streuung_parts: List[str] = [ma_hint]
|
||||
if rhr_var_sentence:
|
||||
block_b_parts.append(rhr_var_sentence)
|
||||
streuung_parts.append(rhr_var_sentence)
|
||||
if hrv_var_sentence:
|
||||
block_b_parts.append(hrv_var_sentence)
|
||||
if block_b_parts:
|
||||
paras.append(ma_hint + " " + " ".join(block_b_parts))
|
||||
elif series:
|
||||
# Kein Kurzvergleich/keine σ-Sätze, aber mindestens eine Vital-Zeitreihe: MA-Hinweis (Diagramm)
|
||||
paras.append(ma_hint)
|
||||
streuung_parts.append(hrv_var_sentence)
|
||||
if rhr or hrv_s:
|
||||
out.append(
|
||||
_ins(
|
||||
"heart_streuung_ma",
|
||||
"heart",
|
||||
"Streuung & gleitender Mittelwert",
|
||||
" ".join(streuung_parts),
|
||||
"neutral",
|
||||
)
|
||||
)
|
||||
|
||||
if rhr_short_body:
|
||||
out.append(_ins("heart_rhr_kurz", "heart", "Ruhepuls: Kurzvergleich", rhr_short_body, r_short_tone))
|
||||
|
||||
# ── VO2max: Wortlaut wie in den früheren Bullet-Karten
|
||||
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:
|
||||
paras.append(
|
||||
out.append(
|
||||
_ins(
|
||||
"vo2_trend_up",
|
||||
"vo2",
|
||||
"VO2max-Verlauf",
|
||||
"Im gewählten Fenster steigt der erfasste VO2max tendenziell — häufig mit Trainingsreiz oder "
|
||||
"besserer Datenlage vereinbar."
|
||||
"besserer Datenlage vereinbar.",
|
||||
"good",
|
||||
)
|
||||
)
|
||||
elif s < -0.002:
|
||||
paras.append(
|
||||
out.append(
|
||||
_ins(
|
||||
"vo2_trend_down",
|
||||
"vo2",
|
||||
"VO2max-Verlauf",
|
||||
"VO2max zeigt im Fenster einen fallenden Trend — kann z. B. durch Pause, Krankheit oder Messrauschen "
|
||||
"entstehen; Verlauf beobachten."
|
||||
"entstehen; Verlauf beobachten.",
|
||||
"warn",
|
||||
)
|
||||
)
|
||||
|
||||
# ── Belastung vs. Folge-Ruhepuls: frühere Formulierungen + r/n wo berechnet
|
||||
if r_pearson is not None and pairs_n >= 8:
|
||||
if r_pearson > 0.35:
|
||||
paras.append(
|
||||
out.append(
|
||||
_ins(
|
||||
"heart_load_rhr",
|
||||
"heart",
|
||||
"Training und Folge-Ruhepuls",
|
||||
(
|
||||
"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). "
|
||||
f"Korrelation (Trainingsminuten am Tag → Ruhepuls am Folgetag): r ≈ {r_pearson:.2f} bei n = {pairs_n} Paaren."
|
||||
),
|
||||
"warn",
|
||||
)
|
||||
)
|
||||
elif r_pearson < -0.25:
|
||||
paras.append(
|
||||
out.append(
|
||||
_ins(
|
||||
"heart_load_rhr_neg",
|
||||
"heart",
|
||||
"Training und Folge-Ruhepuls",
|
||||
"Es zeigt sich ein leicht negatives Zusammenspiel zwischen Tages-Belastung und Folge-Ruhepuls in diesem "
|
||||
f"Fenster — stark von Datenlage und Ausreißern abhängig. r ≈ {r_pearson:.2f}, n = {pairs_n} Paare."
|
||||
f"Fenster — stark von Datenlage und Ausreißern abhängig. r ≈ {r_pearson:.2f}, n = {pairs_n} Paare.",
|
||||
"neutral",
|
||||
)
|
||||
)
|
||||
|
||||
return [p for p in paras if p]
|
||||
return out
|
||||
|
||||
|
||||
def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]:
|
||||
|
|
@ -318,7 +356,7 @@ def build_vitals_history_and_analytics(
|
|||
r_pearson = _pearson(pairs_load, pairs_rhr) if len(pairs_load) >= 8 else None
|
||||
pairs_n = len(pairs_load)
|
||||
|
||||
consolidated = _build_consolidated_paragraphs(
|
||||
section_insights = _build_section_insights(
|
||||
series,
|
||||
hrv_vs_baseline_pct,
|
||||
rhr_vs_baseline_pct,
|
||||
|
|
@ -331,7 +369,11 @@ def build_vitals_history_and_analytics(
|
|||
"chart_type": "vitals_dashboard",
|
||||
"window_days": days,
|
||||
"series": {},
|
||||
"analytics": {"bullets": [], "consolidated_paragraphs": consolidated},
|
||||
"analytics": {
|
||||
"bullets": [],
|
||||
"consolidated_paragraphs": [],
|
||||
"section_insights": section_insights,
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"message": "Keine Vital-Zeitreihen im Fenster",
|
||||
|
|
@ -346,7 +388,8 @@ def build_vitals_history_and_analytics(
|
|||
"series": serialize_dates(series),
|
||||
"analytics": {
|
||||
"bullets": [],
|
||||
"consolidated_paragraphs": consolidated,
|
||||
"consolidated_paragraphs": [],
|
||||
"section_insights": section_insights,
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "medium",
|
||||
|
|
|
|||
|
|
@ -58,13 +58,120 @@ function VitalZoneHint({ item }) {
|
|||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, color: 'var(--text1)', marginRight: 6 }}>Zuletzt (Snapshot):</span>
|
||||
<span style={{ fontWeight: 600, color: 'var(--text1)', marginRight: 6 }}>Letzte Einordnung (Snapshot):</span>
|
||||
<span style={{ fontWeight: 600, color: stripe }}>{item.zone_label_de}</span>
|
||||
<span style={{ marginLeft: 6 }}>{item.hint_de}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** KPI «Herz & autonomes System» — kurze Lesart (kein Ersatz für ärztliche Bewertung). */
|
||||
function HeartAutonomicGuide() {
|
||||
return (
|
||||
<details style={{ marginBottom: 12, fontSize: 11, color: 'var(--text2)' }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 600, color: 'var(--text3)', listStyle: 'none' }}>
|
||||
Einordnungshilfe: KPI «Herz & autonomes System» & Diagramm
|
||||
</summary>
|
||||
<div style={{ marginTop: 8, lineHeight: 1.5, paddingLeft: 2 }}>
|
||||
<p style={{ margin: '0 0 8px' }}>
|
||||
Es handelt sich um <strong>Abweichungen in %</strong> vom älteren Referenzmittel (kurzfristiges Mittel vs. längere
|
||||
Basis) — nicht um absolute Normalwerte.
|
||||
</p>
|
||||
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||
<li>
|
||||
<strong>HRV</strong>: Positive % = zuletzt oft über der älteren Basis; sehr personenabhängig.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Ruhepuls</strong>: Negative % = niedriger als die Referenz; bei Training oft unkritisch günstig.
|
||||
</li>
|
||||
</ul>
|
||||
<p style={{ margin: '8px 0 0' }}>
|
||||
Das Liniendiagramm zeigt die Rohverläufe; in anderen Karten kann eine gestrichelte Linie den gleitenden Mittelwert
|
||||
anzeigen.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionInsightCard({ ins }) {
|
||||
const t = ['good', 'warn', 'bad', 'neutral'].includes(ins.tone) ? ins.tone : 'neutral'
|
||||
const stripe = insightBulletStripe(t)
|
||||
const bg =
|
||||
t === 'good' ? getStatusBg('good') : t === 'bad' ? getStatusBg('bad') : t === 'warn' ? getStatusBg('warn') : 'var(--surface2)'
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderLeft: `4px solid ${stripe}`,
|
||||
background: bg,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4, color: 'var(--text1)' }}>{ins.title_de}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', lineHeight: 1.45 }}>{ins.body}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SnapshotCards({ items }) {
|
||||
if (!items?.length) return null
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 12 }}>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
function ChartCard({ title, loading, error, children, description }) {
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
|
|
@ -168,11 +275,10 @@ export default function RecoveryDashboardOverview({
|
|||
vitalItemsByKey[it.key] = it
|
||||
})
|
||||
|
||||
const showVitalNarrativeBlock =
|
||||
vitalsHistory &&
|
||||
vitalsHistory.metadata?.confidence !== 'insufficient' &&
|
||||
(((vitalsHistory.analytics?.consolidated_paragraphs || []).length > 0 ||
|
||||
(vitalsHistory.analytics?.bullets || []).length > 0))
|
||||
const sectionInsights = vitalsHistory?.analytics?.section_insights || []
|
||||
const heartSectionInsights = sectionInsights.filter((s) => s.section === 'heart')
|
||||
const vo2SectionInsights = sectionInsights.filter((s) => s.section === 'vo2')
|
||||
const heartSnapshotItems = ['resting_hr', 'hrv', 'blood_pressure'].map((k) => vitalItemsByKey[k]).filter(Boolean)
|
||||
|
||||
const kpiTiles = (viz.kpi_tiles || []).map((t) => ({
|
||||
...t,
|
||||
|
|
@ -383,62 +489,8 @@ export default function RecoveryDashboardOverview({
|
|||
)
|
||||
}
|
||||
|
||||
/** Nur Fließtext Einordnung Vital & Belastung (für Block „Einschätzungen“). */
|
||||
const renderVitalBelastungNarrative = () => {
|
||||
const vh = vitalsHistory
|
||||
if (!vh || vh.metadata?.confidence === 'insufficient') return null
|
||||
const paragraphs = vh.analytics?.consolidated_paragraphs || []
|
||||
const bullets = vh.analytics?.bullets || []
|
||||
const showParagraphs = paragraphs.length > 0
|
||||
const showBulletsFallback = !showParagraphs && bullets.length > 0
|
||||
if (!showParagraphs && !showBulletsFallback) return null
|
||||
return (
|
||||
<div style={{ marginBottom: 0 }}>
|
||||
{showParagraphs ? (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
padding: '12px 14px',
|
||||
border: '1px solid var(--border)',
|
||||
borderLeft: '4px solid var(--accent)',
|
||||
background: 'var(--surface2)',
|
||||
fontSize: 12,
|
||||
color: 'var(--text2)',
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{paragraphs.map((text, i) => (
|
||||
<p key={i} style={{ margin: i === 0 ? 0 : '10px 0 0' }}>
|
||||
{text}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{showBulletsFallback ? (
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** VO2 / SpO2 / Atemfrequenz — Verlauf mit Zonen-Hinweis aus Snapshot; kein Ruhepuls/HRV (siehe kombiniertes Diagramm). */
|
||||
const renderWeitereVitalVerlaeufe = (vitalItemsByKey) => {
|
||||
/** VO2 / SpO2 / Atemfrequenz — Verlauf; VO2-Zusatztexte aus section_insights oben. */
|
||||
const renderWeitereVitalVerlaeufe = (vo2Insights, vitalItemsByKey) => {
|
||||
const vh = vitalsHistory
|
||||
if (!vh) {
|
||||
return <div style={{ padding: 12, fontSize: 12, color: 'var(--text3)' }}>Keine Verlaufs-Daten (Bundle).</div>
|
||||
|
|
@ -462,6 +514,13 @@ export default function RecoveryDashboardOverview({
|
|||
|
||||
return (
|
||||
<div style={{ width: '100%', minWidth: 0 }}>
|
||||
{vo2Insights.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
|
||||
{vo2Insights.map((ins) => (
|
||||
<SectionInsightCard key={ins.key} ins={ins} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10 }}>
|
||||
Gestrichelte Linie: gleitender Mittelwert (max. 7 aufeinanderfolgende Messungen). Y-Achse auf den Datenbereich
|
||||
begrenzt.
|
||||
|
|
@ -556,106 +615,6 @@ export default function RecoveryDashboardOverview({
|
|||
)
|
||||
}
|
||||
|
||||
const renderVitalSigns = () => {
|
||||
if (!vitalsData) {
|
||||
return (
|
||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
|
||||
Keine Snapshot-Daten zur Vital-Matrix.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const meta = vitalsData.metadata || {}
|
||||
const items = meta.vital_items || []
|
||||
const ins = meta.confidence === 'insufficient'
|
||||
if (ins && items.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>
|
||||
{meta.message || 'Keine zusammengefassten Vitalwerte für die Einordnung.'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const vitDate = meta.vitals_measured_at
|
||||
const bpDate = meta.blood_pressure_measured_at
|
||||
const disclaimer = meta.disclaimer_de
|
||||
|
||||
return (
|
||||
<>
|
||||
{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}
|
||||
|
||||
<div style={{ marginTop: 10, fontSize: 10, color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||
{vitDate ? (
|
||||
<>
|
||||
Baseline-Vitals (Snapshot): <strong>{fmtDate(vitDate)}</strong>
|
||||
</>
|
||||
) : null}
|
||||
{vitDate && bpDate ? ' · ' : null}
|
||||
{bpDate ? (
|
||||
<>
|
||||
Blutdruck: <strong>{fmtDate(bpDate)}</strong>
|
||||
</>
|
||||
) : null}
|
||||
{!vitDate && !bpDate ? <>Bezug: Vital-Matrix {vDays} Tage</> : null}
|
||||
</div>
|
||||
{disclaimer ? (
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', fontStyle: 'italic' }}>{disclaimer}</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
|
||||
|
|
@ -687,9 +646,11 @@ export default function RecoveryDashboardOverview({
|
|||
|
||||
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" marginBottom={16} />
|
||||
|
||||
{insights.length > 0 || showVitalNarrativeBlock ? (
|
||||
{insights.length > 0 ? (
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einschätzungen</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
|
||||
Überblick: Recovery & Schlaf
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{insights.map((ins) => {
|
||||
const t = ['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn'
|
||||
|
|
@ -709,14 +670,6 @@ export default function RecoveryDashboardOverview({
|
|||
</div>
|
||||
)
|
||||
})}
|
||||
{showVitalNarrativeBlock ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||
Vitalverlauf & Belastung (Text)
|
||||
</div>
|
||||
{renderVitalBelastungNarrative()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -732,7 +685,14 @@ export default function RecoveryDashboardOverview({
|
|||
>
|
||||
{renderRecoveryScore()}
|
||||
</ChartCard>
|
||||
<ChartCard title="Schlaf: Dauer & Qualität" description="Dauer (h) und Qualitätsanteil (%) — eigene Achsen.">
|
||||
<ChartCard
|
||||
title="Schlaf: Dauer & Qualität"
|
||||
description={
|
||||
sleepData && sleepData.metadata?.confidence !== 'insufficient' && sleepData.metadata?.avg_duration_hours != null
|
||||
? `Dauer (h) und Qualitätsanteil (%). Mittlere Schlafdauer im Chart-Fenster: ${sleepData.metadata.avg_duration_hours} h — gleiche Information wie früher in der KPI «Ø Schlafdauer», jetzt hier im Schlaf-Kontext.`
|
||||
: 'Dauer (h) und Qualitätsanteil (%). Sobald genug Daten vorliegen, siehst du die mittlere Schlafdauer unter dem Diagramm.'
|
||||
}
|
||||
>
|
||||
{renderSleepQuality()}
|
||||
</ChartCard>
|
||||
<ChartCard title="Schlafschuld" description="Kumulierte Differenz zur Zielschlafdauer.">
|
||||
|
|
@ -741,29 +701,56 @@ export default function RecoveryDashboardOverview({
|
|||
|
||||
<SectionHeading
|
||||
title="Herz & Kreislauf"
|
||||
hint="Ruhepuls und HRV nur hier im kombinierten Verlauf — keine zweite Darstellung darunter."
|
||||
hint="Text-Hinweise und Zonen-Snapshots zu Ruhepuls, HRV und Blutdruck; Verlauf nur im kombinierten Diagramm (keine zweite RHR/HRV-Linie unten)."
|
||||
/>
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einordnung & Kontext</div>
|
||||
<HeartAutonomicGuide />
|
||||
{heartSectionInsights.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
|
||||
{heartSectionInsights.map((ins) => (
|
||||
<SectionInsightCard key={ins.key} ins={ins} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>Letzte Messwerte (Zonen)</div>
|
||||
<SnapshotCards items={heartSnapshotItems} />
|
||||
{vitalsData?.metadata?.vitals_measured_at || vitalsData?.metadata?.blood_pressure_measured_at ? (
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
|
||||
{vitalsData?.metadata?.vitals_measured_at ? (
|
||||
<>
|
||||
Baseline-Vitals: <strong>{fmtDate(vitalsData.metadata.vitals_measured_at)}</strong>
|
||||
</>
|
||||
) : null}
|
||||
{vitalsData?.metadata?.vitals_measured_at && vitalsData?.metadata?.blood_pressure_measured_at ? ' · ' : null}
|
||||
{vitalsData?.metadata?.blood_pressure_measured_at ? (
|
||||
<>
|
||||
Blutdruck: <strong>{fmtDate(vitalsData.metadata.blood_pressure_measured_at)}</strong>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{vitalsData?.metadata?.disclaimer_de ? (
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', fontStyle: 'italic', marginBottom: 10 }}>
|
||||
{vitalsData.metadata.disclaimer_de}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ChartCard
|
||||
title="HRV & Ruhepuls"
|
||||
description="Zwei Y-Achsen: HRV (ms, links), Ruhepuls (bpm, rechts). Gleiche Tage wie oben."
|
||||
title="HRV & Ruhepuls — Zeitverlauf"
|
||||
description="Zwei Y-Achsen: HRV (ms, links), Ruhepuls (bpm, rechts). Gleicher Zeitraum wie die Charts oben."
|
||||
>
|
||||
{renderHrvRhr()}
|
||||
</ChartCard>
|
||||
|
||||
<SectionHeading
|
||||
title="Weitere Vitalparameter (Verlauf)"
|
||||
hint="VO2max, SpO2 und Atemfrequenz mit Zonen-Einordnung zum letzten Snapshot (farbig unter dem Titel)."
|
||||
hint="VO2max-Trendtexte erscheinen oberhalb des Diagramms. SpO2 und Atemfrequenz: Zonen zum letzten Snapshot unter dem Titel."
|
||||
/>
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Verläufe</div>
|
||||
{renderWeitereVitalVerlaeufe(vitalItemsByKey)}
|
||||
{renderWeitereVitalVerlaeufe(vo2SectionInsights, vitalItemsByKey)}
|
||||
</div>
|
||||
|
||||
<SectionHeading
|
||||
title="Aktuelle Messwerte & Zonen"
|
||||
hint="Neueste Baseline-Vitals und Blutdruck im Fenster — gleiche Logik wie Vital-Seite (keine Diagnose)."
|
||||
/>
|
||||
<ChartCard title="Snapshot & Einordnung">{renderVitalSigns()}</ChartCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user