feat: add recovery_history_viz widget and enhance configuration handling
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s

- Introduced the `recovery_history_viz` widget to the dashboard, enabling users to visualize recovery history data.
- Updated widget configuration to include `recovery_history_viz` in the allowed widgets and added validation for its configuration.
- Enhanced the widget catalog with details for the new `recovery_history_viz` entry.
- Implemented default values and validation logic for the widget's configuration, ensuring proper handling of user inputs.
- Added tests to ensure proper validation of the `recovery_history_viz` widget configuration.
- Bumped application version to reflect the addition of the new widget.
This commit is contained in:
Lars 2026-04-22 10:18:02 +02:00
parent d22e0ba0a7
commit e20b321b64
16 changed files with 735 additions and 258 deletions

View File

@ -17,6 +17,7 @@ WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
"body_history_viz", "body_history_viz",
"nutrition_history_viz", "nutrition_history_viz",
"fitness_history_viz", "fitness_history_viz",
"recovery_history_viz",
"activity_overview", "activity_overview",
"kpi_board", "kpi_board",
"quick_capture", "quick_capture",
@ -111,6 +112,38 @@ _FITNESS_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
"show_chart_load_monitoring": False, "show_chart_load_monitoring": False,
} }
_RECOVERY_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
"show_layer_meta",
"show_kpis",
"show_progress_insights",
"show_sleep_section_heading",
"show_chart_recovery_score",
"show_chart_sleep_quality",
"show_chart_sleep_debt",
"show_heart_section_heading",
"show_heart_context_card",
"show_chart_hrv_rhr",
"show_vitals_extra_heading",
"show_vitals_extra_trends",
})
_RECOVERY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
"chart_days": 30,
"show_layer_meta": False,
"show_kpis": True,
"kpi_detail": "compact",
"show_progress_insights": False,
"show_sleep_section_heading": True,
"show_chart_recovery_score": True,
"show_chart_sleep_quality": True,
"show_chart_sleep_debt": False,
"show_heart_section_heading": True,
"show_heart_context_card": False,
"show_chart_hrv_rhr": True,
"show_vitals_extra_heading": False,
"show_vitals_extra_trends": False,
}
def _config_json_size_bytes(config: dict[str, Any]) -> int: def _config_json_size_bytes(config: dict[str, Any]) -> int:
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8")) return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8"))
@ -136,6 +169,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
return _validate_nutrition_history_viz_config({}) return _validate_nutrition_history_viz_config({})
if widget_id == "fitness_history_viz": if widget_id == "fitness_history_viz":
return _validate_fitness_history_viz_config({}) return _validate_fitness_history_viz_config({})
if widget_id == "recovery_history_viz":
return _validate_recovery_history_viz_config({})
return {} return {}
if widget_id == "body_overview": if widget_id == "body_overview":
@ -146,6 +181,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
return _validate_nutrition_history_viz_config(raw) return _validate_nutrition_history_viz_config(raw)
if widget_id == "fitness_history_viz": if widget_id == "fitness_history_viz":
return _validate_fitness_history_viz_config(raw) return _validate_fitness_history_viz_config(raw)
if widget_id == "recovery_history_viz":
return _validate_recovery_history_viz_config(raw)
if widget_id == "activity_overview": if widget_id == "activity_overview":
return _validate_chart_days_only(raw, label="activity_overview") return _validate_chart_days_only(raw, label="activity_overview")
if widget_id == "kpi_board": if widget_id == "kpi_board":
@ -359,6 +396,45 @@ def _validate_fitness_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
return out return out
def _validate_recovery_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
label = "recovery_history_viz"
allowed = _RECOVERY_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
unknown = set(raw) - allowed
if unknown:
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
out: dict[str, Any] = dict(_RECOVERY_HISTORY_VIZ_DEFAULTS)
for k in _RECOVERY_HISTORY_VIZ_BOOL_KEYS:
if k not in raw:
continue
v = raw[k]
if not isinstance(v, bool):
raise ValueError(f"{label}: {k} muss boolean sein")
out[k] = v
if "kpi_detail" in raw:
kd = raw["kpi_detail"]
if kd not in ("compact", "full"):
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
out["kpi_detail"] = kd
if "chart_days" in raw:
v = _parse_chart_days(raw["chart_days"], label)
if v < 7 or v > 90:
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
out["chart_days"] = v
if not out["show_kpis"] and not out["show_progress_insights"] and not out["show_heart_context_card"] and not out[
"show_vitals_extra_trends"
] and not any(
out[k]
for k in (
"show_chart_recovery_score",
"show_chart_sleep_quality",
"show_chart_sleep_debt",
"show_chart_hrv_rhr",
)
):
raise ValueError(f"{label}: mindestens KPIs, Überblick, Kontextkarte, Extra-Vitals oder ein Chart muss sichtbar sein")
return out
def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]: def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]:
allowed = frozenset({"chart_days"}) allowed = frozenset({"chart_days"})
unknown = set(raw) - allowed unknown = set(raw) - allowed

View File

@ -113,6 +113,46 @@ def test_fitness_history_viz_unknown_key():
validate_widget_entry_config("fitness_history_viz", {"evil": True}) validate_widget_entry_config("fitness_history_viz", {"evil": True})
def test_recovery_history_viz_empty_expands_defaults():
d = validate_widget_entry_config("recovery_history_viz", {})
assert d["chart_days"] == 30
assert d["show_kpis"] is True
assert d["show_chart_recovery_score"] is True
assert d["kpi_detail"] == "compact"
assert d["show_heart_context_card"] is False
assert d["show_vitals_extra_trends"] is False
def test_recovery_history_viz_chart_days_and_merge():
d = validate_widget_entry_config("recovery_history_viz", {"chart_days": 42})
assert d["chart_days"] == 42
assert d["show_layer_meta"] is False
with pytest.raises(ValueError):
validate_widget_entry_config("recovery_history_viz", {"chart_days": 3})
def test_recovery_history_viz_requires_visible_block():
with pytest.raises(ValueError):
validate_widget_entry_config(
"recovery_history_viz",
{
"show_kpis": False,
"show_progress_insights": False,
"show_heart_context_card": False,
"show_vitals_extra_trends": False,
"show_chart_recovery_score": False,
"show_chart_sleep_quality": False,
"show_chart_sleep_debt": False,
"show_chart_hrv_rhr": False,
},
)
def test_recovery_history_viz_unknown_key():
with pytest.raises(ValueError):
validate_widget_entry_config("recovery_history_viz", {"evil": True})
def test_welcome_config_rejected_unknown_key(): def test_welcome_config_rejected_unknown_key():
with pytest.raises(ValueError): with pytest.raises(ValueError):
validate_widget_entry_config("welcome", {"x": 1}) validate_widget_entry_config("welcome", {"x": 1})

