feat: Extend widget configuration for activity overview and improve validation
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 16s

- Added support for the "activity_overview" widget in the dashboard configuration, allowing for chart_days validation.
- Refactored validation logic to streamline error handling for both "body_overview" and "activity_overview" widgets.
- Updated the widget catalog description to reflect the new configuration options.
- Enhanced the DashboardLabPage to manage chart_days input for both widgets, improving user experience.
- Bumped app_dashboard version to 1.3.0 to reflect these enhancements.
This commit is contained in:
Lars 2026-04-07 12:20:23 +02:00
parent 4493b140bd
commit b617212145
9 changed files with 164 additions and 60 deletions

View File

@ -11,7 +11,7 @@ from typing import Any
MAX_WIDGET_CONFIG_JSON_BYTES = 1024
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({"body_overview"})
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({"body_overview", "activity_overview"})
def _config_json_size_bytes(config: dict[str, Any]) -> int:
@ -32,33 +32,35 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
if widget_id == "body_overview":
return _validate_body_overview_config(raw)
return _validate_chart_days_only(raw, label="body_overview")
if widget_id == "activity_overview":
return _validate_chart_days_only(raw, label="activity_overview")
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
def _validate_body_overview_config(raw: dict[str, Any]) -> dict[str, Any]:
def _parse_chart_days(v: Any, label: str) -> int:
if isinstance(v, bool):
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
if isinstance(v, float):
if not math.isfinite(v):
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
if abs(v - round(v)) > 1e-9:
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
return int(round(v))
if isinstance(v, int):
return v
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]:
allowed = frozenset({"chart_days"})
unknown = set(raw) - allowed
if unknown:
raise ValueError(f"body_overview: unbekannte config-Felder: {sorted(unknown)}")
out: dict[str, Any] = {}
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
if "chart_days" not in raw:
return out
v = raw["chart_days"]
if isinstance(v, bool):
raise ValueError("body_overview: chart_days muss ganze Zahl sein")
if isinstance(v, float):
if not math.isfinite(v):
raise ValueError("body_overview: chart_days muss ganze Zahl sein")
if abs(v - round(v)) > 1e-9:
raise ValueError("body_overview: chart_days muss ganze Zahl sein")
v = int(round(v))
elif isinstance(v, int):
pass
else:
raise ValueError("body_overview: chart_days muss ganze Zahl sein")
return {}
v = _parse_chart_days(raw["chart_days"], label)
if v < 7 or v > 90:
raise ValueError("body_overview: chart_days muss zwischen 7 und 90 liegen")
out["chart_days"] = v
return out
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
return {"chart_days": v}

View File

