feat: Extend widget configuration for activity overview and improve validation
- 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:
parent
4493b140bd
commit
b617212145
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
|||
{
|
||||
"id": "activity_overview",
|
||||
"title": "Aktivität",
|
||||
"description": "Training & Konsistenz",
|
||||
"description": "Training & Konsistenz (optional: config chart_days 7–90)",
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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 →
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import {
|
|||
} from '../widgetSystem/bodyChartDays'
|
||||
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
|
||||
|
||||
/** Widgets mit optionalem config.chart_days (7–90), 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> 7–90 Tage). Layout pro Profil in der DB — getrennt vom Produktiv-Dashboard.
|
||||
<strong>Körper</strong> und <strong>Aktivität</strong>: Zeitraum 7–90 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()
|
||||
|
|
|
|||
51
frontend/src/widgetSystem/WidgetErrorBoundary.jsx
Normal file
51
frontend/src/widgetSystem/WidgetErrorBoundary.jsx
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user