View File

@ -33,7 +33,7 @@ def test_get_product_default_base_uses_db_when_valid(monkeypatch):
"widgets": [{"id": wid, "enabled": wid == "welcome"} for wid in sorted(ALLOWED_WIDGET_IDS)], "widgets": [{"id": wid, "enabled": wid == "welcome"} for wid in sorted(ALLOWED_WIDGET_IDS)],
} }
# Gleicher Pfad wie get_stored_product_default_validated: Widget-Configs werden normalisiert # Gleicher Pfad wie get_stored_product_default_validated: Widget-Configs werden normalisiert
# (z. B. body_history_viz / nutrition_history_viz / fitness_history_viz: leere config → volle Defaults in to_stored_dict). # (z. B. body_history_viz / nutrition_history_viz / fitness_history_viz / recovery_history_viz: leere config → volle Defaults in to_stored_dict).
expected = DashboardLayoutPayload.model_validate(small).to_stored_dict() expected = DashboardLayoutPayload.model_validate(small).to_stored_dict()
class _Cur: class _Cur:

View File

@ -30,7 +30,7 @@ MODULE_VERSIONS = {
"importdata": "1.0.0", "importdata": "1.0.0",
"membership": "2.1.0", "membership": "2.1.0",
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode) "workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
"app_dashboard": "1.15.0", # fitness_history_viz: Verlauf-Bundle-Widget + Config "app_dashboard": "1.16.0", # recovery_history_viz: Verlauf-Bundle-Widget + Config
"csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise "csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise
"admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response) "admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response)
} }

View File

