feat: add recovery_history_viz widget and enhance configuration handling
- 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:
parent
d22e0ba0a7
commit
e20b321b64
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"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",
|
"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",
|
"id": "recovery_charts_panel",
|
||||||
"title": "Erholung — Charts R1–R5",
|
"title": "Erholung — Charts R1–R5",
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (7–90)
|
||||||
|
* @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 0–100 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 0–100 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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 (7–90), gleiche UX im Editor */
|
/** 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_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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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={{
|
||||||
|
|
|
||||||
106
frontend/src/widgetSystem/RecoveryHistoryVizConfigEditor.jsx
Normal file
106
frontend/src/widgetSystem/RecoveryHistoryVizConfigEditor.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
frontend/src/widgetSystem/recoveryHistoryVizConfig.js
Normal file
85
frontend/src/widgetSystem/recoveryHistoryVizConfig.js
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user