Neue Aufbereitung Fitness Verlauf #97

Merged
Lars merged 5 commits from develop into main 2026-04-20 11:47:53 +02:00
4 changed files with 291 additions and 258 deletions
Showing only changes of commit 61738cecb7 - Show all commits

View File

@ -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,20 +80,21 @@ def build_recovery_dashboard_kpi_tiles(
}
)
tiles.append(
{
"key": "avg_sleep",
"category": "Ø Schlafdauer",
"icon": "🌙",
"value": f"{avg_sleep_hours:.1f} h".replace(".", ",") if avg_sleep_hours is not None else "",
"sublabel": "Im gewählten Fenster",
"status": "good" if avg_sleep_hours and avg_sleep_hours >= 7 else "warn",
"verdict": "Gut" if avg_sleep_hours and avg_sleep_hours >= 7 else "Hinweis",
"hoverTop": "Durchschnittliche Schlafdauer",
"hoverBody": "get_sleep_duration_data",
"keys": ["sleep_duration_avg"],
}
)
if include_avg_sleep_kpi:
tiles.append(
{
"key": "avg_sleep",
"category": "Ø Schlafdauer",
"icon": "🌙",
"value": f"{avg_sleep_hours:.1f} h".replace(".", ",") if avg_sleep_hours is not None else "",
"sublabel": "Im gewählten Fenster",
"status": "good" if avg_sleep_hours and avg_sleep_hours >= 7 else "warn",
"verdict": "Gut" if avg_sleep_hours and avg_sleep_hours >= 7 else "Hinweis",
"hoverTop": "Durchschnittliche Schlafdauer",
"hoverBody": "get_sleep_duration_data",
"keys": ["sleep_duration_avg"],
}
)
if merge_heart_autonomic_tiles and (
hrv_vs_baseline_pct is not None or rhr_vs_baseline_pct is not None

View File

@ -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(

View File

@ -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(
" ".join(basis_bits)
+ " — Vergleich kurzfristiges Mittel gegenüber älterer Basis; individuell interpretieren."
out.append(
_ins(
"heart_baseline",
"heart",
"Kurzfristiges Mittel vs. ältere Basis",
" ".join(basis_bits)
+ " — 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(
"Im gewählten Fenster steigt der erfasste VO2max tendenziell — häufig mit Trainingsreiz oder "
"besserer Datenlage vereinbar."
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.",
"good",
)
)
elif s < -0.002:
paras.append(
"VO2max zeigt im Fenster einen fallenden Trend — kann z. B. durch Pause, Krankheit oder Messrauschen "
"entstehen; Verlauf beobachten."
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.",
"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(
"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."
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(
"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."
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.",
"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",

View File

@ -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>
)
}