diff --git a/frontend/src/components/pilot/GoalsSnapshotWidget.jsx b/frontend/src/components/pilot/GoalsSnapshotWidget.jsx
new file mode 100644
index 0000000..d2adb68
--- /dev/null
+++ b/frontend/src/components/pilot/GoalsSnapshotWidget.jsx
@@ -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 (
+
+ )
+ }
+ if (err) {
+ return {err}
+ }
+
+ return (
+
+
+
+
+
+
+ {count === 0
+ ? 'Noch keine strategischen Ziele'
+ : `${count} ${count === 1 ? 'Ziel' : 'Ziele'} aktiv`}
+
+
+ Focus Areas und Fortschritt – wie auf dem Haupt-Dashboard, hier als Pilot-Widget.
+
+
+ Zu den Zielen →
+
+
+
+ )
+}
diff --git a/frontend/src/components/pilot/PilotVizAdminCard.jsx b/frontend/src/components/pilot/PilotVizAdminCard.jsx
new file mode 100644
index 0000000..77aafdc
--- /dev/null
+++ b/frontend/src/components/pilot/PilotVizAdminCard.jsx
@@ -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 (
+
+
+
+ Widget-Konfiguration (Pilot)
+
+
+ Sichtbarkeit und Reihenfolge steuern. Wird nur lokal in diesem Browser gespeichert
+ (localStorage) – gut zum Ausprobieren vor einer serverseitigen
+ Profil-Konfiguration.
+
+
+
+
+
+
+
+ Standard wiederherstellen
+
+
+ Standard-Reihenfolge: {PILOT_WIDGET_IDS_DEFAULT_ORDER.join(' → ')}
+
+
+
+ )
+}
diff --git a/frontend/src/components/pilot/WeightKpiWidget.jsx b/frontend/src/components/pilot/WeightKpiWidget.jsx
new file mode 100644
index 0000000..3fd0269
--- /dev/null
+++ b/frontend/src/components/pilot/WeightKpiWidget.jsx
@@ -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 (
+
+ )
+ }
+ if (err) {
+ return {err}
+ }
+ const latest = stats?.latest
+ const prev = stats?.prev
+ if (!latest) {
+ return (
+
+ Noch kein Gewicht erfasst.{' '}
+
+ Zur Eingabe
+
+
+ )
+ }
+
+ 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 (
+
+
+
Aktuelles Gewicht
+
+ {latest.weight}
+ kg
+
+
+ Stand {dayjs(latest.date).format('DD.MM.YYYY')}
+
+
+ {delta != null && (
+
+ {delta > 0 ? '+' : ''}
+ {delta} kg ggü. Vorher
+
+ )}
+
+ Verlauf →
+
+
+ )
+}
diff --git a/frontend/src/pages/PilotVizPage.jsx b/frontend/src/pages/PilotVizPage.jsx
index a539fab..e6c1d3b 100644
--- a/frontend/src/pages/PilotVizPage.jsx
+++ b/frontend/src/pages/PilotVizPage.jsx
@@ -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 (
@@ -24,26 +27,34 @@ export default function PilotVizPage() {
Pilot: Visualisierungs-Module
- 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.
- {widgets.map((def) => {
- const { Component } = def
- return (
-
- {def.title}
- {def.description && (
-
- {def.description}
-
- )}
-
-
- )
- })}
+
+
+ {widgets.length === 0 ? (
+
+
+ Keine Widgets sichtbar. Aktiviere mindestens eines in der Widget-Konfiguration oben.
+
+
+ ) : (
+ widgets.map((def) => {
+ const { Component } = def
+ return (
+
+ {def.title}
+ {def.description && (
+
+ {def.description}
+
+ )}
+
+
+ )
+ })
+ )}
)
}
diff --git a/frontend/src/pilot/pilotLayoutStorage.js b/frontend/src/pilot/pilotLayoutStorage.js
new file mode 100644
index 0000000..dc25848
--- /dev/null
+++ b/frontend/src/pilot/pilotLayoutStorage.js
@@ -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
+}
diff --git a/frontend/src/pilot/widgetRegistry.js b/frontend/src/pilot/widgetRegistry.js
index 3840a56..7c74ddb 100644
--- a/frontend/src/pilot/widgetRegistry.js
+++ b/frontend/src/pilot/widgetRegistry.js
@@ -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])
}