feat: Refactor PilotVizPage and widget registry for improved layout and rendering
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s

- Replaced the previous widget retrieval method with a new layout-based approach in PilotVizPage.
- Introduced a PilotVizAdminCard for layout configuration and management.
- Updated widget definitions in the registry to include new components and default order.
- Enhanced user feedback for widget visibility with a message when no widgets are active.
This commit is contained in:
Lars 2026-04-07 10:21:11 +02:00
parent 932bceb1e1
commit 8c8f595385
6 changed files with 398 additions and 26 deletions

View File

@ -0,0 +1,76 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Target } from 'lucide-react'
import { api } from '../../utils/api'
export default function GoalsSnapshotWidget() {
const [count, setCount] = useState(null)
const [loading, setLoading] = useState(true)
const [err, setErr] = useState(null)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const list = await api.listGoals()
if (!cancelled) {
setCount(Array.isArray(list) ? list.length : 0)
setErr(null)
}
} catch (e) {
if (!cancelled) {
setErr(e.message || 'Laden fehlgeschlagen')
setCount(null)
}
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
if (loading) {
return (
<div style={{ padding: 16, textAlign: 'center' }}>
<div className="spinner" />
</div>
)
}
if (err) {
return <div style={{ fontSize: 13, color: 'var(--danger)' }}>{err}</div>
}
return (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14, flexWrap: 'wrap' }}>
<div
style={{
width: 44,
height: 44,
borderRadius: 12,
background: 'var(--accent-light)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Target size={22} color="var(--accent)" />
</div>
<div style={{ flex: '1 1 200px' }}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text1)' }}>
{count === 0
? 'Noch keine strategischen Ziele'
: `${count} ${count === 1 ? 'Ziel' : 'Ziele'} aktiv`}
</div>
<p style={{ fontSize: 13, color: 'var(--text2)', margin: '8px 0 0', lineHeight: 1.5 }}>
Focus Areas und Fortschritt wie auf dem Haupt-Dashboard, hier als Pilot-Widget.
</p>
<Link to="/goals" className="btn btn-secondary" style={{ marginTop: 12, display: 'inline-block', fontSize: 12, padding: '6px 12px', textDecoration: 'none' }}>
Zu den Zielen
</Link>
</div>
</div>
)
}

View File

@ -0,0 +1,128 @@
import { Settings2, RotateCcw, ChevronUp, ChevronDown } from 'lucide-react'
import { PILOT_WIDGET_DEFS, PILOT_WIDGET_IDS_DEFAULT_ORDER } from '../../pilot/widgetRegistry'
import { resetPilotLayout, savePilotLayout } from '../../pilot/pilotLayoutStorage'
/**
* Pilot: lokale Konfiguration (Ein/Aus, Reihenfolge). Kein Admin-Recht nötig.
*/
export default function PilotVizAdminCard({ layout, onLayoutChange }) {
const persist = (next) => {
savePilotLayout(next)
onLayoutChange(next)
}
const move = (index, dir) => {
const nextOrder = [...layout.order]
const j = index + dir
if (j < 0 || j >= nextOrder.length) return
;[nextOrder[index], nextOrder[j]] = [nextOrder[j], nextOrder[index]]
persist({ ...layout, order: nextOrder })
}
const toggle = (id) => {
persist({
...layout,
enabled: { ...layout.enabled, [id]: !layout.enabled[id] },
})
}
const handleReset = () => {
onLayoutChange(resetPilotLayout())
}
return (
<section
className="card section-gap"
style={{
borderStyle: 'solid',
borderColor: 'var(--accent)',
borderWidth: 1,
background: 'var(--surface)',
}}
>
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Settings2 size={18} color="var(--accent)" />
Widget-Konfiguration (Pilot)
</div>
<p style={{ fontSize: 12, color: 'var(--text2)', marginTop: 4, marginBottom: 14, lineHeight: 1.5 }}>
Sichtbarkeit und Reihenfolge steuern. Wird nur <strong>lokal in diesem Browser</strong> gespeichert
(<code style={{ fontSize: 11 }}>localStorage</code>) gut zum Ausprobieren vor einer serverseitigen
Profil-Konfiguration.
</p>
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{layout.order.map((id, index) => {
const def = PILOT_WIDGET_DEFS[id]
if (!def) return null
const on = !!layout.enabled[id]
return (
<li
key={id}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
padding: '10px 12px',
borderRadius: 10,
background: on ? 'var(--surface2)' : 'var(--surface)',
border: '1px solid var(--border)',
opacity: on ? 1 : 0.72,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '4px 8px', minWidth: 36 }}
title="Nach oben"
disabled={index === 0}
onClick={() => move(index, -1)}
>
<ChevronUp size={16} />
</button>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '4px 8px', minWidth: 36 }}
title="Nach unten"
disabled={index === layout.order.length - 1}
onClick={() => move(index, 1)}
>
<ChevronDown size={16} />
</button>
</div>
<div style={{ flex: 1, minWidth: 140 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text1)' }}>{def.title}</div>
<code style={{ fontSize: 10, color: 'var(--text3)' }}>{id}</code>
</div>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 13,
cursor: 'pointer',
userSelect: 'none',
}}
>
<input type="checkbox" checked={on} onChange={() => toggle(id)} />
sichtbar
</label>
</li>
)
})}
</ul>
<div style={{ marginTop: 14, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<button type="button" className="btn btn-secondary" onClick={handleReset} style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<RotateCcw size={14} />
Standard wiederherstellen
</button>
<span style={{ fontSize: 11, color: 'var(--text3)' }}>
Standard-Reihenfolge: {PILOT_WIDGET_IDS_DEFAULT_ORDER.join(' → ')}
</span>
</div>
</section>
)
}

