diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py index f4fdff6..e6310fc 100644 --- a/backend/dashboard_widget_config.py +++ b/backend/dashboard_widget_config.py @@ -17,6 +17,7 @@ WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({ "body_history_viz", "nutrition_history_viz", "fitness_history_viz", + "recovery_history_viz", "activity_overview", "kpi_board", "quick_capture", @@ -111,6 +112,38 @@ _FITNESS_HISTORY_VIZ_DEFAULTS: dict[str, Any] = { "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: 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({}) if widget_id == "fitness_history_viz": return _validate_fitness_history_viz_config({}) + if widget_id == "recovery_history_viz": + return _validate_recovery_history_viz_config({}) return {} 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) if widget_id == "fitness_history_viz": 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": return _validate_chart_days_only(raw, label="activity_overview") if widget_id == "kpi_board": @@ -359,6 +396,45 @@ def _validate_fitness_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]: 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]: allowed = frozenset({"chart_days"}) unknown = set(raw) - allowed diff --git a/backend/tests/test_dashboard_widget_config.py b/backend/tests/test_dashboard_widget_config.py index c72cbe2..496dcd2 100644 --- a/backend/tests/test_dashboard_widget_config.py +++ b/backend/tests/test_dashboard_widget_config.py @@ -113,6 +113,46 @@ def test_fitness_history_viz_unknown_key(): 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(): with pytest.raises(ValueError): validate_widget_entry_config("welcome", {"x": 1}) diff --git a/backend/tests/test_system_dashboard_product_default.py b/backend/tests/test_system_dashboard_product_default.py index c1dd7a0..4b3e662 100644 --- a/backend/tests/test_system_dashboard_product_default.py +++ b/backend/tests/test_system_dashboard_product_default.py @@ -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)], } # 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() class _Cur: diff --git a/backend/version.py b/backend/version.py index fd814bf..174510d 100644 --- a/backend/version.py +++ b/backend/version.py @@ -30,7 +30,7 @@ MODULE_VERSIONS = { "importdata": "1.0.0", "membership": "2.1.0", "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 "admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response) } diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py index 1c5e0b4..4d5947b 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -112,6 +112,11 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [ "description": "Layer-2b fitness-dashboard-viz: schlanker Standard; Blöcke per show_* / kpi_detail; chart_days 7–90; 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 7–90", + }, { "id": "recovery_charts_panel", "title": "Erholung — Charts R1–R5", diff --git a/frontend/src/components/RecoveryCharts.jsx b/frontend/src/components/RecoveryCharts.jsx index 6cad7bd..efe6db2 100644 --- a/frontend/src/components/RecoveryCharts.jsx +++ b/frontend/src/components/RecoveryCharts.jsx @@ -1,8 +1,8 @@ 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 }) { - return + return } diff --git a/frontend/src/components/RecoveryDashboardOverview.jsx b/frontend/src/components/RecoveryDashboardOverview.jsx index f826030..d7f959f 100644 --- a/frontend/src/components/RecoveryDashboardOverview.jsx +++ b/frontend/src/components/RecoveryDashboardOverview.jsx @@ -4,6 +4,11 @@ import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianG import { api } from '../utils/api' import KpiTilesOverview from './KpiTilesOverview' import { getStatusColor, getStatusBg } from '../utils/interpret' +import { + RECOVERY_HISTORY_VIZ_HISTORY_FULL, + filterRecoveryHistoryKpiTiles, + normalizeRecoveryHistoryVizConfig, +} from '../widgetSystem/recoveryHistoryVizConfig' import dayjs from 'dayjs' 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). + * @param {number} [props.externalPeriod] — Widget: feste Tage (7–90) + * @param {boolean} [props.embedded] + * @param {Record} [props.visibility] — Dashboard-Config; undefined = voller Verlauf + * @param {import('react').ReactNode} [props.footer] */ export default function RecoveryDashboardOverview({ period: periodProp, onPeriodChange, hidePeriodSelector = false, + externalPeriod, + embedded = false, + visibility, + footer = null, }) { const nav = useNavigate() const [internalPeriod, setInternalPeriod] = useState(28) const controlled = periodProp !== undefined && typeof onPeriodChange === 'function' - const period = controlled ? periodProp : internalPeriod - const setPeriod = controlled ? onPeriodChange : setInternalPeriod + const period = + 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 [loading, setLoading] = useState(true) @@ -230,10 +250,13 @@ export default function RecoveryDashboardOverview({ } }, [period]) + const outerClass = embedded ? '' : 'card section-gap' + const showPeriodDropdown = !hidePeriodSelector && externalPeriod === undefined && !controlled + if (loading) { return ( -
-
Erholung & Vitalwerte
+
+ {!embedded &&
Erholung & Vitalwerte
}
) @@ -241,8 +264,8 @@ export default function RecoveryDashboardOverview({ if (err) { return ( -
-
Erholung & Vitalwerte
+
+ {!embedded &&
Erholung & Vitalwerte
}
{err}
) @@ -250,8 +273,8 @@ export default function RecoveryDashboardOverview({ if (!viz?.has_recovery_data) { return ( -
-
Erholung & Vitalwerte
+
+ {!embedded &&
Erholung & Vitalwerte
}

{viz?.message || 'Noch keine Schlaf- oder Vitaldaten.'} Sobald du Schlaf oder morgendliche Vitalwerte erfasst oder importierst, erscheinen Auswertungen hier. @@ -280,18 +303,19 @@ export default function RecoveryDashboardOverview({ 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) => ({ + const kpiTilesRaw = (viz.kpi_tiles || []).map((t) => ({ ...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 eff = viz.effective_window_days const cDays = viz.chart_days_used const vDays = viz.vital_matrix_days_used - const showPeriodDropdown = !hidePeriodSelector && !controlled - const renderRecoveryScore = () => { if (!recoveryData || recoveryData.metadata?.confidence === 'insufficient') { return ( @@ -306,41 +330,43 @@ export default function RecoveryDashboardOverview({ })) return ( <> - - - - - (Number.isFinite(Number(v)) ? String(Math.round(Number(v))) : '')} - tickCount={6} - width={36} - /> - - - - +

+ + + + + (Number.isFinite(Number(v)) ? String(Math.round(Number(v))) : '')} + tickCount={6} + width={36} + /> + + + + +
KPI Recovery-Score (aktuell): {recoveryData.metadata.current_score}/100 · Datenpunkte Kurve:{' '} {recoveryData.metadata.data_points} @@ -364,44 +390,46 @@ export default function RecoveryDashboardOverview({ })) return ( <> - - - - - - - - - - - +
+ + + + + + + + + + + +
HRV Ø {hrvRhrData.metadata.avg_hrv}ms · RHR Ø {hrvRhrData.metadata.avg_rhr}bpm
@@ -424,29 +452,31 @@ export default function RecoveryDashboardOverview({ })) return ( <> - - - - - - - - - - - +
+ + + + + + + + + + + +
Ø {sleepData.metadata.avg_duration_hours}h Schlaf
@@ -469,35 +499,37 @@ export default function RecoveryDashboardOverview({ const curDebt = debtData.metadata?.current_debt_hours return ( <> - - - - - - - - - +
+ + + + + + + + + +
Aktuelle Schuld: {curDebt != null ? Number(curDebt).toFixed(1) : '—'}h
@@ -577,7 +609,7 @@ export default function RecoveryDashboardOverview({ Ein Messpunkt ({formatAxisTick(m.last)}) — weiter erfassen, um einen Verlauf zu sehen.
) : ( -
+
@@ -632,37 +664,43 @@ export default function RecoveryDashboardOverview({ } return ( -
-
- Erholung & Vitalwerte - {showPeriodDropdown ? ( - - ) : null} -
+ Zeitraum + + + ) : null} +
+ )} -

- Daten-Layer Auswertung · Fenster ca. {eff} Tage · Chart-Horizont {cDays} Tage · - Vital-Snapshot {vDays} Tage. -

+ {display.show_layer_meta ? ( +

+ Daten-Layer Auswertung · Fenster ca. {eff} Tage · Chart-Horizont {cDays} Tage · + Vital-Snapshot {vDays} Tage. +

+ ) : null} - + {kpiTilesShown.length > 0 ? ( + + ) : null} - {insights.length > 0 ? ( + {display.show_progress_insights && insights.length > 0 ? (
Überblick: Recovery & Schlaf @@ -690,93 +728,113 @@ export default function RecoveryDashboardOverview({
) : null} - - - {renderRecoveryScore()} - - - {renderSleepQuality()} - - - {renderSleepDebt()} - + {display.show_sleep_section_heading ? ( + + ) : null} + {display.show_chart_recovery_score ? ( + + {renderRecoveryScore()} + + ) : null} + {display.show_chart_sleep_quality ? ( + + {renderSleepQuality()} + + ) : null} + {display.show_chart_sleep_debt ? ( + + {renderSleepDebt()} + + ) : null} - -
-
Einordnung & Kontext
- - {heartSectionInsights.length > 0 ? ( -
- {heartSectionInsights.map((ins) => ( - - ))} -
- ) : null} -
Letzte Messwerte (Zonen)
- - {vitalsData?.metadata?.vitals_measured_at || vitalsData?.metadata?.blood_pressure_measured_at ? ( -
- {vitalsData?.metadata?.vitals_measured_at ? ( - <> - Baseline-Vitals: {fmtDate(vitalsData.metadata.vitals_measured_at)} - - ) : null} - {vitalsData?.metadata?.vitals_measured_at && vitalsData?.metadata?.blood_pressure_measured_at ? ' · ' : null} - {vitalsData?.metadata?.blood_pressure_measured_at ? ( - <> - Blutdruck: {fmtDate(vitalsData.metadata.blood_pressure_measured_at)} - - ) : null} -
- ) : null} - {vitalsData?.metadata?.disclaimer_de ? ( -
- {vitalsData.metadata.disclaimer_de} -
- ) : null} -
- - {renderHrvRhr()} - + {display.show_heart_section_heading ? ( + + ) : null} + {display.show_heart_context_card ? ( +
+
Einordnung & Kontext
+ + {heartSectionInsights.length > 0 ? ( +
+ {heartSectionInsights.map((ins) => ( + + ))} +
+ ) : null} +
Letzte Messwerte (Zonen)
+ + {vitalsData?.metadata?.vitals_measured_at || vitalsData?.metadata?.blood_pressure_measured_at ? ( +
+ {vitalsData?.metadata?.vitals_measured_at ? ( + <> + Baseline-Vitals: {fmtDate(vitalsData.metadata.vitals_measured_at)} + + ) : null} + {vitalsData?.metadata?.vitals_measured_at && vitalsData?.metadata?.blood_pressure_measured_at ? ' · ' : null} + {vitalsData?.metadata?.blood_pressure_measured_at ? ( + <> + Blutdruck: {fmtDate(vitalsData.metadata.blood_pressure_measured_at)} + + ) : null} +
+ ) : null} + {vitalsData?.metadata?.disclaimer_de ? ( +
+ {vitalsData.metadata.disclaimer_de} +
+ ) : null} +
+ ) : null} + {display.show_chart_hrv_rhr ? ( + + {renderHrvRhr()} + + ) : null} - -
-
Verläufe
- {renderWeitereVitalVerlaeufe(vo2SectionInsights, vitalItemsByKey)} -
+ {display.show_vitals_extra_heading ? ( + + ) : null} + {display.show_vitals_extra_trends ? ( +
+
Verläufe
+ {renderWeitereVitalVerlaeufe(vo2SectionInsights, vitalItemsByKey)} +
+ ) : null} + + {footer}
) } diff --git a/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx b/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx index 218cb81..38c10df 100644 --- a/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx +++ b/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx @@ -26,7 +26,7 @@ export default function RecoveryChartsPanelWidget({ refreshTick = 0, chartDays } Verlauf →
- +
) } diff --git a/frontend/src/components/dashboard-widgets/RecoveryHistoryVizWidget.jsx b/frontend/src/components/dashboard-widgets/RecoveryHistoryVizWidget.jsx new file mode 100644 index 0000000..260b636 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/RecoveryHistoryVizWidget.jsx @@ -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 }} props + */ +export default function RecoveryHistoryVizWidget({ refreshTick = 0, recoveryHistoryVizConfig }) { + const nav = useNavigate() + const cfg = normalizeRecoveryHistoryVizConfig(recoveryHistoryVizConfig) + const days = normalizeBodyChartDays(cfg.chart_days) + + return ( +
+
+
+
Erholung (Verlauf-Bundle)
+
recovery-dashboard-viz · {days} Tage
+
+ +
+ +
+ ) +} diff --git a/frontend/src/components/history/RecoveryHistoryVizSection.jsx b/frontend/src/components/history/RecoveryHistoryVizSection.jsx new file mode 100644 index 0000000..1f0c736 --- /dev/null +++ b/frontend/src/components/history/RecoveryHistoryVizSection.jsx @@ -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 ( + + ) +} diff --git a/frontend/src/pages/DashboardConfigurePage.jsx b/frontend/src/pages/DashboardConfigurePage.jsx index 16d7553..8aabc7a 100644 --- a/frontend/src/pages/DashboardConfigurePage.jsx +++ b/frontend/src/pages/DashboardConfigurePage.jsx @@ -14,6 +14,7 @@ import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor' import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor' import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor' +import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor' import { moveWidget, moveWidgetToIndex, @@ -28,6 +29,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([ 'nutrition_detail_charts', 'nutrition_history_viz', 'fitness_history_viz', + 'recovery_history_viz', 'recovery_charts_panel', ]) @@ -552,6 +554,23 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) { } /> )} + {w.id === 'recovery_history_viz' && ( + + 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 } } + }), + }) + ) + } + /> + )} ) })} diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx index 0e7cce2..38f41b9 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -15,6 +15,7 @@ import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor' import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor' import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor' +import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor' import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor' /** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */ @@ -25,6 +26,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([ 'nutrition_detail_charts', 'nutrition_history_viz', 'fitness_history_viz', + 'recovery_history_viz', 'recovery_charts_panel', ]) @@ -334,7 +336,9 @@ export default function DashboardLabPage() { ? 'Ernährung (Verlauf-Bundle)' : w.id === 'fitness_history_viz' ? '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} )} + {w.id === 'recovery_history_viz' && ( + + 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 } } + }), + }) + ) + } + /> + )} ) })} diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 071a950..8cd9121 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -13,7 +13,7 @@ import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoD import { getStatusColor, getStatusBg } from '../utils/interpret' import Markdown from '../utils/Markdown' import FitnessHistoryVizSection from '../components/history/FitnessHistoryVizSection' -import RecoveryDashboardOverview from '../components/RecoveryDashboardOverview' +import RecoveryHistoryVizSection from '../components/history/RecoveryHistoryVizSection' import BodyHistoryVizSection from '../components/history/BodyHistoryVizSection' import NutritionHistoryVizSection from '../components/history/NutritionHistoryVizSection' import { EmptySection, NavToCaliper, NavToCircum, PeriodSelector, SectionHeader } from '../components/history/historyPageChrome' @@ -105,7 +105,7 @@ function ActivitySection({ activityLastDate, insights, onRequest, loadingSlug, f
Erholung (Schlaf, HRV, Vitalwerte)
- + {activityLastDate && globalQualityLevel && globalQualityLevel !== 'all' && (
, onChange: (next: Record) => 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 ( +
+
+ Erholung (Verlauf-Bundle): welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker + Standard (KPI kompakt, Schlaf-Charts, HRV/RHR — ohne Kontextkarte und Extra-Vitals). +
+
KPI-Umfang
+ + +
Bereiche
+
+ {OTHER_TOGGLES.map(({ key, label }) => ( + + ))} +
+
Abschnitte
+
+ {SECTION_TOGGLES.map(({ key, label }) => ( + + ))} +
+
Charts
+
+ {CHART_TOGGLES.map(({ key, label }) => ( + + ))} +
+ +
+ ) +} diff --git a/frontend/src/widgetSystem/recoveryHistoryVizConfig.js b/frontend/src/widgetSystem/recoveryHistoryVizConfig.js new file mode 100644 index 0000000..212fb14 --- /dev/null +++ b/frontend/src/widgetSystem/recoveryHistoryVizConfig.js @@ -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|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} kpiTiles + * @param {'compact'|'full'} detail + */ +export function filterRecoveryHistoryKpiTiles(kpiTiles, detail) { + if (detail === 'full' || !Array.isArray(kpiTiles)) return kpiTiles + return kpiTiles.slice(0, 4) +} diff --git a/frontend/src/widgetSystem/registerPilotLabWidgets.js b/frontend/src/widgetSystem/registerPilotLabWidgets.js index f0b2366..35e478f 100644 --- a/frontend/src/widgetSystem/registerPilotLabWidgets.js +++ b/frontend/src/widgetSystem/registerPilotLabWidgets.js @@ -17,9 +17,11 @@ import NutritionDetailChartsWidget from '../components/dashboard-widgets/Nutriti import BodyHistoryVizWidget from '../components/dashboard-widgets/BodyHistoryVizWidget' import NutritionHistoryVizWidget from '../components/dashboard-widgets/NutritionHistoryVizWidget' import FitnessHistoryVizWidget from '../components/dashboard-widgets/FitnessHistoryVizWidget' +import RecoveryHistoryVizWidget from '../components/dashboard-widgets/RecoveryHistoryVizWidget' import { normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig' import { normalizeNutritionHistoryVizConfig } from './nutritionHistoryVizConfig' import { normalizeFitnessHistoryVizConfig } from './fitnessHistoryVizConfig' +import { normalizeRecoveryHistoryVizConfig } from './recoveryHistoryVizConfig' import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget' import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget' import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget' @@ -142,6 +144,14 @@ export function ensurePilotLabWidgetsRegistered() { fitnessHistoryVizConfig: normalizeFitnessHistoryVizConfig(ctx.layoutEntry?.config), }), }) + registerDashboardWidget({ + id: 'recovery_history_viz', + Component: RecoveryHistoryVizWidget, + mapProps: (ctx) => ({ + refreshTick: ctx.refreshTick, + recoveryHistoryVizConfig: normalizeRecoveryHistoryVizConfig(ctx.layoutEntry?.config), + }), + }) registerDashboardWidget({ id: 'recovery_charts_panel', Component: RecoveryChartsPanelWidget,