@ -112,6 +112,11 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
"description": "Layer-2b fitness-dashboard-viz: schlanker Standard; Blöcke per show_* / kpi_detail; chart_days 790; Feature activity_entries", "description": "Layer-2b fitness-dashboard-viz: schlanker Standard; Blöcke per show_* / kpi_detail; chart_days 790; Feature activity_entries",
"requires_feature": "activity_entries", "requires_feature": "activity_entries",
}, },
{
"id": "recovery_history_viz",
"title": "Erholung (Verlauf-Bundle)",
"description": "Layer-2b recovery-dashboard-viz: schlanker Standard; Blöcke per show_* / kpi_detail; chart_days 790",
},
{ {
"id": "recovery_charts_panel", "id": "recovery_charts_panel",
"title": "Erholung — Charts R1R5", "title": "Erholung — Charts R1R5",

View File

@ -1,8 +1,8 @@
import RecoveryDashboardOverview from './RecoveryDashboardOverview' import RecoveryDashboardOverview from './RecoveryDashboardOverview'
/** /**
* @deprecated Nutze direkt {@link RecoveryDashboardOverview}. Wrapper für Dashboard-Widgets (days period). * @deprecated Nutze direkt {@link RecoveryDashboardOverview}. Wrapper (days externalPeriod).
*/ */
export default function RecoveryCharts({ days = 28 }) { export default function RecoveryCharts({ days = 28 }) {
return <RecoveryDashboardOverview period={days} hidePeriodSelector /> return <RecoveryDashboardOverview externalPeriod={days} hidePeriodSelector />
} }

View File

@ -4,6 +4,11 @@ import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianG
import { api } from '../utils/api' import { api } from '../utils/api'
import KpiTilesOverview from './KpiTilesOverview' import KpiTilesOverview from './KpiTilesOverview'
import { getStatusColor, getStatusBg } from '../utils/interpret' import { getStatusColor, getStatusBg } from '../utils/interpret'
import {
RECOVERY_HISTORY_VIZ_HISTORY_FULL,
filterRecoveryHistoryKpiTiles,
normalizeRecoveryHistoryVizConfig,
} from '../widgetSystem/recoveryHistoryVizConfig'
import dayjs from 'dayjs' import dayjs from 'dayjs'
const fmtDate = (d) => dayjs(d).format('DD.MM.') const fmtDate = (d) => dayjs(d).format('DD.MM.')
@ -194,17 +199,32 @@ function ChartCard({ title, loading, error, children, description }) {
/** /**
* Layer 2b: Erholung ein Request GET /api/charts/recovery-dashboard-viz (recovery_metrics). * Layer 2b: Erholung ein Request GET /api/charts/recovery-dashboard-viz (recovery_metrics).
* @param {number} [props.externalPeriod] Widget: feste Tage (790)
* @param {boolean} [props.embedded]
* @param {Record<string, unknown>} [props.visibility] Dashboard-Config; undefined = voller Verlauf
* @param {import('react').ReactNode} [props.footer]
*/ */
export default function RecoveryDashboardOverview({ export default function RecoveryDashboardOverview({
period: periodProp, period: periodProp,
onPeriodChange, onPeriodChange,
hidePeriodSelector = false, hidePeriodSelector = false,
externalPeriod,
embedded = false,
visibility,
footer = null,
}) { }) {
const nav = useNavigate() const nav = useNavigate()
const [internalPeriod, setInternalPeriod] = useState(28) const [internalPeriod, setInternalPeriod] = useState(28)
const controlled = periodProp !== undefined && typeof onPeriodChange === 'function' const controlled = periodProp !== undefined && typeof onPeriodChange === 'function'
const period = controlled ? periodProp : internalPeriod const period =
const setPeriod = controlled ? onPeriodChange : setInternalPeriod externalPeriod !== undefined ? externalPeriod : controlled ? periodProp : internalPeriod
const setPeriod =
externalPeriod !== undefined ? () => {} : controlled ? onPeriodChange : setInternalPeriod
const display =
visibility === undefined ? RECOVERY_HISTORY_VIZ_HISTORY_FULL : normalizeRecoveryHistoryVizConfig(visibility)
const chartH = embedded ? 176 : 200
const chartHVitals = embedded ? 200 : 220
const [viz, setViz] = useState(null) const [viz, setViz] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -230,10 +250,13 @@ export default function RecoveryDashboardOverview({
} }
}, [period]) }, [period])
const outerClass = embedded ? '' : 'card section-gap'
const showPeriodDropdown = !hidePeriodSelector && externalPeriod === undefined && !controlled
if (loading) { if (loading) {
return ( return (
<div className="card section-gap"> <div className={outerClass || undefined}>
<div className="card-title">Erholung & Vitalwerte</div> {!embedded && <div className="card-title">Erholung & Vitalwerte</div>}
<div className="spinner" style={{ margin: 24 }} /> <div className="spinner" style={{ margin: 24 }} />
</div> </div>
) )
@ -241,8 +264,8 @@ export default function RecoveryDashboardOverview({
if (err) { if (err) {
return ( return (
<div className="card section-gap"> <div className={outerClass || undefined}>
<div className="card-title">Erholung & Vitalwerte</div> {!embedded && <div className="card-title">Erholung & Vitalwerte</div>}
<div style={{ color: 'var(--danger)' }}>{err}</div> <div style={{ color: 'var(--danger)' }}>{err}</div>
</div> </div>
) )
@ -250,8 +273,8 @@ export default function RecoveryDashboardOverview({
if (!viz?.has_recovery_data) { if (!viz?.has_recovery_data) {
return ( return (
<div className="card section-gap"> <div className={outerClass || undefined}>
<div className="card-title">Erholung & Vitalwerte</div> {!embedded && <div className="card-title">Erholung & Vitalwerte</div>}
<p style={{ fontSize: 12, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}> <p style={{ fontSize: 12, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}>
{viz?.message || 'Noch keine Schlaf- oder Vitaldaten.'} Sobald du Schlaf oder morgendliche Vitalwerte erfasst {viz?.message || 'Noch keine Schlaf- oder Vitaldaten.'} Sobald du Schlaf oder morgendliche Vitalwerte erfasst
oder importierst, erscheinen Auswertungen hier. oder importierst, erscheinen Auswertungen hier.
@ -280,18 +303,19 @@ export default function RecoveryDashboardOverview({
const vo2SectionInsights = sectionInsights.filter((s) => s.section === 'vo2') const vo2SectionInsights = sectionInsights.filter((s) => s.section === 'vo2')
const heartSnapshotItems = ['resting_hr', 'hrv', 'blood_pressure'].map((k) => vitalItemsByKey[k]).filter(Boolean) const heartSnapshotItems = ['resting_hr', 'hrv', 'blood_pressure'].map((k) => vitalItemsByKey[k]).filter(Boolean)
const kpiTiles = (viz.kpi_tiles || []).map((t) => ({ const kpiTilesRaw = (viz.kpi_tiles || []).map((t) => ({
...t, ...t,
sublabel: sublabel:
typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}` : t.sublabel, typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}` : t.sublabel,
})) }))
const kpiTilesShown = display.show_kpis
? filterRecoveryHistoryKpiTiles(kpiTilesRaw, display.kpi_detail || 'full')
: []
const insights = viz.progress_insights || [] const insights = viz.progress_insights || []
const eff = viz.effective_window_days const eff = viz.effective_window_days
const cDays = viz.chart_days_used const cDays = viz.chart_days_used
const vDays = viz.vital_matrix_days_used const vDays = viz.vital_matrix_days_used
const showPeriodDropdown = !hidePeriodSelector && !controlled
const renderRecoveryScore = () => { const renderRecoveryScore = () => {
if (!recoveryData || recoveryData.metadata?.confidence === 'insufficient') { if (!recoveryData || recoveryData.metadata?.confidence === 'insufficient') {
return ( return (
@ -306,41 +330,43 @@ export default function RecoveryDashboardOverview({
})) }))
return ( return (
<> <>
<ResponsiveContainer width="100%" height={200}> <div style={{ width: '100%', minWidth: 0, height: chartH }}>
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}> <ResponsiveContainer width="100%" height="100%">
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" /> <LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<XAxis <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
dataKey="date" <XAxis
tick={{ fontSize: 9, fill: 'var(--text3)' }} dataKey="date"
tickLine={false} tick={{ fontSize: 9, fill: 'var(--text3)' }}
interval={Math.max(0, Math.floor(chartData.length / 6) - 1)} tickLine={false}
/> interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
<YAxis />
tick={{ fontSize: 9, fill: 'var(--text3)' }} <YAxis
tickLine={false} tick={{ fontSize: 9, fill: 'var(--text3)' }}
domain={[0, 100]} tickLine={false}
tickFormatter={(v) => (Number.isFinite(Number(v)) ? String(Math.round(Number(v))) : '')} domain={[0, 100]}
tickCount={6} tickFormatter={(v) => (Number.isFinite(Number(v)) ? String(Math.round(Number(v))) : '')}
width={36} tickCount={6}
/> width={36}
<Tooltip />
contentStyle={{ <Tooltip
background: 'var(--surface)', contentStyle={{
border: '1px solid var(--border)', background: 'var(--surface)',
borderRadius: 8, border: '1px solid var(--border)',
fontSize: 11, borderRadius: 8,
}} fontSize: 11,
/> }}
<Line />
type="monotone" <Line
dataKey="score" type="monotone"
stroke="#1D9E75" dataKey="score"
strokeWidth={2} stroke="#1D9E75"
name={recoveryData.data?.datasets?.[0]?.label || 'HRV (Proxy)'} strokeWidth={2}
dot={{ r: 2 }} name={recoveryData.data?.datasets?.[0]?.label || 'HRV (Proxy)'}
/> dot={{ r: 2 }}
</LineChart> />
</ResponsiveContainer> </LineChart>
</ResponsiveContainer>
</div>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center', lineHeight: 1.45 }}> <div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center', lineHeight: 1.45 }}>
KPI Recovery-Score (aktuell): <strong>{recoveryData.metadata.current_score}/100</strong> · Datenpunkte Kurve:{' '} KPI Recovery-Score (aktuell): <strong>{recoveryData.metadata.current_score}/100</strong> · Datenpunkte Kurve:{' '}
{recoveryData.metadata.data_points} {recoveryData.metadata.data_points}
@ -364,44 +390,46 @@ export default function RecoveryDashboardOverview({
})) }))
return ( return (
<> <>
<ResponsiveContainer width="100%" height={200}> <div style={{ width: '100%', minWidth: 0, height: chartH }}>
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}> <ResponsiveContainer width="100%" height="100%">
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" /> <LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<XAxis <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
dataKey="date" <XAxis
tick={{ fontSize: 9, fill: 'var(--text3)' }} dataKey="date"
tickLine={false} tick={{ fontSize: 9, fill: 'var(--text3)' }}
interval={Math.max(0, Math.floor(chartData.length / 6) - 1)} tickLine={false}
/> interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
<YAxis />
yAxisId="left" <YAxis
tick={{ fontSize: 9, fill: 'var(--text3)' }} yAxisId="left"
tickLine={false} tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickFormatter={formatAxisTick} tickLine={false}
tickCount={6} tickFormatter={formatAxisTick}
width={44} tickCount={6}
/> width={44}
<YAxis />
yAxisId="right" <YAxis
orientation="right" yAxisId="right"
tick={{ fontSize: 9, fill: 'var(--text3)' }} orientation="right"
tickLine={false} tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickFormatter={formatAxisTick} tickLine={false}
tickCount={6} tickFormatter={formatAxisTick}
width={44} tickCount={6}
/> width={44}
<Tooltip />
contentStyle={{ <Tooltip
background: 'var(--surface)', contentStyle={{
border: '1px solid var(--border)', background: 'var(--surface)',
borderRadius: 8, border: '1px solid var(--border)',
fontSize: 11, borderRadius: 8,
}} fontSize: 11,
/> }}
<Line yAxisId="left" type="monotone" dataKey="hrv" stroke="#1D9E75" strokeWidth={2} name="HRV (ms)" dot={{ r: 2 }} /> />
<Line yAxisId="right" type="monotone" dataKey="rhr" stroke="#3B82F6" strokeWidth={2} name="RHR (bpm)" dot={{ r: 2 }} /> <Line yAxisId="left" type="monotone" dataKey="hrv" stroke="#1D9E75" strokeWidth={2} name="HRV (ms)" dot={{ r: 2 }} />
</LineChart> <Line yAxisId="right" type="monotone" dataKey="rhr" stroke="#3B82F6" strokeWidth={2} name="RHR (bpm)" dot={{ r: 2 }} />
</ResponsiveContainer> </LineChart>
</ResponsiveContainer>
</div>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}> <div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
HRV Ø {hrvRhrData.metadata.avg_hrv}ms · RHR Ø {hrvRhrData.metadata.avg_rhr}bpm HRV Ø {hrvRhrData.metadata.avg_hrv}ms · RHR Ø {hrvRhrData.metadata.avg_rhr}bpm
</div> </div>
@ -424,29 +452,31 @@ export default function RecoveryDashboardOverview({
})) }))
return ( return (
<> <>
<ResponsiveContainer width="100%" height={200}> <div style={{ width: '100%', minWidth: 0, height: chartH }}>
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}> <ResponsiveContainer width="100%" height="100%">
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" /> <LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<XAxis <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
dataKey="date" <XAxis
tick={{ fontSize: 9, fill: 'var(--text3)' }} dataKey="date"
tickLine={false} tick={{ fontSize: 9, fill: 'var(--text3)' }}
interval={Math.max(0, Math.floor(chartData.length / 6) - 1)} tickLine={false}
/> interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
<YAxis yAxisId="left" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} /> />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={[0, 100]} /> <YAxis yAxisId="left" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<Tooltip <YAxis yAxisId="right" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={[0, 100]} />
contentStyle={{ <Tooltip
background: 'var(--surface)', contentStyle={{
border: '1px solid var(--border)', background: 'var(--surface)',
borderRadius: 8, border: '1px solid var(--border)',
fontSize: 11, borderRadius: 8,
}} fontSize: 11,
/> }}
<Line yAxisId="left" type="monotone" dataKey="duration" stroke="#3B82F6" strokeWidth={2} name="Dauer (h)" dot={{ r: 2 }} /> />
<Line yAxisId="right" type="monotone" dataKey="quality" stroke="#1D9E75" strokeWidth={2} name="Qualität (%)" dot={{ r: 2 }} /> <Line yAxisId="left" type="monotone" dataKey="duration" stroke="#3B82F6" strokeWidth={2} name="Dauer (h)" dot={{ r: 2 }} />
</LineChart> <Line yAxisId="right" type="monotone" dataKey="quality" stroke="#1D9E75" strokeWidth={2} name="Qualität (%)" dot={{ r: 2 }} />
</ResponsiveContainer> </LineChart>
</ResponsiveContainer>
</div>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}> <div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
Ø {sleepData.metadata.avg_duration_hours}h Schlaf Ø {sleepData.metadata.avg_duration_hours}h Schlaf
</div> </div>
@ -469,35 +499,37 @@ export default function RecoveryDashboardOverview({
const curDebt = debtData.metadata?.current_debt_hours const curDebt = debtData.metadata?.current_debt_hours
return ( return (
<> <>
<ResponsiveContainer width="100%" height={200}> <div style={{ width: '100%', minWidth: 0, height: chartH }}>
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}> <ResponsiveContainer width="100%" height="100%">
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" /> <LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<XAxis <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
dataKey="date" <XAxis
tick={{ fontSize: 9, fill: 'var(--text3)' }} dataKey="date"
tickLine={false} tick={{ fontSize: 9, fill: 'var(--text3)' }}
interval={Math.max(0, Math.floor(chartData.length / 6) - 1)} tickLine={false}
/> interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} /> />
<Tooltip <YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
contentStyle={{ <Tooltip
background: 'var(--surface)', contentStyle={{
border: '1px solid var(--border)', background: 'var(--surface)',
borderRadius: 8, border: '1px solid var(--border)',
fontSize: 11, borderRadius: 8,
}} fontSize: 11,
/> }}
<Line />
type="monotone" <Line
dataKey="debt" type="monotone"
stroke="#EF4444" dataKey="debt"
strokeWidth={2} stroke="#EF4444"
name={debtData.data?.datasets?.[0]?.label || 'Schlafschuld (h)'} strokeWidth={2}
dot={{ r: 2 }} name={debtData.data?.datasets?.[0]?.label || 'Schlafschuld (h)'}
connectNulls dot={{ r: 2 }}
/> connectNulls
</LineChart> />
</ResponsiveContainer> </LineChart>
</ResponsiveContainer>
</div>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}> <div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
Aktuelle Schuld: {curDebt != null ? Number(curDebt).toFixed(1) : '—'}h Aktuelle Schuld: {curDebt != null ? Number(curDebt).toFixed(1) : '—'}h
</div> </div>
@ -577,7 +609,7 @@ export default function RecoveryDashboardOverview({
Ein Messpunkt ({formatAxisTick(m.last)}) weiter erfassen, um einen Verlauf zu sehen. Ein Messpunkt ({formatAxisTick(m.last)}) weiter erfassen, um einen Verlauf zu sehen.
</div> </div>
) : ( ) : (
<div style={{ width: '100%', height: hasMa ? 220 : 200, minHeight: hasMa ? 220 : 200 }}> <div style={{ width: '100%', minWidth: 0, height: hasMa ? chartHVitals : chartH, minHeight: hasMa ? chartHVitals : chartH }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: 0 }}> <LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: 0 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" /> <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
@ -632,37 +664,43 @@ export default function RecoveryDashboardOverview({
} }
return ( return (
<div className="card section-gap"> <div className={outerClass || undefined}>
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}> {!embedded && (
<span>Erholung & Vitalwerte</span> <div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
{showPeriodDropdown ? ( <span>Erholung & Vitalwerte</span>
<label {showPeriodDropdown ? (
style={{ fontSize: 12, fontWeight: 500, color: 'var(--text3)', display: 'flex', alignItems: 'center', gap: 8 }} <label
> style={{ fontSize: 12, fontWeight: 500, color: 'var(--text3)', display: 'flex', alignItems: 'center', gap: 8 }}
Zeitraum
<select
className="form-input"
style={{ maxWidth: 140, padding: '6px 10px', fontSize: 13 }}
value={period}
onChange={(e) => setPeriod(Number(e.target.value))}
> >
<option value={7}>7 Tage</option> Zeitraum
<option value={28}>28 Tage</option> <select
<option value={90}>90 Tage</option> className="form-input"
<option value={9999}>Gesamt</option> style={{ maxWidth: 140, padding: '6px 10px', fontSize: 13 }}
</select> value={period}
</label> onChange={(e) => setPeriod(Number(e.target.value))}
) : null} >
</div> <option value={7}>7 Tage</option>
<option value={28}>28 Tage</option>
<option value={90}>90 Tage</option>
<option value={9999}>Gesamt</option>
</select>
</label>
) : null}
</div>
)}
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}> {display.show_layer_meta ? (
Daten-Layer Auswertung · Fenster ca. <strong>{eff}</strong> Tage · Chart-Horizont <strong>{cDays}</strong> Tage · <p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
Vital-Snapshot <strong>{vDays}</strong> Tage. Daten-Layer Auswertung · Fenster ca. <strong>{eff}</strong> Tage · Chart-Horizont <strong>{cDays}</strong> Tage ·
</p> Vital-Snapshot <strong>{vDays}</strong> Tage.
</p>
) : null}
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" marginBottom={16} /> {kpiTilesShown.length > 0 ? (
<KpiTilesOverview tiles={kpiTilesShown} heading="Kennzahlen" marginBottom={16} />
) : null}
{insights.length > 0 ? ( {display.show_progress_insights && insights.length > 0 ? (
<div style={{ marginBottom: 18 }}> <div style={{ marginBottom: 18 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}> <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
Überblick: Recovery & Schlaf Überblick: Recovery & Schlaf
@ -690,93 +728,113 @@ export default function RecoveryDashboardOverview({
</div> </div>
) : null} ) : null}
<SectionHeading {display.show_sleep_section_heading ? (
compactTop <SectionHeading
title="Schlaf & Erholung" compactTop
hint="Recovery-Score und Schlaf im gleichen Zeitraum wie die Kennzahlen oben." title="Schlaf & Erholung"
/> hint="Recovery-Score und Schlaf im gleichen Zeitraum wie die Kennzahlen oben."
<ChartCard />
title="HRV-Verlauf (kein Recovery-Score)" ) : null}
description={ {display.show_chart_recovery_score ? (
'Kurve = HRV-Rohwert (ms), auf 0100 begrenzt — nur zur Einordnung des Verlaufs. ' + <ChartCard
'Die KPI-Kachel «Recovery-Score» oben nutzt calculate_recovery_score_v2 (HRV, RHR, Schlaf, Last, …).' title="HRV-Verlauf (kein Recovery-Score)"
} description={
> 'Kurve = HRV-Rohwert (ms), auf 0100 begrenzt — nur zur Einordnung des Verlaufs. ' +
{renderRecoveryScore()} 'Die KPI-Kachel «Recovery-Score» oben nutzt calculate_recovery_score_v2 (HRV, RHR, Schlaf, Last, …).'
</ChartCard> }
<ChartCard >
title="Schlaf: Dauer & Qualität" {renderRecoveryScore()}
description={ </ChartCard>
sleepData && sleepData.metadata?.confidence !== 'insufficient' && sleepData.metadata?.avg_duration_hours != null ) : 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.` {display.show_chart_sleep_quality ? (
: 'Dauer (h) und Qualitätsanteil (%). Sobald genug Daten vorliegen, siehst du die mittlere Schlafdauer unter dem Diagramm.' <ChartCard
} title="Schlaf: Dauer & Qualität"
> description={
{renderSleepQuality()} sleepData && sleepData.metadata?.confidence !== 'insufficient' && sleepData.metadata?.avg_duration_hours != null
</ChartCard> ? `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.`
<ChartCard : 'Dauer (h) und Qualitätsanteil (%). Sobald genug Daten vorliegen, siehst du die mittlere Schlafdauer unter dem Diagramm.'
title="Schlafschuld" }
description={ >
'Gleiche Berechnung wie die KPI: Summe der nächtlichen Defizite gegenüber 7,5 h/Nacht im rollierenden 14-Tage-Fenster ' + {renderSleepQuality()}
'(Ziel derzeit fest im Code, nicht in den Einstellungen). Jeder Punkt = Schlafschuld mit Fensterende an diesem Datum — ' + </ChartCard>
'entspricht der KPI, wenn der letzte Punkt die letzte erfasste Nacht ist.' ) : null}
} {display.show_chart_sleep_debt ? (
> <ChartCard
{renderSleepDebt()} title="Schlafschuld"
</ChartCard> description={
'Gleiche Berechnung wie die KPI: Summe der nächtlichen Defizite gegenüber 7,5 h/Nacht im rollierenden 14-Tage-Fenster ' +
'(Ziel derzeit fest im Code, nicht in den Einstellungen). Jeder Punkt = Schlafschuld mit Fensterende an diesem Datum — ' +
'entspricht der KPI, wenn der letzte Punkt die letzte erfasste Nacht ist.'
}
>
{renderSleepDebt()}
</ChartCard>
) : null}
<SectionHeading {display.show_heart_section_heading ? (
title="Herz & Kreislauf" <SectionHeading
hint="Text-Hinweise und Zonen-Snapshots zu Ruhepuls, HRV und Blutdruck; Verlauf nur im kombinierten Diagramm (keine zweite RHR/HRV-Linie unten)." title="Herz & Kreislauf"
/> 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> ) : null}
<HeartAutonomicGuide /> {display.show_heart_context_card ? (
{heartSectionInsights.length > 0 ? ( <div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}> <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einordnung & Kontext</div>
{heartSectionInsights.map((ins) => ( <HeartAutonomicGuide />
<SectionInsightCard key={ins.key} ins={ins} /> {heartSectionInsights.length > 0 ? (
))} <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
</div> {heartSectionInsights.map((ins) => (
) : null} <SectionInsightCard key={ins.key} ins={ins} />
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>Letzte Messwerte (Zonen)</div> ))}
<SnapshotCards items={heartSnapshotItems} /> </div>
{vitalsData?.metadata?.vitals_measured_at || vitalsData?.metadata?.blood_pressure_measured_at ? ( ) : null}
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>Letzte Messwerte (Zonen)</div>
{vitalsData?.metadata?.vitals_measured_at ? ( <SnapshotCards items={heartSnapshotItems} />
<> {vitalsData?.metadata?.vitals_measured_at || vitalsData?.metadata?.blood_pressure_measured_at ? (
Baseline-Vitals: <strong>{fmtDate(vitalsData.metadata.vitals_measured_at)}</strong> <div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
</> {vitalsData?.metadata?.vitals_measured_at ? (
) : null} <>
{vitalsData?.metadata?.vitals_measured_at && vitalsData?.metadata?.blood_pressure_measured_at ? ' · ' : null} Baseline-Vitals: <strong>{fmtDate(vitalsData.metadata.vitals_measured_at)}</strong>
{vitalsData?.metadata?.blood_pressure_measured_at ? ( </>
<> ) : null}
Blutdruck: <strong>{fmtDate(vitalsData.metadata.blood_pressure_measured_at)}</strong> {vitalsData?.metadata?.vitals_measured_at && vitalsData?.metadata?.blood_pressure_measured_at ? ' · ' : null}
</> {vitalsData?.metadata?.blood_pressure_measured_at ? (
) : null} <>
</div> Blutdruck: <strong>{fmtDate(vitalsData.metadata.blood_pressure_measured_at)}</strong>
) : null} </>
{vitalsData?.metadata?.disclaimer_de ? ( ) : null}
<div style={{ fontSize: 10, color: 'var(--text3)', fontStyle: 'italic', marginBottom: 10 }}> </div>
{vitalsData.metadata.disclaimer_de} ) : null}
</div> {vitalsData?.metadata?.disclaimer_de ? (
) : null} <div style={{ fontSize: 10, color: 'var(--text3)', fontStyle: 'italic', marginBottom: 10 }}>
</div> {vitalsData.metadata.disclaimer_de}
<ChartCard </div>
title="HRV & Ruhepuls — Zeitverlauf" ) : null}
description="Zwei Y-Achsen: HRV (ms, links), Ruhepuls (bpm, rechts). Gleicher Zeitraum wie die Charts oben." </div>
> ) : null}
{renderHrvRhr()} {display.show_chart_hrv_rhr ? (
</ChartCard> <ChartCard
title="HRV & Ruhepuls — Zeitverlauf"
description="Zwei Y-Achsen: HRV (ms, links), Ruhepuls (bpm, rechts). Gleicher Zeitraum wie die Charts oben."
>
{renderHrvRhr()}
</ChartCard>
) : null}
<SectionHeading {display.show_vitals_extra_heading ? (
title="Weitere Vitalparameter (Verlauf)" <SectionHeading
hint="VO2max-Trendtexte erscheinen oberhalb des Diagramms. SpO2 und Atemfrequenz: Zonen zum letzten Snapshot unter dem Titel." title="Weitere Vitalparameter (Verlauf)"
/> 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> ) : null}
{renderWeitereVitalVerlaeufe(vo2SectionInsights, vitalItemsByKey)} {display.show_vitals_extra_trends ? (
</div> <div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Verläufe</div>
{renderWeitereVitalVerlaeufe(vo2SectionInsights, vitalItemsByKey)}
</div>
) : null}
{footer}
</div> </div>
) )
} }

View File

@ -26,7 +26,7 @@ export default function RecoveryChartsPanelWidget({ refreshTick = 0, chartDays }
Verlauf Verlauf
</button> </button>
</div> </div>
<RecoveryDashboardOverview key={`${refreshTick}-${days}`} period={days} hidePeriodSelector /> <RecoveryDashboardOverview key={`${refreshTick}-${days}`} externalPeriod={days} hidePeriodSelector />
</div> </div>
) )
} }

View File

@ -0,0 +1,29 @@
import { useNavigate } from 'react-router-dom'
import RecoveryHistoryVizSection from '../history/RecoveryHistoryVizSection'
import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays'
import { normalizeRecoveryHistoryVizConfig } from '../../widgetSystem/recoveryHistoryVizConfig'
/**
* Verlauf Erholung als Dashboard-Widget: GET /charts/recovery-dashboard-viz (Layer 2b), Umfang über Layout-Config.
* @param {{ refreshTick?: number, recoveryHistoryVizConfig?: Record<string, unknown> }} props
*/
export default function RecoveryHistoryVizWidget({ refreshTick = 0, recoveryHistoryVizConfig }) {
const nav = useNavigate()
const cfg = normalizeRecoveryHistoryVizConfig(recoveryHistoryVizConfig)
const days = normalizeBodyChartDays(cfg.chart_days)
return (
<div className="card section-gap" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Erholung (Verlauf-Bundle)</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>recovery-dashboard-viz · {days} Tage</div>
</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => nav('/history', { state: { tab: 'activity' } })}>
Verlauf
</button>
</div>
<RecoveryHistoryVizSection key={`${refreshTick}-${days}`} externalPeriod={days} embedded visibility={cfg} />
</div>
)
}

View File

@ -0,0 +1,26 @@
import RecoveryDashboardOverview from '../RecoveryDashboardOverview'
/**
* Verlauf Erholung bzw. Dashboard-Widget: GET /charts/recovery-dashboard-viz (Layer 2b).
*/
export default function RecoveryHistoryVizSection({
externalPeriod,
period,
onPeriodChange,
hidePeriodSelector,
embedded,
visibility,
footer,
}) {
return (
<RecoveryDashboardOverview
period={period}
onPeriodChange={onPeriodChange}
hidePeriodSelector={hidePeriodSelector}
externalPeriod={externalPeriod}
embedded={embedded}
visibility={visibility}
footer={footer}
/>
)
}

View File

@ -14,6 +14,7 @@ import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor' import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor'
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor' import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor' import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
import { import {
moveWidget, moveWidget,
moveWidgetToIndex, moveWidgetToIndex,
@ -28,6 +29,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([
'nutrition_detail_charts', 'nutrition_detail_charts',
'nutrition_history_viz', 'nutrition_history_viz',
'fitness_history_viz', 'fitness_history_viz',
'recovery_history_viz',
'recovery_charts_panel', 'recovery_charts_panel',
]) ])
@ -552,6 +554,23 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) {
} }
/> />
)} )}
{w.id === 'recovery_history_viz' && (
<RecoveryHistoryVizConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
})
)
}
/>
)}
</li> </li>
) )
})} })}

View File

@ -15,6 +15,7 @@ import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor' import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor'
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor' import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor' import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor' import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
/** Widgets mit optionalem config.chart_days (790), gleiche UX im Editor */ /** Widgets mit optionalem config.chart_days (790), gleiche UX im Editor */
@ -25,6 +26,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([
'nutrition_detail_charts', 'nutrition_detail_charts',
'nutrition_history_viz', 'nutrition_history_viz',
'fitness_history_viz', 'fitness_history_viz',
'recovery_history_viz',
'recovery_charts_panel', 'recovery_charts_panel',
]) ])
@ -334,7 +336,9 @@ export default function DashboardLabPage() {
? 'Ernährung (Verlauf-Bundle)' ? 'Ernährung (Verlauf-Bundle)'
: w.id === 'fitness_history_viz' : w.id === 'fitness_history_viz'
? 'Fitness (Verlauf-Bundle)' ? 'Fitness (Verlauf-Bundle)'
: 'Erholung — Charts'}{' '} : w.id === 'recovery_history_viz'
? 'Erholung (Verlauf-Bundle)'
: 'Erholung — Charts'}{' '}
Zeitraum (Tage): {BODY_CHART_DAYS_MIN}{BODY_CHART_DAYS_MAX} Zeitraum (Tage): {BODY_CHART_DAYS_MIN}{BODY_CHART_DAYS_MAX}
</label> </label>
<input <input
@ -356,7 +360,9 @@ export default function DashboardLabPage() {
? 'Ernährung Verlauf-Bundle Zeitraum in Tagen' ? 'Ernährung Verlauf-Bundle Zeitraum in Tagen'
: w.id === 'fitness_history_viz' : w.id === 'fitness_history_viz'
? 'Fitness Verlauf-Bundle Zeitraum in Tagen' ? 'Fitness Verlauf-Bundle Zeitraum in Tagen'
: 'Erholungs-Charts Zeitraum in Tagen' : w.id === 'recovery_history_viz'
? 'Erholung Verlauf-Bundle Zeitraum in Tagen'
: 'Erholungs-Charts Zeitraum in Tagen'
} }
value={ value={
chartDaysDraftByWidgetId[w.id] !== undefined chartDaysDraftByWidgetId[w.id] !== undefined
@ -443,6 +449,23 @@ export default function DashboardLabPage() {
} }
/> />
)} )}
{w.id === 'recovery_history_viz' && (
<RecoveryHistoryVizConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
})
)
}
/>
)}
</li> </li>
) )
})} })}

View File

@ -13,7 +13,7 @@ import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoD
import { getStatusColor, getStatusBg } from '../utils/interpret' import { getStatusColor, getStatusBg } from '../utils/interpret'
import Markdown from '../utils/Markdown' import Markdown from '../utils/Markdown'
import FitnessHistoryVizSection from '../components/history/FitnessHistoryVizSection' import FitnessHistoryVizSection from '../components/history/FitnessHistoryVizSection'
import RecoveryDashboardOverview from '../components/RecoveryDashboardOverview' import RecoveryHistoryVizSection from '../components/history/RecoveryHistoryVizSection'
import BodyHistoryVizSection from '../components/history/BodyHistoryVizSection' import BodyHistoryVizSection from '../components/history/BodyHistoryVizSection'
import NutritionHistoryVizSection from '../components/history/NutritionHistoryVizSection' import NutritionHistoryVizSection from '../components/history/NutritionHistoryVizSection'
import { EmptySection, NavToCaliper, NavToCircum, PeriodSelector, SectionHeader } from '../components/history/historyPageChrome' import { EmptySection, NavToCaliper, NavToCircum, PeriodSelector, SectionHeader } from '../components/history/historyPageChrome'
@ -105,7 +105,7 @@ function ActivitySection({ activityLastDate, insights, onRequest, loadingSlug, f
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 20 }}> <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 20 }}>
Erholung (Schlaf, HRV, Vitalwerte) Erholung (Schlaf, HRV, Vitalwerte)
</div> </div>
<RecoveryDashboardOverview period={period} onPeriodChange={setPeriod} hidePeriodSelector /> <RecoveryHistoryVizSection period={period} onPeriodChange={setPeriod} hidePeriodSelector />
{activityLastDate && globalQualityLevel && globalQualityLevel !== 'all' && ( {activityLastDate && globalQualityLevel && globalQualityLevel !== 'all' && (
<div style={{ <div style={{

View File

@ -0,0 +1,106 @@
import { RECOVERY_HISTORY_VIZ_WIDGET_DEFAULTS, normalizeRecoveryHistoryVizConfig } from './recoveryHistoryVizConfig'
const CHART_TOGGLES = [
{ key: 'show_chart_recovery_score', label: 'HRV-/Recovery-Score-Verlauf' },
{ key: 'show_chart_sleep_quality', label: 'Schlaf: Dauer & Qualität' },
{ key: 'show_chart_sleep_debt', label: 'Schlafschuld' },
{ key: 'show_chart_hrv_rhr', label: 'HRV & Ruhepuls (Zeitverlauf)' },
]
const SECTION_TOGGLES = [
{ key: 'show_sleep_section_heading', label: 'Zwischenüberschrift «Schlaf & Erholung»' },
{ key: 'show_heart_section_heading', label: 'Zwischenüberschrift «Herz & Kreislauf»' },
{ key: 'show_heart_context_card', label: 'Herz: Einordnung, Zonen, Snapshots' },
{ key: 'show_vitals_extra_heading', label: 'Überschrift «Weitere Vitalparameter»' },
{ key: 'show_vitals_extra_trends', label: 'VO2 / SpO2 / Atemfrequenz (Verläufe)' },
]
const OTHER_TOGGLES = [
{ key: 'show_layer_meta', label: 'Meta-Zeile (Fenster-Tage, Data-Layer)' },
{ key: 'show_kpis', label: 'KPI-Kacheln' },
{ key: 'show_progress_insights', label: 'Überblick: Recovery & Schlaf (Karten)' },
]
/**
* @param {{ config: Record<string, unknown>, onChange: (next: Record<string, unknown>) => void }} props
*/
export default function RecoveryHistoryVizConfigEditor({ config, onChange }) {
const merged = normalizeRecoveryHistoryVizConfig(config)
const patch = (partial) => {
const next = { ...merged, ...partial }
const def = RECOVERY_HISTORY_VIZ_WIDGET_DEFAULTS
const stored = {}
for (const k of Object.keys(def)) {
if (next[k] !== def[k]) stored[k] = next[k]
}
onChange(stored)
}
const setBool = (key, checked) => {
patch({ [key]: checked })
}
return (
<div style={{ marginTop: 10, marginLeft: 28 }}>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8, lineHeight: 1.5 }}>
<strong>Erholung (Verlauf-Bundle):</strong> welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker
Standard (KPI kompakt, Schlaf-Charts, HRV/RHR ohne Kontextkarte und Extra-Vitals).
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>KPI-Umfang</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 10, cursor: 'pointer' }}>
<input
type="radio"
name="recovery_hist_kpi_detail"
checked={merged.kpi_detail === 'compact'}
onChange={() => patch({ kpi_detail: 'compact' })}
/>
<span>Kompakt (erste 4 Kacheln)</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 12, cursor: 'pointer' }}>
<input
type="radio"
name="recovery_hist_kpi_detail"
checked={merged.kpi_detail === 'full'}
onChange={() => patch({ kpi_detail: 'full' })}
/>
<span>Voll (alle Kacheln)</span>
</label>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>Bereiche</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{OTHER_TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
<span>{label}</span>
</label>
))}
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', margin: '12px 0 6px' }}>Abschnitte</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{SECTION_TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
<span>{label}</span>
</label>
))}
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', margin: '12px 0 6px' }}>Charts</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{CHART_TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
<span>{label}</span>
</label>
))}
</div>
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: 10, fontSize: 12, padding: '6px 12px' }}
onClick={() => onChange({})}
>
Auf schlanken Standard zurück
</button>
</div>
)
}

View File

@ -0,0 +1,85 @@
/**
* Sichtbarkeit für recovery_history_viz (sync mit backend dashboard_widget_config).
* `visibility === undefined` Verlauf: volle Erholungs-Übersicht (wie bisher).
*/
export const RECOVERY_HISTORY_VIZ_HISTORY_FULL = {
chart_days: 30,
show_layer_meta: true,
show_kpis: true,
kpi_detail: 'full',
show_progress_insights: true,
show_sleep_section_heading: true,
show_chart_recovery_score: true,
show_chart_sleep_quality: true,
show_chart_sleep_debt: true,
show_heart_section_heading: true,
show_heart_context_card: true,
show_chart_hrv_rhr: true,
show_vitals_extra_heading: true,
show_vitals_extra_trends: true,
}
export const RECOVERY_HISTORY_VIZ_WIDGET_DEFAULTS = {
chart_days: 30,
show_layer_meta: false,
show_kpis: true,
kpi_detail: 'compact',
show_progress_insights: false,
show_sleep_section_heading: true,
show_chart_recovery_score: true,
show_chart_sleep_quality: true,
show_chart_sleep_debt: false,
show_heart_section_heading: true,
show_heart_context_card: false,
show_chart_hrv_rhr: true,
show_vitals_extra_heading: false,
show_vitals_extra_trends: false,
}
const BOOL_KEYS = [
'show_layer_meta',
'show_kpis',
'show_progress_insights',
'show_sleep_section_heading',
'show_chart_recovery_score',
'show_chart_sleep_quality',
'show_chart_sleep_debt',
'show_heart_section_heading',
'show_heart_context_card',
'show_chart_hrv_rhr',
'show_vitals_extra_heading',
'show_vitals_extra_trends',
]
/**
* @param {Record<string, unknown>|null|undefined} raw
*/
export function normalizeRecoveryHistoryVizConfig(raw) {
const base = { ...RECOVERY_HISTORY_VIZ_WIDGET_DEFAULTS }
if (!raw || typeof raw !== 'object') return base
for (const k of BOOL_KEYS) {
if (Object.prototype.hasOwnProperty.call(raw, k)) {
base[k] = raw[k] === true
}
}
if (raw.kpi_detail === 'full' || raw.kpi_detail === 'compact') {
base.kpi_detail = raw.kpi_detail
}
if (raw.chart_days != null) {
const n = Number(raw.chart_days)
if (Number.isFinite(n)) {
base.chart_days = Math.min(90, Math.max(7, Math.round(n)))
}
}
return base
}
/**
* @param {Array<object>} kpiTiles
* @param {'compact'|'full'} detail
*/
export function filterRecoveryHistoryKpiTiles(kpiTiles, detail) {
if (detail === 'full' || !Array.isArray(kpiTiles)) return kpiTiles
return kpiTiles.slice(0, 4)
}

View File

@ -17,9 +17,11 @@ import NutritionDetailChartsWidget from '../components/dashboard-widgets/Nutriti
import BodyHistoryVizWidget from '../components/dashboard-widgets/BodyHistoryVizWidget' import BodyHistoryVizWidget from '../components/dashboard-widgets/BodyHistoryVizWidget'
import NutritionHistoryVizWidget from '../components/dashboard-widgets/NutritionHistoryVizWidget' import NutritionHistoryVizWidget from '../components/dashboard-widgets/NutritionHistoryVizWidget'
import FitnessHistoryVizWidget from '../components/dashboard-widgets/FitnessHistoryVizWidget' import FitnessHistoryVizWidget from '../components/dashboard-widgets/FitnessHistoryVizWidget'
import RecoveryHistoryVizWidget from '../components/dashboard-widgets/RecoveryHistoryVizWidget'
import { normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig' import { normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig'
import { normalizeNutritionHistoryVizConfig } from './nutritionHistoryVizConfig' import { normalizeNutritionHistoryVizConfig } from './nutritionHistoryVizConfig'
import { normalizeFitnessHistoryVizConfig } from './fitnessHistoryVizConfig' import { normalizeFitnessHistoryVizConfig } from './fitnessHistoryVizConfig'
import { normalizeRecoveryHistoryVizConfig } from './recoveryHistoryVizConfig'
import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget' import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget'
import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget' import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget'
import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget' import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget'
@ -142,6 +144,14 @@ export function ensurePilotLabWidgetsRegistered() {
fitnessHistoryVizConfig: normalizeFitnessHistoryVizConfig(ctx.layoutEntry?.config), fitnessHistoryVizConfig: normalizeFitnessHistoryVizConfig(ctx.layoutEntry?.config),
}), }),
}) })
registerDashboardWidget({
id: 'recovery_history_viz',
Component: RecoveryHistoryVizWidget,
mapProps: (ctx) => ({
refreshTick: ctx.refreshTick,
recoveryHistoryVizConfig: normalizeRecoveryHistoryVizConfig(ctx.layoutEntry?.config),
}),
})
registerDashboardWidget({ registerDashboardWidget({
id: 'recovery_charts_panel', id: 'recovery_charts_panel',
Component: RecoveryChartsPanelWidget, Component: RecoveryChartsPanelWidget,