View File

@ -0,0 +1,87 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import dayjs from 'dayjs'
import { api } from '../../utils/api'
export default function WeightKpiWidget() {
const [stats, setStats] = useState(null)
const [loading, setLoading] = useState(true)
const [err, setErr] = useState(null)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const s = await api.weightStats()
if (!cancelled) {
setStats(s)
setErr(null)
}
} catch (e) {
if (!cancelled) {
setErr(e.message || 'Laden fehlgeschlagen')
setStats(null)
}
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
if (loading) {
return (
<div style={{ padding: 16, textAlign: 'center' }}>
<div className="spinner" />
</div>
)
}
if (err) {
return <div style={{ fontSize: 13, color: 'var(--danger)' }}>{err}</div>
}
const latest = stats?.latest
const prev = stats?.prev
if (!latest) {
return (
<p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>
Noch kein Gewicht erfasst.{' '}
<Link to="/weight" style={{ color: 'var(--accent)' }}>
Zur Eingabe
</Link>
</p>
)
}
const delta =
prev && typeof latest.weight === 'number' && typeof prev.weight === 'number'
? Math.round((latest.weight - prev.weight) * 10) / 10
: null
const deltaColor =
delta == null ? 'var(--text3)' : delta < 0 ? 'var(--accent)' : delta > 0 ? 'var(--warn)' : 'var(--text3)'
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16, alignItems: 'flex-end' }}>
<div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 4 }}>Aktuelles Gewicht</div>
<div style={{ fontSize: 26, fontWeight: 800, color: '#378ADD', lineHeight: 1 }}>
{latest.weight}
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text2)', marginLeft: 4 }}>kg</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 6 }}>
Stand {dayjs(latest.date).format('DD.MM.YYYY')}
</div>
</div>
{delta != null && (
<div style={{ fontSize: 13, fontWeight: 600, color: deltaColor }}>
{delta > 0 ? '+' : ''}
{delta} kg <span style={{ fontWeight: 400, color: 'var(--text3)' }}>ggü. Vorher</span>
</div>
)}
<Link to="/history" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px', textDecoration: 'none' }}>
Verlauf
</Link>
</div>
)
}

View File

@ -1,13 +1,16 @@
import { useState } from 'react'
import { FlaskConical } from 'lucide-react'
import { Link } from 'react-router-dom'
import { getPilotWidgetsInOrder } from '../pilot/widgetRegistry'
import { resolvePilotWidgetsForRender } from '../pilot/widgetRegistry'
import { loadPilotLayout } from '../pilot/pilotLayoutStorage'
import PilotVizAdminCard from '../components/pilot/PilotVizAdminCard'
/**
* Pilot: Widget-Schicht (Layer 3b) parallel zum produktiven Dashboard.
* Daten für Referenzwerte: Layer 1 (backend/data_layer/reference_values.py) API.
*/
export default function PilotVizPage() {
const widgets = getPilotWidgetsInOrder()
const [layout, setLayout] = useState(() => loadPilotLayout())
const widgets = resolvePilotWidgetsForRender(layout)
return (
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 900, margin: '0 auto' }}>
@ -24,26 +27,34 @@ export default function PilotVizPage() {
Pilot: Visualisierungs-Module
</h1>
<p style={{ fontSize: 14, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
Testumgebung für die zukünftige Widget-Registry. Die produktive Übersicht und der Verlauf bleiben
unverändert. Referenzwerte-Summary wird über die gleiche API geladen wie in der Erfassungs-UI, angeboten
durch den Data Layer (Layer 1).
Testumgebung für die Widget-Registry. Konfiguration unten; produktive Übersicht und Verlauf sind unverändert.
</p>
</div>
{widgets.map((def) => {
const { Component } = def
return (
<section key={def.id} className="card section-gap" style={{ overflow: 'hidden' }}>
<div className="card-title">{def.title}</div>
{def.description && (
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 4, marginBottom: 14, lineHeight: 1.5 }}>
{def.description}
</p>
)}
<Component />
</section>
)
})}
<PilotVizAdminCard layout={layout} onLayoutChange={setLayout} />
{widgets.length === 0 ? (
<div className="card section-gap" style={{ textAlign: 'center', padding: 24 }}>
<p style={{ fontSize: 14, color: 'var(--text2)', margin: 0 }}>
Keine Widgets sichtbar. Aktiviere mindestens eines in der <strong>Widget-Konfiguration</strong> oben.
</p>
</div>
) : (
widgets.map((def) => {
const { Component } = def
return (
<section key={def.id} className="card section-gap" style={{ overflow: 'hidden' }}>
<div className="card-title">{def.title}</div>
{def.description && (
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 4, marginBottom: 14, lineHeight: 1.5 }}>
{def.description}
</p>
)}
<Component />
</section>
)
})
)}
</div>
)
}