@ -24,6 +24,17 @@ def test_body_unknown_key():
validate_widget_entry_config("body_overview", {"chart_days": 30, "extra": 1})
def test_activity_chart_days():
assert validate_widget_entry_config("activity_overview", {"chart_days": 14}) == {"chart_days": 14}
with pytest.raises(ValueError):
validate_widget_entry_config("activity_overview", {"chart_days": 5})
def test_kpi_config_rejected():
with pytest.raises(ValueError):
validate_widget_entry_config("kpi_board", {"chart_days": 30})
def test_layout_payload_with_chart_days_roundtrip():
p = DashboardLayoutPayload.model_validate(
{

View File

@ -30,7 +30,7 @@ MODULE_VERSIONS = {
"importdata": "1.0.0",
"membership": "2.1.0",
"workflow": "0.6.0", # Phase 4: End Node Template Engine
"app_dashboard": "1.2.0", # Widget-Config (body_overview.chart_days) + Validierung
"app_dashboard": "1.3.0", # activity_overview.chart_days + WidgetErrorBoundary
}
CHANGELOG = [

View File

@ -40,7 +40,7 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
{
"id": "activity_overview",
"title": "Aktivität",
"description": "Training & Konsistenz",
"description": "Training & Konsistenz (optional: config chart_days 790)",
},
]

View File

@ -4,11 +4,14 @@ import dayjs from 'dayjs'
import { api } from '../../utils/api'
import { useProfile } from '../../context/ProfileContext'
import TrainingTypeDistribution from '../TrainingTypeDistribution'
import {
BODY_CHART_DAYS_DEFAULT,
normalizeBodyChartDays,
} from '../../widgetSystem/bodyChartDays'
import PilotRuleCard from './PilotRuleCard'
const PERIOD = 30
export default function PilotActivitySection({ refreshTick = 0 }) {
export default function PilotActivitySection({ refreshTick = 0, chartDays = BODY_CHART_DAYS_DEFAULT }) {
const periodDays = normalizeBodyChartDays(chartDays)
const { activeProfile } = useProfile()
const globalQualityLevel = activeProfile?.quality_filter_level
const [activities, setActivities] = useState([])
@ -18,7 +21,8 @@ export default function PilotActivitySection({ refreshTick = 0 }) {
let cancelled = false
;(async () => {
try {
const a = await api.listActivity(120)
const fetchDays = Math.max(120, periodDays + 60)
const a = await api.listActivity(fetchDays)
if (!cancelled) setActivities(Array.isArray(a) ? a : [])
} catch {
if (!cancelled) setActivities([])
@ -29,15 +33,15 @@ export default function PilotActivitySection({ refreshTick = 0 }) {
return () => {
cancelled = true
}
}, [refreshTick, globalQualityLevel])
}, [refreshTick, globalQualityLevel, periodDays])
const cutoff = dayjs().subtract(PERIOD, 'day').format('YYYY-MM-DD')
const cutoff = dayjs().subtract(periodDays, 'day').format('YYYY-MM-DD')
const filtA = (activities || []).filter((d) => d.date >= cutoff)
const daysWithAct = new Set(filtA.map((a) => a.date)).size
const totalDays =
filtA.length > 0
? Math.min(PERIOD, dayjs().diff(dayjs(filtA[filtA.length - 1]?.date), 'day') + 1)
? Math.min(periodDays, dayjs().diff(dayjs(filtA[filtA.length - 1]?.date), 'day') + 1)
: 0
const consistency = totalDays > 0 ? Math.round((daysWithAct / totalDays) * 100) : 0
@ -46,7 +50,7 @@ export default function PilotActivitySection({ refreshTick = 0 }) {
status: consistency >= 70 ? 'good' : consistency >= 40 ? 'warn' : 'bad',
icon: '📅',
category: 'Konsistenz',
title: `${consistency}% aktive Tage (${daysWithAct}/${Math.min(PERIOD, totalDays || PERIOD)} Tage)`,
title: `${consistency}% aktive Tage (${daysWithAct}/${Math.min(periodDays, totalDays || periodDays)} Tage)`,
detail:
consistency >= 70
? 'Ausgezeichnete Regelmäßigkeit.'
@ -77,7 +81,7 @@ export default function PilotActivitySection({ refreshTick = 0 }) {
>
<h2 style={{ fontSize: 17, fontWeight: 700, margin: 0, color: 'var(--text1)' }}>Bereich Aktivität</h2>
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '6px 0 0', lineHeight: 1.5 }}>
Trainingstyp-Verteilung {PERIOD} Tage · Bewertung Konsistenz wie im Verlauf
Trainingstyp-Verteilung {periodDays} Tage · Bewertung Konsistenz wie im Verlauf
</p>
</div>
@ -102,7 +106,7 @@ export default function PilotActivitySection({ refreshTick = 0 }) {
<div className="card section-gap">
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Trainingstyp-Verteilung</div>
<TrainingTypeDistribution days={PERIOD} />
<TrainingTypeDistribution days={periodDays} />
<div style={{ marginTop: 8, textAlign: 'right' }}>
<Link to="/history" state={{ tab: 'activity' }} style={{ fontSize: 12, color: 'var(--accent)' }}>
Vollständiger Verlauf Aktivität

View File

@ -12,6 +12,9 @@ import {
} from '../widgetSystem/bodyChartDays'
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
/** Widgets mit optionalem config.chart_days (790), gleiche UX im Editor */
const CHART_DAYS_WIDGET_IDS = new Set(['body_overview', 'activity_overview'])
function catalogMetaById(catalog) {
if (!catalog?.widgets?.length) return {}
return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
@ -28,17 +31,19 @@ export default function DashboardLabPage() {
const [err, setErr] = useState(null)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null)
/** Während der Fokus im Körper-Chart-Feld: Rohstring, damit Tippen (z. B. „14“) nicht sofort geclamped wird */
const [bodyChartDaysDraft, setBodyChartDaysDraft] = useState(null)
/** Pro Widget-ID: Rohstring während der Eingabe (Tippen ohne sofortiges Clampen) */
const [chartDaysDraftByWidgetId, setChartDaysDraftByWidgetId] = useState({})
const metaById = catalogMetaById(catalog)
const commitBodyChartDraftToLayout = useCallback((draftStr, baseLayout) => {
const clamped = normalizeBodyChartDays(draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr)
const commitChartDaysDraftToLayout = useCallback((draftStr, baseLayout, widgetId) => {
const clamped = normalizeBodyChartDays(
draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr
)
return {
...baseLayout,
widgets: baseLayout.widgets.map((x) =>
x.id !== 'body_overview' ? x : { ...x, config: { ...x.config, chart_days: clamped } }
x.id !== widgetId ? x : { ...x, config: { ...x.config, chart_days: clamped } }
),
}
}, [])
@ -49,7 +54,7 @@ export default function DashboardLabPage() {
const [cat, b] = await Promise.all([api.getAppWidgetsCatalog(), api.getAppDashboardLayout()])
setCatalog(cat)
setBundle(b)
setBodyChartDaysDraft(null)
setChartDaysDraftByWidgetId({})
setLayout(normalizeLayoutForEditor(b.layout))
} catch (e) {
setErr(formatFastApiDetail(null, e.message))
@ -63,10 +68,13 @@ export default function DashboardLabPage() {
const save = async () => {
if (!layout) return
let toSave = layout
if (bodyChartDaysDraft !== null) {
toSave = normalizeLayoutForEditor(commitBodyChartDraftToLayout(bodyChartDaysDraft, layout))
const draftEntries = Object.entries(chartDaysDraftByWidgetId)
if (draftEntries.length) {
for (const [wid, val] of draftEntries) {
toSave = normalizeLayoutForEditor(commitChartDaysDraftToLayout(val, toSave, wid))
}
setLayout(toSave)
setBodyChartDaysDraft(null)
setChartDaysDraftByWidgetId({})
}
setBusy(true)
setMsg(null)
@ -89,7 +97,7 @@ export default function DashboardLabPage() {
setErr(null)
try {
const r = await api.resetAppDashboardLayout()
setBodyChartDaysDraft(null)
setChartDaysDraftByWidgetId({})
setLayout(normalizeLayoutForEditor(r.layout))
setMsg('Auf Standard zurückgesetzt.')
await load()
@ -102,7 +110,7 @@ export default function DashboardLabPage() {
const applyDefaultLocal = () => {
if (bundle?.default_layout) {
setBodyChartDaysDraft(null)
setChartDaysDraftByWidgetId({})
setLayout(normalizeLayoutForEditor(structuredClone(bundle.default_layout)))
setMsg('Standard geladen (noch nicht gespeichert).')
}
@ -143,7 +151,8 @@ export default function DashboardLabPage() {
</h1>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
Widget-System: Katalog, Registry, Renderer; optional pro Widget <code>config</code> (z.B.{' '}
<strong>Körper-Chart</strong> 790 Tage). Layout pro Profil in der DB getrennt vom Produktiv-Dashboard.
<strong>Körper</strong> und <strong>Aktivität</strong>: Zeitraum 790 Tage). Layout pro Profil in der DB
getrennt vom Produktiv-Dashboard.
Vergleich:{' '}
<Link to="/pilot/viz" style={{ color: 'var(--accent)' }}>
Pilot-Übersicht (festes Standard-Layout)
@ -221,10 +230,11 @@ export default function DashboardLabPage() {
</button>
</div>
</div>
{w.id === 'body_overview' && (
{CHART_DAYS_WIDGET_IDS.has(w.id) && (
<div style={{ marginTop: 10, marginLeft: 28 }}>
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
Körper-Chart Zeitraum (Tage): {BODY_CHART_DAYS_MIN}{BODY_CHART_DAYS_MAX}
{w.id === 'body_overview' ? 'Körper-Chart' : 'Aktivität (Verteilung & Konsistenz)'} Zeitraum
(Tage): {BODY_CHART_DAYS_MIN}{BODY_CHART_DAYS_MAX}
</label>
<input
type="text"
@ -232,21 +242,38 @@ export default function DashboardLabPage() {
autoComplete="off"
className="form-input"
style={{ maxWidth: 120 }}
aria-label="Körper-Chart Zeitraum in Tagen"
aria-label={
w.id === 'body_overview'
? 'Körper-Chart Zeitraum in Tagen'
: 'Aktivität Zeitraum in Tagen'
}
value={
bodyChartDaysDraft !== null
? bodyChartDaysDraft
chartDaysDraftByWidgetId[w.id] !== undefined
? chartDaysDraftByWidgetId[w.id]
: String(chartDaysVal)
}
onFocus={() => setBodyChartDaysDraft(String(chartDaysVal))}
onChange={(e) => setBodyChartDaysDraft(e.target.value)}
onBlur={() => {
onFocus={() =>
setChartDaysDraftByWidgetId((prev) => ({
...prev,
[w.id]: String(chartDaysVal),
}))
}
onChange={(e) =>
setChartDaysDraftByWidgetId((prev) => ({
...prev,
[w.id]: e.target.value,
}))
}
onBlur={(e) => {
const raw = e.target.value
setLayout((L) =>
normalizeLayoutForEditor(
commitBodyChartDraftToLayout(bodyChartDaysDraft ?? String(chartDaysVal), L)
)
normalizeLayoutForEditor(commitChartDaysDraftToLayout(raw, L, w.id))
)
setBodyChartDaysDraft(null)
setChartDaysDraftByWidgetId((prev) => {
const next = { ...prev }
delete next[w.id]
return next
})
}}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur()

View File

@ -0,0 +1,51 @@
import { Component } from 'react'
/**
* Verhindert, dass ein fehlerhaftes Dashboard-Widget die ganze Seite mitreißt.
*/
export default class WidgetErrorBoundary extends Component {
constructor(props) {
super(props)
this.state = { error: null }
}
static getDerivedStateFromError(error) {
return { error }
}
render() {
if (this.state.error) {
const msg =
this.state.error && typeof this.state.error === 'object' && 'message' in this.state.error
? String(this.state.error.message)
: String(this.state.error)
return (
<div
className="card section-gap"
style={{ marginBottom: 16, borderColor: 'var(--danger, #D85A30)' }}
>
<div className="card-title" style={{ color: 'var(--danger, #D85A30)' }}>
Widget-Fehler
</div>
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '4px 0 8px' }}>
<code>{this.props.widgetId}</code>
</p>
<pre
style={{
fontSize: 11,
overflow: 'auto',
margin: 0,
padding: 8,
background: 'var(--surface2)',
borderRadius: 8,
color: 'var(--text2)',
}}
>
{msg}
</pre>
</div>
)
}
return this.props.children
}
}

View File

@ -1,3 +1,5 @@
import WidgetErrorBoundary from './WidgetErrorBoundary'
/**
* @typedef {object} LayoutWidgetEntry
* @property {string} id
@ -52,7 +54,11 @@ export function renderRegisteredWidget(id, ctx) {
}
const { Component } = spec
const props = spec.mapProps ? spec.mapProps(ctx) : {}
return <Component key={id} {...props} />
return (
<WidgetErrorBoundary key={id} widgetId={id}>
<Component {...props} />
</WidgetErrorBoundary>
)
}
/**

View File

@ -41,7 +41,10 @@ export function ensurePilotLabWidgetsRegistered() {
registerDashboardWidget({
id: 'activity_overview',
Component: PilotActivitySection,
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
mapProps: (ctx) => ({
refreshTick: ctx.refreshTick,
chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days),
}),
})
}