diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py index fb97e31..f4fdff6 100644 --- a/backend/dashboard_widget_config.py +++ b/backend/dashboard_widget_config.py @@ -16,6 +16,7 @@ WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({ "body_overview", "body_history_viz", "nutrition_history_viz", + "fitness_history_viz", "activity_overview", "kpi_board", "quick_capture", @@ -88,6 +89,28 @@ _NUTRITION_HISTORY_VIZ_DEFAULTS: dict[str, Any] = { "show_energy_protein_charts": False, } +_FITNESS_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({ + "show_layer_meta", + "show_kpis", + "show_progress_insights", + "show_chart_training_volume", + "show_chart_training_type_distribution", + "show_chart_quality_sessions", + "show_chart_load_monitoring", +}) + +_FITNESS_HISTORY_VIZ_DEFAULTS: dict[str, Any] = { + "chart_days": 30, + "show_layer_meta": False, + "show_kpis": True, + "kpi_detail": "compact", + "show_progress_insights": False, + "show_chart_training_volume": True, + "show_chart_training_type_distribution": True, + "show_chart_quality_sessions": False, + "show_chart_load_monitoring": 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")) @@ -111,6 +134,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: return _validate_body_history_viz_config({}) if widget_id == "nutrition_history_viz": return _validate_nutrition_history_viz_config({}) + if widget_id == "fitness_history_viz": + return _validate_fitness_history_viz_config({}) return {} if widget_id == "body_overview": @@ -119,6 +144,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: return _validate_body_history_viz_config(raw) if widget_id == "nutrition_history_viz": return _validate_nutrition_history_viz_config(raw) + if widget_id == "fitness_history_viz": + return _validate_fitness_history_viz_config(raw) if widget_id == "activity_overview": return _validate_chart_days_only(raw, label="activity_overview") if widget_id == "kpi_board": @@ -295,6 +322,43 @@ def _validate_nutrition_history_viz_config(raw: dict[str, Any]) -> dict[str, Any return out +def _validate_fitness_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]: + label = "fitness_history_viz" + allowed = _FITNESS_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(_FITNESS_HISTORY_VIZ_DEFAULTS) + for k in _FITNESS_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 any( + out[k] + for k in ( + "show_chart_training_volume", + "show_chart_training_type_distribution", + "show_chart_quality_sessions", + "show_chart_load_monitoring", + ) + ): + raise ValueError(f"{label}: mindestens KPIs, Einschätzungen 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 1a1244a..c72cbe2 100644 --- a/backend/tests/test_dashboard_widget_config.py +++ b/backend/tests/test_dashboard_widget_config.py @@ -75,6 +75,44 @@ def test_nutrition_history_viz_unknown_key(): validate_widget_entry_config("nutrition_history_viz", {"evil": True}) +def test_fitness_history_viz_empty_expands_defaults(): + d = validate_widget_entry_config("fitness_history_viz", {}) + assert d["chart_days"] == 30 + assert d["show_kpis"] is True + assert d["show_chart_training_volume"] is True + assert d["kpi_detail"] == "compact" + assert d["show_layer_meta"] is False + assert d["show_chart_load_monitoring"] is False + + +def test_fitness_history_viz_chart_days_and_merge(): + d = validate_widget_entry_config("fitness_history_viz", {"chart_days": 60}) + assert d["chart_days"] == 60 + assert d["show_progress_insights"] is False + with pytest.raises(ValueError): + validate_widget_entry_config("fitness_history_viz", {"chart_days": 5}) + + +def test_fitness_history_viz_requires_visible_block(): + with pytest.raises(ValueError): + validate_widget_entry_config( + "fitness_history_viz", + { + "show_kpis": False, + "show_progress_insights": False, + "show_chart_training_volume": False, + "show_chart_training_type_distribution": False, + "show_chart_quality_sessions": False, + "show_chart_load_monitoring": False, + }, + ) + + +def test_fitness_history_viz_unknown_key(): + with pytest.raises(ValueError): + validate_widget_entry_config("fitness_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 8bd9af6..c1dd7a0 100644 --- a/backend/tests/test_system_dashboard_product_default.py +++ b/backend/tests/test_system_dashboard_product_default.py @@ -32,7 +32,9 @@ def test_get_product_default_base_uses_db_when_valid(monkeypatch): "version": 1, "widgets": [{"id": wid, "enabled": wid == "welcome"} for wid in sorted(ALLOWED_WIDGET_IDS)], } - DashboardLayoutPayload.model_validate(small) + # 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). + expected = DashboardLayoutPayload.model_validate(small).to_stored_dict() class _Cur: def execute(self, *a, **k): @@ -42,4 +44,4 @@ def test_get_product_default_base_uses_db_when_valid(monkeypatch): return {"value": small} monkeypatch.setattr("system_dashboard_product_default.get_cursor", lambda _c: _Cur()) - assert get_product_default_base_dict(object()) == small + assert get_product_default_base_dict(object()) == expected diff --git a/backend/version.py b/backend/version.py index 1e2cb34..fd814bf 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.14.0", # nutrition_history_viz: Verlauf-Bundle-Widget + Config wie Körper + "app_dashboard": "1.15.0", # fitness_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 5b6f0c8..1c5e0b4 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -106,6 +106,12 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [ "description": "Layer-2b nutrition-history-viz: schlanker Standard; Blöcke per show_* / kpi_detail; chart_days 7–90; Feature nutrition_entries", "requires_feature": "nutrition_entries", }, + { + "id": "fitness_history_viz", + "title": "Fitness (Verlauf-Bundle)", + "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_charts_panel", "title": "Erholung — Charts R1–R5", diff --git a/frontend/src/components/FitnessDashboardOverview.jsx b/frontend/src/components/FitnessDashboardOverview.jsx index 447a4b2..bf6d4fa 100644 --- a/frontend/src/components/FitnessDashboardOverview.jsx +++ b/frontend/src/components/FitnessDashboardOverview.jsx @@ -17,6 +17,11 @@ import { import { api } from '../utils/api' import KpiTilesOverview from './KpiTilesOverview' import { getStatusColor } from '../utils/interpret' +import { + FITNESS_HISTORY_VIZ_HISTORY_FULL, + filterFitnessHistoryKpiTiles, + normalizeFitnessHistoryVizConfig, +} from '../widgetSystem/fitnessHistoryVizConfig' import dayjs from 'dayjs' const PERIODS = [ @@ -28,21 +33,35 @@ const PERIODS = [ /** * Layer 2b: Kennzahlen und Charts nur aus GET /api/charts/fitness-dashboard-viz (activity_metrics). + * @param {number} [props.externalPeriod] — feste Tage (z. B. Dashboard-Widget 7–90) + * @param {boolean} [props.embedded] + * @param {Record} [props.visibility] — Dashboard-Config; undefined = voller Verlauf + * @param {import('react').ReactNode} [props.footer] */ export default function FitnessDashboardOverview({ 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 [viz, setViz] = useState(null) const [loading, setLoading] = useState(true) const [err, setErr] = useState(null) + const display = visibility === undefined ? FITNESS_HISTORY_VIZ_HISTORY_FULL : normalizeFitnessHistoryVizConfig(visibility) + const chartH = embedded ? 176 : 200 + const chartLoadH = embedded ? 200 : 220 + useEffect(() => { let cancelled = false setLoading(true) @@ -63,10 +82,13 @@ export default function FitnessDashboardOverview({ } }, [period]) + const outerClass = embedded ? '' : 'card section-gap' + const showPeriodDropdown = !hidePeriodSelector && externalPeriod === undefined && !controlled + if (loading) { return ( -
-
Fitness-Übersicht
+
+ {!embedded &&
Fitness-Übersicht
}
) @@ -74,8 +96,8 @@ export default function FitnessDashboardOverview({ if (err) { return ( -
-
Fitness-Übersicht
+
+ {!embedded &&
Fitness-Übersicht
}
{err}
) @@ -83,8 +105,8 @@ export default function FitnessDashboardOverview({ if (!viz?.has_activity_entries) { return ( -
-
Fitness-Übersicht
+
+ {!embedded &&
Fitness-Übersicht
}

Noch keine Aktivitätsdaten. Sobald du Trainings erfasst oder importierst, erscheinen Auswertungen hier.

@@ -130,11 +152,14 @@ export default function FitnessDashboardOverview({ })) const loadMeta = loadCh?.metadata || {} - 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 + ? filterFitnessHistoryKpiTiles(kpiTilesRaw, display.kpi_detail || 'full') + : [] const insights = viz.progress_insights || [] const eff = viz.effective_window_days @@ -142,49 +167,59 @@ export default function FitnessDashboardOverview({ const dTyp = viz.training_type_dist_days_used const loadDays = viz.load_chart_days_used - const showPeriodDropdown = !hidePeriodSelector && !controlled + const gridWrapStyle = { width: '100%', minWidth: 0 } return ( -
-
- Fitness-Übersicht - {showPeriodDropdown ? ( - - ) : null} -
+ Zeitraum + + + ) : null} +
+ )} -

- Alles aus dem Aktivitäts-Data-Layer (Issue 53). Zusammenfassung ca. {eff} Tage · Volumen{' '} - {wUsed} Wochen · Kategorien {dTyp} Tage · Load-Zeitreihe{' '} - {loadDays ?? '—'} Tage - {viz.last_updated ? ( - <> - {' '} - · letzte Aktivität {viz.last_updated} - - ) : null} - . -

+ {embedded && viz?.last_updated ? ( +
+ Letzte Aktivität {viz.last_updated} +
+ ) : null} - + {display.show_layer_meta ? ( +

+ Alles aus dem Aktivitäts-Data-Layer (Issue 53). Zusammenfassung ca. {eff} Tage · Volumen{' '} + {wUsed} Wochen · Kategorien {dTyp} Tage · Load-Zeitreihe{' '} + {loadDays ?? '—'} Tage + {viz.last_updated ? ( + <> + {' '} + · letzte Aktivität {viz.last_updated} + + ) : null} + . +

+ ) : null} - {insights.length > 0 ? ( + {kpiTilesShown.length > 0 ? : null} + + {display.show_progress_insights && insights.length > 0 ? (
Einschätzungen
@@ -213,136 +248,155 @@ export default function FitnessDashboardOverview({ gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 16, marginTop: 8, + minWidth: 0, }} > -
-
- Trainingsvolumen (Minuten / Woche) -
- {volRows.length >= 1 ? ( - - - - - - [`${Math.round(v)} min`, 'Volumen']} - /> - - - - ) : ( -
Keine Wochendaten im gewählten Fenster.
- )} -
- -
-
- Training nach Kategorie -
- {pieData.length >= 1 ? ( - - - `${name} ${(percent * 100).toFixed(0)}%`} - /> - - - - ) : ( -
Keine kategorisierten Sessions im Fenster.
- )} -
- -
-
- Qualitäts-Sessions (Schätzung) -
- {qualBar.length >= 1 ? ( - - - - - - - - {qualBar.map((entry, i) => ( - - ))} - - - - ) : ( -
Keine Daten.
- )} -
- -
-
- Belastung (Proxy-Load · duration×RPE / Tag) -
- {loadRows.length >= 1 ? ( - <> - - - - - - - - - -
- ACWR {loadMeta.acwr != null ? Number(loadMeta.acwr).toFixed(2) : '—'} ( - {loadMeta.acwr_status === 'optimal' ? 'oft als günstig beschrieben' : 'außerhalb 0,8–1,3'} · Proxy) + {display.show_chart_training_volume ? ( +
+
+ Trainingsvolumen (Minuten / Woche) +
+ {volRows.length >= 1 ? ( +
+ + + + + + [`${Math.round(v)} min`, 'Volumen']} + /> + + +
- - ) : ( -
Keine Load-Daten im Fenster.
- )} -
+ ) : ( +
Keine Wochendaten im gewählten Fenster.
+ )} +
+ ) : null} + + {display.show_chart_training_type_distribution ? ( +
+
+ Training nach Kategorie +
+ {pieData.length >= 1 ? ( +
+ + + `${name} ${(percent * 100).toFixed(0)}%`} + /> + + + +
+ ) : ( +
Keine kategorisierten Sessions im Fenster.
+ )} +
+ ) : null} + + {display.show_chart_quality_sessions ? ( +
+
+ Qualitäts-Sessions (Schätzung) +
+ {qualBar.length >= 1 ? ( +
+ + + + + + + + {qualBar.map((entry, i) => ( + + ))} + + + +
+ ) : ( +
Keine Daten.
+ )} +
+ ) : null} + + {display.show_chart_load_monitoring ? ( +
+
+ Belastung (Proxy-Load · duration×RPE / Tag) +
+ {loadRows.length >= 1 ? ( + <> +
+ + + + + + + + + +
+
+ ACWR {loadMeta.acwr != null ? Number(loadMeta.acwr).toFixed(2) : '—'} ( + {loadMeta.acwr_status === 'optimal' ? 'oft als günstig beschrieben' : 'außerhalb 0,8–1,3'} · Proxy) +
+ + ) : ( +
Keine Load-Daten im Fenster.
+ )} +
+ ) : null}
+ + {footer}
) } diff --git a/frontend/src/components/dashboard-widgets/FitnessHistoryVizWidget.jsx b/frontend/src/components/dashboard-widgets/FitnessHistoryVizWidget.jsx new file mode 100644 index 0000000..6b57196 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/FitnessHistoryVizWidget.jsx @@ -0,0 +1,29 @@ +import { useNavigate } from 'react-router-dom' +import FitnessHistoryVizSection from '../history/FitnessHistoryVizSection' +import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays' +import { normalizeFitnessHistoryVizConfig } from '../../widgetSystem/fitnessHistoryVizConfig' + +/** + * Verlauf → Fitness als Dashboard-Widget: GET /charts/fitness-dashboard-viz (Layer 2b), Umfang über Layout-Config. + * @param {{ refreshTick?: number, fitnessHistoryVizConfig?: Record }} props + */ +export default function FitnessHistoryVizWidget({ refreshTick = 0, fitnessHistoryVizConfig }) { + const nav = useNavigate() + const cfg = normalizeFitnessHistoryVizConfig(fitnessHistoryVizConfig) + const days = normalizeBodyChartDays(cfg.chart_days) + + return ( +
+
+
+
Fitness (Verlauf-Bundle)
+
fitness-dashboard-viz · {days} Tage
+
+ +
+ +
+ ) +} diff --git a/frontend/src/components/history/FitnessHistoryVizSection.jsx b/frontend/src/components/history/FitnessHistoryVizSection.jsx new file mode 100644 index 0000000..f07f39f --- /dev/null +++ b/frontend/src/components/history/FitnessHistoryVizSection.jsx @@ -0,0 +1,33 @@ +import FitnessDashboardOverview from '../FitnessDashboardOverview' + +/** + * Verlauf → Fitness bzw. Dashboard-Widget: GET /charts/fitness-dashboard-viz (Layer 2b). + * @param {number} [props.externalPeriod] — Widget: feste Tage (7–90) + * @param {number} [props.period] — Verlauf: gesteuerter Zeitraum (inkl. 9999) + * @param {(n: number) => void} [props.onPeriodChange] + * @param {boolean} [props.hidePeriodSelector] + * @param {boolean} [props.embedded] + * @param {Record} [props.visibility] — undefined = volle Übersicht (wie bisher) + * @param {import('react').ReactNode} [props.footer] + */ +export default function FitnessHistoryVizSection({ + externalPeriod, + period, + onPeriodChange, + hidePeriodSelector, + embedded, + visibility, + footer, +}) { + return ( + + ) +} diff --git a/frontend/src/pages/DashboardConfigurePage.jsx b/frontend/src/pages/DashboardConfigurePage.jsx index 3ec05a8..16d7553 100644 --- a/frontend/src/pages/DashboardConfigurePage.jsx +++ b/frontend/src/pages/DashboardConfigurePage.jsx @@ -13,6 +13,7 @@ import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor' import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor' +import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor' import { moveWidget, moveWidgetToIndex, @@ -26,6 +27,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([ 'activity_overview', 'nutrition_detail_charts', 'nutrition_history_viz', + 'fitness_history_viz', 'recovery_charts_panel', ]) @@ -533,6 +535,23 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) { } /> )} + {w.id === 'fitness_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 7c3a0ee..0e7cce2 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -14,6 +14,7 @@ import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor' import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor' +import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor' import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor' /** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */ @@ -23,6 +24,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([ 'activity_overview', 'nutrition_detail_charts', 'nutrition_history_viz', + 'fitness_history_viz', 'recovery_charts_panel', ]) @@ -330,7 +332,9 @@ export default function DashboardLabPage() { ? 'Ernährung — Charts' : w.id === 'nutrition_history_viz' ? 'Ernährung (Verlauf-Bundle)' - : 'Erholung — Charts'}{' '} + : w.id === 'fitness_history_viz' + ? 'Fitness (Verlauf-Bundle)' + : 'Erholung — Charts'}{' '} — Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX} )} + {w.id === 'fitness_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 5ba9ed8..071a950 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -12,7 +12,7 @@ import { api } from '../utils/api' import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay' import { getStatusColor, getStatusBg } from '../utils/interpret' import Markdown from '../utils/Markdown' -import FitnessDashboardOverview from '../components/FitnessDashboardOverview' +import FitnessHistoryVizSection from '../components/history/FitnessHistoryVizSection' import RecoveryDashboardOverview from '../components/RecoveryDashboardOverview' import BodyHistoryVizSection from '../components/history/BodyHistoryVizSection' import NutritionHistoryVizSection from '../components/history/NutritionHistoryVizSection' @@ -100,7 +100,7 @@ function ActivitySection({ activityLastDate, insights, onRequest, loadingSlug, f

Fitness und Erholung aus den Data-Layer-Bundles (Issue 53). Zeitraum-Buttons steuern beide Bereiche gleichzeitig.

- +
Erholung (Schlaf, HRV, Vitalwerte) diff --git a/frontend/src/widgetSystem/FitnessHistoryVizConfigEditor.jsx b/frontend/src/widgetSystem/FitnessHistoryVizConfigEditor.jsx new file mode 100644 index 0000000..b46b940 --- /dev/null +++ b/frontend/src/widgetSystem/FitnessHistoryVizConfigEditor.jsx @@ -0,0 +1,89 @@ +import { FITNESS_HISTORY_VIZ_WIDGET_DEFAULTS, normalizeFitnessHistoryVizConfig } from './fitnessHistoryVizConfig' + +const CHART_TOGGLES = [ + { key: 'show_chart_training_volume', label: 'Trainingsvolumen (Balken)' }, + { key: 'show_chart_training_type_distribution', label: 'Training nach Kategorie (Kuchen)' }, + { key: 'show_chart_quality_sessions', label: 'Qualitäts-Sessions' }, + { key: 'show_chart_load_monitoring', label: 'Belastung / Load-Zeitreihe' }, +] + +const OTHER_TOGGLES = [ + { key: 'show_layer_meta', label: 'Meta-Zeile (Fenster-Tage, Issue-53-Hinweis)' }, + { key: 'show_kpis', label: 'KPI-Kacheln' }, + { key: 'show_progress_insights', label: 'Einschätzungen (Progress-Insights)' }, +] + +/** + * @param {{ config: Record, onChange: (next: Record) => void }} props + */ +export default function FitnessHistoryVizConfigEditor({ config, onChange }) { + const merged = normalizeFitnessHistoryVizConfig(config) + + const patch = (partial) => { + const next = { ...merged, ...partial } + const def = FITNESS_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 ( +
+
+ Fitness (Verlauf-Bundle): welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker + Standard (KPI kompakt, Volumen + Kategorien). +
+
KPI-Umfang
+ + +
Bereiche
+
+ {OTHER_TOGGLES.map(({ key, label }) => ( + + ))} +
+
Charts
+
+ {CHART_TOGGLES.map(({ key, label }) => ( + + ))} +
+ +
+ ) +} diff --git a/frontend/src/widgetSystem/fitnessHistoryVizConfig.js b/frontend/src/widgetSystem/fitnessHistoryVizConfig.js new file mode 100644 index 0000000..f1a23d7 --- /dev/null +++ b/frontend/src/widgetSystem/fitnessHistoryVizConfig.js @@ -0,0 +1,70 @@ +/** + * Sichtbarkeit für fitness_history_viz (sync mit backend dashboard_widget_config). + * `visibility === undefined` → Verlauf: alles an (wie bisherige Fitness-Übersicht). + */ + +export const FITNESS_HISTORY_VIZ_HISTORY_FULL = { + chart_days: 30, + show_layer_meta: true, + show_kpis: true, + kpi_detail: 'full', + show_progress_insights: true, + show_chart_training_volume: true, + show_chart_training_type_distribution: true, + show_chart_quality_sessions: true, + show_chart_load_monitoring: true, +} + +export const FITNESS_HISTORY_VIZ_WIDGET_DEFAULTS = { + chart_days: 30, + show_layer_meta: false, + show_kpis: true, + kpi_detail: 'compact', + show_progress_insights: false, + show_chart_training_volume: true, + show_chart_training_type_distribution: true, + show_chart_quality_sessions: false, + show_chart_load_monitoring: false, +} + +const BOOL_KEYS = [ + 'show_layer_meta', + 'show_kpis', + 'show_progress_insights', + 'show_chart_training_volume', + 'show_chart_training_type_distribution', + 'show_chart_quality_sessions', + 'show_chart_load_monitoring', +] + +/** + * @param {Record|null|undefined} raw + */ +export function normalizeFitnessHistoryVizConfig(raw) { + const base = { ...FITNESS_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 filterFitnessHistoryKpiTiles(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 0992c6b..f0b2366 100644 --- a/frontend/src/widgetSystem/registerPilotLabWidgets.js +++ b/frontend/src/widgetSystem/registerPilotLabWidgets.js @@ -16,8 +16,10 @@ import NutritionActivitySummaryWidget from '../components/dashboard-widgets/Nutr import NutritionDetailChartsWidget from '../components/dashboard-widgets/NutritionDetailChartsWidget' import BodyHistoryVizWidget from '../components/dashboard-widgets/BodyHistoryVizWidget' import NutritionHistoryVizWidget from '../components/dashboard-widgets/NutritionHistoryVizWidget' +import FitnessHistoryVizWidget from '../components/dashboard-widgets/FitnessHistoryVizWidget' import { normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig' import { normalizeNutritionHistoryVizConfig } from './nutritionHistoryVizConfig' +import { normalizeFitnessHistoryVizConfig } from './fitnessHistoryVizConfig' import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget' import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget' import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget' @@ -132,6 +134,14 @@ export function ensurePilotLabWidgetsRegistered() { nutritionHistoryVizConfig: normalizeNutritionHistoryVizConfig(ctx.layoutEntry?.config), }), }) + registerDashboardWidget({ + id: 'fitness_history_viz', + Component: FitnessHistoryVizWidget, + mapProps: (ctx) => ({ + refreshTick: ctx.refreshTick, + fitnessHistoryVizConfig: normalizeFitnessHistoryVizConfig(ctx.layoutEntry?.config), + }), + }) registerDashboardWidget({ id: 'recovery_charts_panel', Component: RecoveryChartsPanelWidget,