View File

@ -0,0 +1,52 @@
/**
* Pilot-Layout: nur lokal (localStorage), kein Server.
* Dient zum Testen von Sichtbarkeit und Reihenfolge vor einer DB-Lösung.
*/
import { PILOT_WIDGET_DEFS, PILOT_WIDGET_IDS_DEFAULT_ORDER } from './widgetRegistry'
const STORAGE_KEY = 'mitai_pilot_viz_layout_v1'
function defaultLayout() {
const order = [...PILOT_WIDGET_IDS_DEFAULT_ORDER]
const enabled = Object.fromEntries(order.map((id) => [id, true]))
return { version: 1, order, enabled}
}
function mergeWithRegistry(saved) {
if (!saved || typeof saved !== 'object') return defaultLayout()
const known = new Set(Object.keys(PILOT_WIDGET_DEFS))
let order = Array.isArray(saved.order) ? saved.order.filter((id) => known.has(id)) : []
for (const id of PILOT_WIDGET_IDS_DEFAULT_ORDER) {
if (!order.includes(id)) order.push(id)
}
const enabled = { ...defaultLayout().enabled, ...(saved.enabled && typeof saved.enabled === 'object' ? saved.enabled : {}) }
for (const id of Object.keys(PILOT_WIDGET_DEFS)) {
if (typeof enabled[id] !== 'boolean') enabled[id] = true
}
return { version: 1, order, enabled }
}
export function loadPilotLayout() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return defaultLayout()
return mergeWithRegistry(JSON.parse(raw))
} catch {
return defaultLayout()
}
}
export function savePilotLayout(layout) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(layout))
} catch {
/* ignore quota */
}
}
export function resetPilotLayout() {
const d = defaultLayout()
savePilotLayout(d)
return d
}

View File

@ -1,20 +1,38 @@
import ReferenceValuesSummaryWidget from '../components/pilot/ReferenceValuesSummaryWidget'
import GoalsSnapshotWidget from '../components/pilot/GoalsSnapshotWidget'
import WeightKpiWidget from '../components/pilot/WeightKpiWidget'
/**
* Pilot: konfigurierbare Widget-Reihenfolge (Layer 3b-Vorspiel).
* Nur für /pilot/viz produktives Dashboard bleibt unverändert.
* Pilot: Widget-Registry (Layer 3b-Vorspiel).
* Reihenfolge-Default: PILOT_WIDGET_IDS_DEFAULT_ORDER
*/
export const PILOT_WIDGET_ORDER = ['reference_values_summary']
export const PILOT_WIDGET_IDS_DEFAULT_ORDER = ['weight_kpi', 'goals_teaser', 'reference_values_summary']
export const PILOT_WIDGET_DEFS = {
weight_kpi: {
id: 'weight_kpi',
title: 'Gewicht (KPI)',
description: 'Letzter Eintrag und Delta zum vorherigen Messwert (API weight/stats).',
Component: WeightKpiWidget,
},
goals_teaser: {
id: 'goals_teaser',
title: 'Strategische Ziele',
description: 'Anzahl aktiver Ziele, Link zur Ziele-Seite (API goals/list).',
Component: GoalsSnapshotWidget,
},
reference_values_summary: {
id: 'reference_values_summary',
title: 'Referenzwerte',
description: 'Aktuellste Kennwerte pro Typ (Daten via Layer 1 → bestehende API).',
description: 'Aktuellste Kennwerte pro Typ (Layer 1 → /profile-reference-values/summary).',
Component: ReferenceValuesSummaryWidget,
},
}
export function getPilotWidgetsInOrder() {
return PILOT_WIDGET_ORDER.map((id) => PILOT_WIDGET_DEFS[id]).filter(Boolean)
/** Sichtbare Widgets in gespeicherter Reihenfolge (für Render). */
export function resolvePilotWidgetsForRender(layout) {
if (!layout?.order) return []
return layout.order
.filter((id) => layout.enabled[id] && PILOT_WIDGET_DEFS[id])
.map((id) => PILOT_WIDGET_DEFS[id])
}