feat: Add new widgets and enhance configuration validation
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s

- Introduced "nutrition_detail_charts", "recovery_charts_panel", and "progress_photos" widgets to the dashboard.
- Updated widget configuration validation to support new widgets, including chart days for nutrition and recovery charts.
- Enhanced the widget catalog and dashboard layout to include the new features.
- Bumped app_dashboard version to 1.7.0 to reflect these additions and improvements.
This commit is contained in:
Lars 2026-04-07 20:58:44 +02:00
parent 7f833b2cb1
commit bc91396885
9 changed files with 226 additions and 4 deletions

View File

@ -18,6 +18,8 @@ WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
"kpi_board",
"quick_capture",
"trend_kcal_weight",
"nutrition_detail_charts",
"recovery_charts_panel",
})
_QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
@ -58,6 +60,10 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
return _validate_quick_capture_config(raw)
if widget_id == "trend_kcal_weight":
return _validate_chart_days_only(raw, label="trend_kcal_weight")
if widget_id == "nutrition_detail_charts":
return _validate_chart_days_only(raw, label="nutrition_detail_charts")
if widget_id == "recovery_charts_panel":
return _validate_chart_days_only(raw, label="recovery_charts_panel")
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")

View File

@ -71,6 +71,20 @@ def test_quick_capture_visibility():
validate_widget_entry_config("quick_capture", {"extra": 1})
def test_nutrition_detail_charts_days():
assert validate_widget_entry_config("nutrition_detail_charts", {}) == {}
assert validate_widget_entry_config("nutrition_detail_charts", {"chart_days": 60}) == {"chart_days": 60}
with pytest.raises(ValueError):
validate_widget_entry_config("nutrition_detail_charts", {"chart_days": 3})
def test_recovery_charts_panel_days():
assert validate_widget_entry_config("recovery_charts_panel", {}) == {}
assert validate_widget_entry_config("recovery_charts_panel", {"chart_days": 28}) == {"chart_days": 28}
with pytest.raises(ValueError):
validate_widget_entry_config("recovery_charts_panel", {"chart_days": 99})
def test_trend_kcal_weight_chart_days():
assert validate_widget_entry_config("trend_kcal_weight", {}) == {}
assert validate_widget_entry_config("trend_kcal_weight", {"chart_days": 30}) == {"chart_days": 30}

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.6.2", # quick_capture: Sichtbarkeit show_* konfigurierbar
"app_dashboard": "1.7.0", # nutrition_detail_charts, recovery_charts_panel, progress_photos
}
CHANGELOG = [

View File

@ -77,6 +77,21 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
"title": "Ernährung & Aktivität Kurz",
"description": "Ø 7T Kacheln",
},
{
"id": "nutrition_detail_charts",
"title": "Ernährung — Detaillierte Charts",
"description": "Phase-0c NutritionCharts (optional chart_days 790, Default 30)",
},
{
"id": "recovery_charts_panel",
"title": "Erholung — Charts R1R5",
"description": "RecoveryCharts wie Verlauf (optional chart_days 790, Default 28)",
},
{
"id": "progress_photos",
"title": "Fortschrittsfotos",
"description": "Galerie der hochgeladenen Fotos",
},
{
"id": "recovery_sleep_rest",
"title": "Erholung",

View File

@ -0,0 +1,32 @@
import { useNavigate } from 'react-router-dom'
import NutritionCharts from '../NutritionCharts'
import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays'
/**
* Phase-0c-Ernährungscharts (wie Detaillierte Charts im Verlauf).
* @param {{ refreshTick?: number, chartDays?: number }} props
*/
export default function NutritionDetailChartsWidget({ refreshTick = 0, chartDays }) {
const nav = useNavigate()
const days = chartDays != null ? normalizeBodyChartDays(chartDays) : 30
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)' }}>Ernährung Charts</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>API-Charts · {days} Tage</div>
</div>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: 12, padding: '6px 12px' }}
onClick={() => nav('/history', { state: { tab: 'nutrition' } })}
>
Verlauf
</button>
</div>
<NutritionCharts key={`${refreshTick}-${days}`} days={days} />
</div>
)
}

View File

