From 7f833b2cb13c1a0652b13065a15d6debd8ecf942 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 7 Apr 2026 18:02:18 +0200 Subject: [PATCH] feat: Introduce quick capture widget configuration and validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added support for the "quick_capture" widget, allowing users to configure visibility for weight and baseline vitals (resting HR, HRV, VO₂max). - Implemented validation logic to ensure correct configuration input and prevent errors. - Updated the widget catalog and dashboard layout to reflect the new quick capture features. - Removed the "training_type_distribution" widget from the catalog as part of the refactor. - Bumped app_dashboard version to 1.6.2 to incorporate these enhancements. --- backend/dashboard_widget_config.py | 45 ++++-- backend/tests/test_dashboard_widget_config.py | 37 +++-- backend/version.py | 2 +- backend/widget_catalog.py | 9 +- .../TrainingTypeDistributionWidget.jsx | 25 --- .../components/pilot/PilotQuickCapture.jsx | 150 ++++++++++++++---- frontend/src/pages/DashboardLabPage.jsx | 22 +++ .../widgetSystem/QuickCaptureConfigEditor.jsx | 67 ++++++++ .../widgetSystem/registerPilotLabWidgets.js | 17 +- 9 files changed, 267 insertions(+), 107 deletions(-) delete mode 100644 frontend/src/components/dashboard-widgets/TrainingTypeDistributionWidget.jsx create mode 100644 frontend/src/widgetSystem/QuickCaptureConfigEditor.jsx diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py index 432df73..57c6a58 100644 --- a/backend/dashboard_widget_config.py +++ b/backend/dashboard_widget_config.py @@ -16,8 +16,15 @@ WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({ "body_overview", "activity_overview", "kpi_board", + "quick_capture", "trend_kcal_weight", - "training_type_distribution", +}) + +_QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({ + "show_weight", + "show_resting_hr", + "show_hrv", + "show_vo2_max", }) _KPI_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"}) @@ -47,14 +54,34 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: return _validate_chart_days_only(raw, label="activity_overview") if widget_id == "kpi_board": return _validate_kpi_board_config(raw) + if widget_id == "quick_capture": + return _validate_quick_capture_config(raw) if widget_id == "trend_kcal_weight": return _validate_chart_days_only(raw, label="trend_kcal_weight") - if widget_id == "training_type_distribution": - return _validate_distribution_days_only(raw) raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt") +def _validate_quick_capture_config(raw: dict[str, Any]) -> dict[str, Any]: + label = "quick_capture" + unknown = set(raw) - _QUICK_CAPTURE_KEYS + if unknown: + raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}") + out: dict[str, bool] = {} + for k in _QUICK_CAPTURE_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 + merged = {k: True for k in _QUICK_CAPTURE_KEYS} + merged.update(out) + if not any(merged.values()): + raise ValueError(f"{label}: mindestens ein Bereich muss sichtbar sein (show_*)") + return out + + def _kpi_tile_id_valid(tid: str) -> bool: if tid in _KPI_TILE_FIXED: return True @@ -130,15 +157,3 @@ def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, A return {"chart_days": v} -def _validate_distribution_days_only(raw: dict[str, Any]) -> dict[str, Any]: - label = "training_type_distribution" - allowed = frozenset({"distribution_days"}) - unknown = set(raw) - allowed - if unknown: - raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}") - if "distribution_days" not in raw: - return {} - v = _parse_chart_days(raw["distribution_days"], label) - if v < 7 or v > 120: - raise ValueError(f"{label}: distribution_days muss zwischen 7 und 120 liegen") - return {"distribution_days": v} diff --git a/backend/tests/test_dashboard_widget_config.py b/backend/tests/test_dashboard_widget_config.py index 875238b..f2287fc 100644 --- a/backend/tests/test_dashboard_widget_config.py +++ b/backend/tests/test_dashboard_widget_config.py @@ -45,6 +45,32 @@ def test_kpi_board_tiles(): validate_widget_entry_config("kpi_board", {"extra": 1}) +def test_quick_capture_visibility(): + assert validate_widget_entry_config("quick_capture", {}) == {} + assert validate_widget_entry_config("quick_capture", {"show_weight": False}) == {"show_weight": False} + full = { + "show_weight": True, + "show_resting_hr": False, + "show_hrv": True, + "show_vo2_max": False, + } + assert validate_widget_entry_config("quick_capture", full) == full + with pytest.raises(ValueError): + validate_widget_entry_config("quick_capture", {"show_weight": "yes"}) + with pytest.raises(ValueError): + validate_widget_entry_config( + "quick_capture", + { + "show_weight": False, + "show_resting_hr": False, + "show_hrv": False, + "show_vo2_max": False, + }, + ) + with pytest.raises(ValueError): + validate_widget_entry_config("quick_capture", {"extra": 1}) + + def test_trend_kcal_weight_chart_days(): assert validate_widget_entry_config("trend_kcal_weight", {}) == {} assert validate_widget_entry_config("trend_kcal_weight", {"chart_days": 30}) == {"chart_days": 30} @@ -52,17 +78,6 @@ def test_trend_kcal_weight_chart_days(): validate_widget_entry_config("trend_kcal_weight", {"chart_days": 6}) -def test_training_type_distribution_days(): - assert validate_widget_entry_config("training_type_distribution", {}) == {} - assert validate_widget_entry_config( - "training_type_distribution", {"distribution_days": 28} - ) == {"distribution_days": 28} - with pytest.raises(ValueError): - validate_widget_entry_config("training_type_distribution", {"distribution_days": 5}) - with pytest.raises(ValueError): - validate_widget_entry_config("training_type_distribution", {"distribution_days": 200}) - - def test_kpi_board_legacy_chart_days_dropped(): """Nur chart_days (Alt-Layouts) → automatische Kachelwahl, kein Ø-Kal-Fenster mehr.""" assert validate_widget_entry_config("kpi_board", {"chart_days": 14}) == {} diff --git a/backend/version.py b/backend/version.py index 379d111..e2cb67f 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.6.0", # Phase 4: End Node Template Engine - "app_dashboard": "1.6.0", # P1 Produkt-Widgets im Katalog + Default nur Kern-5 aktiv + "app_dashboard": "1.6.2", # quick_capture: Sichtbarkeit show_* konfigurierbar } CHANGELOG = [ diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py index 80fbda6..b0c8f9e 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -25,7 +25,7 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [ { "id": "quick_capture", "title": "Schnelleingabe", - "description": "Gewicht und Vitalwerte erfassen", + "description": "Gewicht + Baseline-Vitals; optional show_weight / show_resting_hr / show_hrv / show_vo2_max (false = aus)", }, { "id": "kpi_board", @@ -40,7 +40,7 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [ { "id": "activity_overview", "title": "Aktivität", - "description": "Training & Konsistenz (optional: config chart_days 7–90)", + "description": "Trainingstyp-Verteilung (Kuchen) + Konsistenz — Zeitraum über config chart_days 7–90", }, { "id": "dashboard_greeting", @@ -82,11 +82,6 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [ "title": "Erholung", "description": "Schlaf-Widget & Ruhetage", }, - { - "id": "training_type_distribution", - "title": "Training Verteilung", - "description": "Kuchen Trainingstypen (optional config distribution_days 7–120, Default 28)", - }, { "id": "goals_focus_teaser", "title": "Ziele Teaser", diff --git a/frontend/src/components/dashboard-widgets/TrainingTypeDistributionWidget.jsx b/frontend/src/components/dashboard-widgets/TrainingTypeDistributionWidget.jsx deleted file mode 100644 index 2ab05ac..0000000 --- a/frontend/src/components/dashboard-widgets/TrainingTypeDistributionWidget.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useNavigate } from 'react-router-dom' -import TrainingTypeDistribution from '../TrainingTypeDistribution' - -/** - * @param {{ refreshTick?: number, distributionDays?: number }} props - */ -export default function TrainingTypeDistributionWidget({ refreshTick = 0, distributionDays = 28 }) { - const nav = useNavigate() - const days = Math.max(7, Math.min(120, Number(distributionDays) || 28)) - - return ( -
-
-
-
Training
-
Verteilung der Trainingstypen ({days} Tage)
-
- -
- -
- ) -} diff --git a/frontend/src/components/pilot/PilotQuickCapture.jsx b/frontend/src/components/pilot/PilotQuickCapture.jsx index 17cd288..2854bee 100644 --- a/frontend/src/components/pilot/PilotQuickCapture.jsx +++ b/frontend/src/components/pilot/PilotQuickCapture.jsx @@ -6,8 +6,16 @@ import { api } from '../../utils/api' /** * Schnelleingabe: Gewicht + Baseline Vitals (Ruhepuls, HRV, VO₂max) für heute. + * @param {{ onSaved?: () => void, captureConfig?: Record }} props + * captureConfig: show_weight, show_resting_hr, show_hrv, show_vo2_max (false = ausblenden; fehlend = true) */ -export default function PilotQuickCapture({ onSaved }) { +export default function PilotQuickCapture({ onSaved, captureConfig }) { + const cfgRaw = captureConfig && typeof captureConfig === 'object' ? captureConfig : {} + const showWeight = cfgRaw.show_weight !== false + const showRestingHr = cfgRaw.show_resting_hr !== false + const showHrv = cfgRaw.show_hrv !== false + const showVo2 = cfgRaw.show_vo2_max !== false + const showVitalsBlock = showRestingHr || showHrv || showVo2 const today = dayjs().format('YYYY-MM-DD') const [weightInput, setWeightInput] = useState('') const [weightSaving, setWeightSaving] = useState(false) @@ -77,12 +85,17 @@ export default function PilotQuickCapture({ onSaved }) { setVOk(false) try { const payload = { date: today } - if (vForm.resting_hr) payload.resting_hr = parseInt(vForm.resting_hr, 10) - if (vForm.hrv) payload.hrv = parseInt(vForm.hrv, 10) - if (vForm.vo2_max) payload.vo2_max = parseFloat(vForm.vo2_max) + if (showRestingHr && vForm.resting_hr) payload.resting_hr = parseInt(vForm.resting_hr, 10) + if (showHrv && vForm.hrv) payload.hrv = parseInt(vForm.hrv, 10) + if (showVo2 && vForm.vo2_max) payload.vo2_max = parseFloat(vForm.vo2_max) if (!payload.resting_hr && !payload.hrv && !payload.vo2_max) { - setVErr('Mindestens Ruhepuls, HRV oder VO₂max angeben.') + const hint = [showRestingHr && 'Ruhepuls', showHrv && 'HRV', showVo2 && 'VO₂max'].filter(Boolean).join(', ') + setVErr( + hint + ? `Mindestens einen sichtbaren Wert angeben (${hint}).` + : 'Keine Vitalfelder sichtbar.' + ) setVSaving(false) return } @@ -112,17 +125,34 @@ export default function PilotQuickCapture({ onSaved }) { background: 'var(--surface2)', } + if (!showWeight && !showVitalsBlock) { + return ( +
+
Schnelleingabe (heute)
+

+ Für dieses Widget sind keine Eingabebereiche aktiviert. Im Dashboard-Lab die Sichtbarkeit prüfen + oder Vitalwerte-Seite nutzen. +

+
+ ) + } + return (
Schnelleingabe (heute)
-

- Gewicht separat; Vitalwerte typischerweise gemeinsam.{' '} - - Volle Vitalwerte-Seite → - -

+ {(showWeight || showVitalsBlock) && ( +

+ {showWeight && showVitalsBlock && 'Gewicht separat; Vitalwerte typischerweise gemeinsam. '} + {showWeight && !showVitalsBlock && 'Gewicht für heute. '} + {!showWeight && showVitalsBlock && 'Baseline-Vitalwerte für heute. '} + + Volle Vitalwerte-Seite → + +

+ )}
+ {showWeight && (
Gewicht
{weightErr && ( @@ -152,35 +182,84 @@ export default function PilotQuickCapture({ onSaved }) {
+ )} -
+ {showVitalsBlock && ( +
Vitalwerte (Baseline)
{vErr &&
{vErr}
} -
- setVForm((f) => ({ ...f, resting_hr: e.target.value }))} - /> - setVForm((f) => ({ ...f, hrv: e.target.value }))} - /> - setVForm((f) => ({ ...f, vo2_max: e.target.value }))} - /> +
+ {showRestingHr && ( +
+ + setVForm((f) => ({ ...f, resting_hr: e.target.value }))} + /> +
+ )} + {showHrv && ( +
+ + setVForm((f) => ({ ...f, hrv: e.target.value }))} + /> +
+ )} + {showVo2 && ( +
+ + setVForm((f) => ({ ...f, vo2_max: e.target.value }))} + /> +
+ )}
+ )}
) diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx index 0dea543..fc38925 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -11,6 +11,7 @@ import { normalizeBodyChartDays, } from '../widgetSystem/bodyChartDays' import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' +import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor' /** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */ @@ -234,6 +235,27 @@ export default function DashboardLabPage() {
+ {w.id === 'quick_capture' && ( + + setLayout((L) => + normalizeLayoutForEditor({ + ...L, + widgets: L.widgets.map((x, j) => { + if (j !== i) return x + const cfg = { ...(x.config || {}) } + for (const k of ['show_weight', 'show_resting_hr', 'show_hrv', 'show_vo2_max']) { + delete cfg[k] + } + Object.assign(cfg, next) + return { ...x, config: cfg } + }), + }) + ) + } + /> + )} {w.id === 'kpi_board' && ( , onChange: (next: Record) => void }} props */ +export default function QuickCaptureConfigEditor({ config, onChange }) { + const vis = mergeFromConfig(config) + + const setKey = (k, checked) => { + const next = { ...vis, [k]: checked } + if (!next.show_weight && !next.show_resting_hr && !next.show_hrv && !next.show_vo2_max) { + return + } + const stored = {} + for (const { key } of KEYS) { + if (!next[key]) stored[key] = false + } + onChange(stored) + } + + const resetAllVisible = () => onChange({}) + + return ( +
+
+ Schnelleingabe: welche Bereiche angezeigt werden. Ohne Eintrag = alles sichtbar. +
+
+ {KEYS.map(({ key, label }) => ( + + ))} +
+ +
+ ) +} diff --git a/frontend/src/widgetSystem/registerPilotLabWidgets.js b/frontend/src/widgetSystem/registerPilotLabWidgets.js index c72546e..1e45fcf 100644 --- a/frontend/src/widgetSystem/registerPilotLabWidgets.js +++ b/frontend/src/widgetSystem/registerPilotLabWidgets.js @@ -14,7 +14,6 @@ import ProfileGoalsProgressWidget from '../components/dashboard-widgets/ProfileG import TrendKcalWeightWidget from '../components/dashboard-widgets/TrendKcalWeightWidget' import NutritionActivitySummaryWidget from '../components/dashboard-widgets/NutritionActivitySummaryWidget' import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget' -import TrainingTypeDistributionWidget from '../components/dashboard-widgets/TrainingTypeDistributionWidget' import GoalsFocusTeaserWidget from '../components/dashboard-widgets/GoalsFocusTeaserWidget' import AiPipelineInsightWidget from '../components/dashboard-widgets/AiPipelineInsightWidget' import { normalizeBodyChartDays } from './bodyChartDays' @@ -34,7 +33,10 @@ export function ensurePilotLabWidgetsRegistered() { registerDashboardWidget({ id: 'quick_capture', Component: PilotQuickCapture, - mapProps: (ctx) => ({ onSaved: ctx.requestRefresh }), + mapProps: (ctx) => ({ + onSaved: ctx.requestRefresh, + captureConfig: ctx.layoutEntry?.config || {}, + }), }) registerDashboardWidget({ id: 'kpi_board', @@ -104,17 +106,6 @@ export function ensurePilotLabWidgetsRegistered() { Component: RecoverySleepRestWidget, mapProps: () => ({}), }) - registerDashboardWidget({ - id: 'training_type_distribution', - Component: TrainingTypeDistributionWidget, - mapProps: (ctx) => ({ - refreshTick: ctx.refreshTick, - distributionDays: - ctx.layoutEntry?.config?.distribution_days != null - ? Number(ctx.layoutEntry.config.distribution_days) - : 28, - }), - }) registerDashboardWidget({ id: 'goals_focus_teaser', Component: GoalsFocusTeaserWidget,