feat: add fitness_history_viz widget and enhance configuration handling
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 18s

- Introduced the `fitness_history_viz` widget to the dashboard, enabling users to visualize fitness history data.
- Updated widget configuration to include `fitness_history_viz` in the allowed widgets and added validation for its configuration.
- Enhanced the widget catalog with details for the new `fitness_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 `fitness_history_viz` widget configuration.
- Bumped application version to reflect the addition of the new widget.
This commit is contained in:
Lars 2026-04-22 10:13:21 +02:00
parent db5557e4aa
commit d22e0ba0a7
14 changed files with 616 additions and 179 deletions

View File

@ -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

View File

@ -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})

View File

@ -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

View File

@ -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)
}

View File

@ -106,6 +106,12 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
"description": "Layer-2b nutrition-history-viz: schlanker Standard; Blöcke per show_* / kpi_detail; chart_days 790; 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 790; Feature activity_entries",
"requires_feature": "activity_entries",
},
{
"id": "recovery_charts_panel",
"title": "Erholung — Charts R1R5",

View File

@ -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 790)
* @param {boolean} [props.embedded]
* @param {Record<string, unknown>} [props.visibility] Dashboard-Config; undefined = voller Verlauf
* @param {import('react').ReactNode} [props.footer]
*/
export default function 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 (
<div className="card section-gap">
<div className="card-title">Fitness-Übersicht</div>
<div className={outerClass || undefined}>
{!embedded && <div className="card-title">Fitness-Übersicht</div>}
<div className="spinner" style={{ margin: 24 }} />
</div>
)
@ -74,8 +96,8 @@ export default function FitnessDashboardOverview({
if (err) {
return (
<div className="card section-gap">
<div className="card-title">Fitness-Übersicht</div>
<div className={outerClass || undefined}>
{!embedded && <div className="card-title">Fitness-Übersicht</div>}
<div style={{ color: 'var(--danger)' }}>{err}</div>
</div>
)
@ -83,8 +105,8 @@ export default function FitnessDashboardOverview({
if (!viz?.has_activity_entries) {
return (
<div className="card section-gap">
<div className="card-title">Fitness-Übersicht</div>
<div className={outerClass || undefined}>
{!embedded && <div className="card-title">Fitness-Übersicht</div>}
<p style={{ fontSize: 12, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}>
Noch keine Aktivitätsdaten. Sobald du Trainings erfasst oder importierst, erscheinen Auswertungen hier.
</p>
@ -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 (
<div className="card section-gap">
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
<span>Fitness-Übersicht</span>
{showPeriodDropdown ? (
<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))}
<div className={outerClass || undefined}>
{!embedded && (
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
<span>Fitness-Übersicht</span>
{showPeriodDropdown ? (
<label
style={{ fontSize: 12, fontWeight: 500, color: 'var(--text3)', display: 'flex', alignItems: 'center', gap: 8 }}
>
{PERIODS.map((p) => (
<option key={p.v} value={p.v}>
{p.label}
</option>
))}
</select>
</label>
) : null}
</div>
Zeitraum
<select
className="form-input"
style={{ maxWidth: 140, padding: '6px 10px', fontSize: 13 }}
value={period}
onChange={(e) => setPeriod(Number(e.target.value))}
>
{PERIODS.map((p) => (
<option key={p.v} value={p.v}>
{p.label}
</option>
))}
</select>
</label>
) : null}
</div>
)}
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Alles aus dem Aktivitäts-Data-Layer (Issue 53). Zusammenfassung ca. <strong>{eff}</strong> Tage · Volumen{' '}
<strong>{wUsed}</strong> Wochen · Kategorien <strong>{dTyp}</strong> Tage · Load-Zeitreihe{' '}
<strong>{loadDays ?? '—'}</strong> Tage
{viz.last_updated ? (
<>
{' '}
· letzte Aktivität <strong>{viz.last_updated}</strong>
</>
) : null}
.
</p>
{embedded && viz?.last_updated ? (
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10 }}>
Letzte Aktivität {viz.last_updated}
</div>
) : null}
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" />
{display.show_layer_meta ? (
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Alles aus dem Aktivitäts-Data-Layer (Issue 53). Zusammenfassung ca. <strong>{eff}</strong> Tage · Volumen{' '}
<strong>{wUsed}</strong> Wochen · Kategorien <strong>{dTyp}</strong> Tage · Load-Zeitreihe{' '}
<strong>{loadDays ?? '—'}</strong> Tage
{viz.last_updated ? (
<>
{' '}
· letzte Aktivität <strong>{viz.last_updated}</strong>
</>
) : null}
.
</p>
) : null}
{insights.length > 0 ? (
{kpiTilesShown.length > 0 ? <KpiTilesOverview tiles={kpiTilesShown} heading="Kennzahlen" /> : null}
{display.show_progress_insights && insights.length > 0 ? (
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einschätzungen</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
@ -213,136 +248,155 @@ export default function FitnessDashboardOverview({
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
gap: 16,
marginTop: 8,
minWidth: 0,
}}
>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Trainingsvolumen (Minuten / Woche)
</div>
{volRows.length >= 1 ? (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={volRows} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis
dataKey="name"
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
interval={0}
angle={-35}
textAnchor="end"
height={48}
/>
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
formatter={(v) => [`${Math.round(v)} min`, 'Volumen']}
/>
<Bar dataKey="min" fill="#1D9E75" radius={[3, 3, 0, 0]} name="Minuten" />
</BarChart>
</ResponsiveContainer>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Wochendaten im gewählten Fenster.</div>
)}
</div>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Training nach Kategorie
</div>
{pieData.length >= 1 ? (
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={72}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
/>
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine kategorisierten Sessions im Fenster.</div>
)}
</div>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Qualitäts-Sessions (Schätzung)
</div>
{qualBar.length >= 1 ? (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={qualBar} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} allowDecimals={false} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
<Bar dataKey="n" radius={[3, 3, 0, 0]}>
{qualBar.map((entry, i) => (
<Cell key={`q-${i}`} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Daten.</div>
)}
</div>
<div style={{ gridColumn: '1 / -1', maxWidth: '100%' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Belastung (Proxy-Load · duration×RPE / Tag)
</div>
{loadRows.length >= 1 ? (
<>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={loadRows} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="t" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
<Line type="monotone" dataKey="load" stroke="#1D9E75" strokeWidth={2} dot={false} name="Load" />
</LineChart>
</ResponsiveContainer>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 6, lineHeight: 1.4 }}>
ACWR {loadMeta.acwr != null ? Number(loadMeta.acwr).toFixed(2) : '—'} (
{loadMeta.acwr_status === 'optimal' ? 'oft als günstig beschrieben' : 'außerhalb 0,81,3'} · Proxy)
{display.show_chart_training_volume ? (
<div style={gridWrapStyle}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Trainingsvolumen (Minuten / Woche)
</div>
{volRows.length >= 1 ? (
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={volRows} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis
dataKey="name"
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
interval={0}
angle={-35}
textAnchor="end"
height={48}
/>
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
formatter={(v) => [`${Math.round(v)} min`, 'Volumen']}
/>
<Bar dataKey="min" fill="#1D9E75" radius={[3, 3, 0, 0]} name="Minuten" />
</BarChart>
</ResponsiveContainer>
</div>
</>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Load-Daten im Fenster.</div>
)}
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Wochendaten im gewählten Fenster.</div>
)}
</div>
) : null}
{display.show_chart_training_type_distribution ? (
<div style={gridWrapStyle}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Training nach Kategorie
</div>
{pieData.length >= 1 ? (
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={72}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
/>
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine kategorisierten Sessions im Fenster.</div>
)}
</div>
) : null}
{display.show_chart_quality_sessions ? (
<div style={gridWrapStyle}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Qualitäts-Sessions (Schätzung)
</div>
{qualBar.length >= 1 ? (
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={qualBar} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} allowDecimals={false} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
<Bar dataKey="n" radius={[3, 3, 0, 0]}>
{qualBar.map((entry, i) => (
<Cell key={`q-${i}`} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Daten.</div>
)}
</div>
) : null}
{display.show_chart_load_monitoring ? (
<div style={{ ...gridWrapStyle, gridColumn: '1 / -1', maxWidth: '100%' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Belastung (Proxy-Load · duration×RPE / Tag)
</div>
{loadRows.length >= 1 ? (
<>
<div style={{ width: '100%', minWidth: 0, height: chartLoadH }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={loadRows} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="t" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
<Line type="monotone" dataKey="load" stroke="#1D9E75" strokeWidth={2} dot={false} name="Load" />
</LineChart>
</ResponsiveContainer>
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 6, lineHeight: 1.4 }}>
ACWR {loadMeta.acwr != null ? Number(loadMeta.acwr).toFixed(2) : '—'} (
{loadMeta.acwr_status === 'optimal' ? 'oft als günstig beschrieben' : 'außerhalb 0,81,3'} · Proxy)
</div>
</>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Load-Daten im Fenster.</div>
)}
</div>
) : null}
</div>
{footer}
</div>
)
}

View File

@ -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<string, unknown> }} props
*/
export default function FitnessHistoryVizWidget({ refreshTick = 0, fitnessHistoryVizConfig }) {
const nav = useNavigate()
const cfg = normalizeFitnessHistoryVizConfig(fitnessHistoryVizConfig)
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)' }}>Fitness (Verlauf-Bundle)</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>fitness-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>
<FitnessHistoryVizSection key={`${refreshTick}-${days}`} externalPeriod={days} embedded visibility={cfg} />
</div>
)
}

View File

@ -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 (790)
* @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<string, unknown>} [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 (
<FitnessDashboardOverview
period={period}
onPeriodChange={onPeriodChange}
hidePeriodSelector={hidePeriodSelector}
externalPeriod={externalPeriod}
embedded={embedded}
visibility={visibility}
footer={footer}
/>
)
}

View File

@ -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' && (
<FitnessHistoryVizConfigEditor
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>
)
})}

View File

@ -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 (790), 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}
</label>
<input
@ -350,7 +354,9 @@ export default function DashboardLabPage() {
? 'Ernährungs-Charts Zeitraum in Tagen'
: w.id === 'nutrition_history_viz'
? 'Ernährung Verlauf-Bundle Zeitraum in Tagen'
: 'Erholungs-Charts Zeitraum in Tagen'
: w.id === 'fitness_history_viz'
? 'Fitness Verlauf-Bundle Zeitraum in Tagen'
: 'Erholungs-Charts Zeitraum in Tagen'
}
value={
chartDaysDraftByWidgetId[w.id] !== undefined
@ -420,6 +426,23 @@ export default function DashboardLabPage() {
}
/>
)}
{w.id === 'fitness_history_viz' && (
<FitnessHistoryVizConfigEditor
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>
)
})}

View File

@ -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
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Fitness und Erholung aus den Data-Layer-Bundles (Issue 53). Zeitraum-Buttons steuern beide Bereiche gleichzeitig.
</p>
<FitnessDashboardOverview period={period} onPeriodChange={setPeriod} hidePeriodSelector />
<FitnessHistoryVizSection period={period} onPeriodChange={setPeriod} hidePeriodSelector />
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 20 }}>
Erholung (Schlaf, HRV, Vitalwerte)

View File

@ -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<string, unknown>, onChange: (next: Record<string, unknown>) => 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 (
<div style={{ marginTop: 10, marginLeft: 28 }}>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8, lineHeight: 1.5 }}>
<strong>Fitness (Verlauf-Bundle):</strong> welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker
Standard (KPI kompakt, Volumen + Kategorien).
</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="fitness_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="fitness_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' }}>Charts</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{CHART_TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
<span>{label}</span>
</label>
))}
</div>
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: 10, fontSize: 12, padding: '6px 12px' }}
onClick={() => onChange({})}
>
Auf schlanken Standard zurück
</button>
</div>
)
}

View File

@ -0,0 +1,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<string, unknown>|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<object>} kpiTiles
* @param {'compact'|'full'} detail
*/
export function filterFitnessHistoryKpiTiles(kpiTiles, detail) {
if (detail === 'full' || !Array.isArray(kpiTiles)) return kpiTiles
return kpiTiles.slice(0, 4)
}

View File

@ -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,