@ -0,0 +1,86 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../../utils/api'
/**
* Fortschrittsfotos (Galerie wie Verlauf-Tab Fotos).
*/
export default function ProgressPhotosWidget({ refreshTick = 0 }) {
const nav = useNavigate()
const [photos, setPhotos] = useState([])
const [big, setBig] = useState(null)
useEffect(() => {
api.listPhotos().then(setPhotos).catch(() => setPhotos([]))
}, [refreshTick])
if (!photos.length) {
return (
<div className="card section-gap" style={{ marginBottom: 16, textAlign: 'center', padding: 24 }}>
<div style={{ fontSize: 13, color: 'var(--text3)', marginBottom: 12 }}>Noch keine Fotos.</div>
<button type="button" className="btn btn-primary" onClick={() => nav('/capture')}>
Zur Erfassung
</button>
</div>
)
}
return (
<div className="card section-gap" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Fortschrittsfotos</div>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: 12, padding: '6px 12px' }}
onClick={() => nav('/history', { state: { tab: 'photos' } })}
>
Verlauf
</button>
</div>
{big && (
<div
role="presentation"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.9)',
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={() => setBig(null)}
>
<img src={api.photoUrl(big)} style={{ maxWidth: '100%', maxHeight: '100%', borderRadius: 8 }} alt="" />
</div>
)}
<div className="photo-grid">
{photos.map((p) => (
<div key={p.id} style={{ position: 'relative' }}>
<img
src={api.photoUrl(p.id)}
className="photo-thumb"
alt=""
onClick={() => setBig(p.id)}
/>
<div
style={{
position: 'absolute',
bottom: 4,
left: 4,
fontSize: 9,
background: 'rgba(0,0,0,0.6)',
color: 'white',
padding: '1px 4px',
borderRadius: 3,
}}
>
{p.date?.slice(0, 10) || p.created?.slice(0, 10)}
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,32 @@
import { useNavigate } from 'react-router-dom'
import RecoveryCharts from '../RecoveryCharts'
import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays'
/**
* Erholung R1R5 (wie Verlauf Erholung).
* @param {{ refreshTick?: number, chartDays?: number }} props
*/
export default function RecoveryChartsPanelWidget({ refreshTick = 0, chartDays }) {
const nav = useNavigate()
const days = chartDays != null ? normalizeBodyChartDays(chartDays) : 28
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 Charts</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Schlaf, Recovery, Vitalwerte · {days} Tage</div>
</div>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: 12, padding: '6px 12px' }}
onClick={() => nav('/history', { state: { tab: 'recovery' } })}
>
Verlauf
</button>
</div>
<RecoveryCharts key={`${refreshTick}-${days}`} days={days} />
</div>
)
}

View File

@ -15,7 +15,12 @@ import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
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'])
const CHART_DAYS_WIDGET_IDS = new Set([
'body_overview',
'activity_overview',
'nutrition_detail_charts',
'recovery_charts_panel',
])
function catalogMetaById(catalog) {
if (!catalog?.widgets?.length) return {}
@ -283,7 +288,11 @@ export default function DashboardLabPage() {
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
{w.id === 'body_overview'
? 'Körper-Chart'
: 'Aktivität (Verteilung & Konsistenz)'}{' '}
: w.id === 'activity_overview'
? 'Aktivität (Verteilung & Konsistenz)'
: w.id === 'nutrition_detail_charts'
? 'Ernährung — Charts'
: 'Erholung — Charts'}{' '}
Zeitraum (Tage): {BODY_CHART_DAYS_MIN}{BODY_CHART_DAYS_MAX}
</label>
<input
@ -295,7 +304,11 @@ export default function DashboardLabPage() {
aria-label={
w.id === 'body_overview'
? 'Körper-Chart Zeitraum in Tagen'
: 'Aktivität Zeitraum in Tagen'
: w.id === 'activity_overview'
? 'Aktivität Zeitraum in Tagen'
: w.id === 'nutrition_detail_charts'
? 'Ernährungs-Charts Zeitraum in Tagen'
: 'Erholungs-Charts Zeitraum in Tagen'
}
value={
chartDaysDraftByWidgetId[w.id] !== undefined

View File

@ -13,6 +13,9 @@ import StatusPillsWidget from '../components/dashboard-widgets/StatusPillsWidget
import ProfileGoalsProgressWidget from '../components/dashboard-widgets/ProfileGoalsProgressWidget'
import TrendKcalWeightWidget from '../components/dashboard-widgets/TrendKcalWeightWidget'
import NutritionActivitySummaryWidget from '../components/dashboard-widgets/NutritionActivitySummaryWidget'
import NutritionDetailChartsWidget from '../components/dashboard-widgets/NutritionDetailChartsWidget'
import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget'
import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget'
import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget'
import GoalsFocusTeaserWidget from '../components/dashboard-widgets/GoalsFocusTeaserWidget'
import AiPipelineInsightWidget from '../components/dashboard-widgets/AiPipelineInsightWidget'
@ -101,6 +104,27 @@ export function ensurePilotLabWidgetsRegistered() {
Component: NutritionActivitySummaryWidget,
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
})
registerDashboardWidget({
id: 'nutrition_detail_charts',
Component: NutritionDetailChartsWidget,
mapProps: (ctx) => ({
refreshTick: ctx.refreshTick,
chartDays: ctx.layoutEntry?.config?.chart_days,
}),
})
registerDashboardWidget({
id: 'recovery_charts_panel',
Component: RecoveryChartsPanelWidget,
mapProps: (ctx) => ({
refreshTick: ctx.refreshTick,
chartDays: ctx.layoutEntry?.config?.chart_days,
}),
})
registerDashboardWidget({
id: 'progress_photos',
Component: ProgressPhotosWidget,
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
})
registerDashboardWidget({
id: 'recovery_sleep_rest',
Component: RecoverySleepRestWidget,