diff --git a/frontend/src/components/pilot/GoalsSnapshotWidget.jsx b/frontend/src/components/pilot/GoalsSnapshotWidget.jsx
deleted file mode 100644
index d2adb68..0000000
--- a/frontend/src/components/pilot/GoalsSnapshotWidget.jsx
+++ /dev/null
@@ -1,76 +0,0 @@
-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/PilotActivitySection.jsx b/frontend/src/components/pilot/PilotActivitySection.jsx
new file mode 100644
index 0000000..fe3199d
--- /dev/null
+++ b/frontend/src/components/pilot/PilotActivitySection.jsx
@@ -0,0 +1,128 @@
+import { useState, useEffect } from 'react'
+import { Link } from 'react-router-dom'
+import dayjs from 'dayjs'
+import { api } from '../../utils/api'
+import { useProfile } from '../../context/ProfileContext'
+import TrainingTypeDistribution from '../TrainingTypeDistribution'
+import PilotRuleCard from './PilotRuleCard'
+
+const PERIOD = 30
+
+export default function PilotActivitySection({ refreshTick = 0 }) {
+ const { activeProfile } = useProfile()
+ const globalQualityLevel = activeProfile?.quality_filter_level
+ const [activities, setActivities] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ let cancelled = false
+ ;(async () => {
+ try {
+ const a = await api.listActivity(120)
+ if (!cancelled) setActivities(Array.isArray(a) ? a : [])
+ } catch {
+ if (!cancelled) setActivities([])
+ } finally {
+ if (!cancelled) setLoading(false)
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [refreshTick, globalQualityLevel])
+
+ const cutoff = dayjs().subtract(PERIOD, '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)
+ : 0
+ const consistency = totalDays > 0 ? Math.round((daysWithAct / totalDays) * 100) : 0
+
+ const actRules = [
+ {
+ status: consistency >= 70 ? 'good' : consistency >= 40 ? 'warn' : 'bad',
+ icon: '📅',
+ category: 'Konsistenz',
+ title: `${consistency}% aktive Tage (${daysWithAct}/${Math.min(PERIOD, totalDays || PERIOD)} Tage)`,
+ detail:
+ consistency >= 70
+ ? 'Ausgezeichnete Regelmäßigkeit.'
+ : consistency >= 40
+ ? 'Ziel: 4–5 Einheiten/Woche.'
+ : 'Mehr Regelmäßigkeit empfohlen.',
+ value: `${consistency}%`,
+ },
+ ]
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
Bereich Aktivität
+
+ Trainingstyp-Verteilung {PERIOD} Tage · Bewertung Konsistenz wie im Verlauf
+
+
+
+ {globalQualityLevel && globalQualityLevel !== 'all' && (
+
+ Aktiver Qualitätsfilter im Profil – Aktivitätsdaten entsprechend gefiltert.
+
+ Einstellungen
+
+
+ )}
+
+
+
Trainingstyp-Verteilung
+
+
+
+ Vollständiger Verlauf Aktivität →
+
+
+
+
+
+
Bewertung · Aktivität
+ {filtA.length === 0 ? (
+
+ Noch keine Aktivitäten.{' '}
+
+ Training erfassen
+
+
+ ) : (
+ actRules.map((item, i) =>
)
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/pilot/PilotBodySection.jsx b/frontend/src/components/pilot/PilotBodySection.jsx
new file mode 100644
index 0000000..22313af
--- /dev/null
+++ b/frontend/src/components/pilot/PilotBodySection.jsx
@@ -0,0 +1,228 @@
+import { useState, useEffect } from 'react'
+import { Link } from 'react-router-dom'
+import {
+ LineChart,
+ Line,
+ XAxis,
+ YAxis,
+ Tooltip,
+ ResponsiveContainer,
+ CartesianGrid,
+ ReferenceLine,
+} from 'recharts'
+import { ChevronRight } from 'lucide-react'
+import dayjs from 'dayjs'
+import { api } from '../../utils/api'
+import { useProfile } from '../../context/ProfileContext'
+import { getInterpretation } from '../../utils/interpret'
+import { rollingAvg, fmtDate } from '../../pilot/pilotChartUtils'
+import PilotRuleCard from './PilotRuleCard'
+
+const WINDOW_DAYS = 30
+
+export default function PilotBodySection({ refreshTick = 0 }) {
+ const { activeProfile } = useProfile()
+ const [weights, setWeights] = useState([])
+ const [calipers, setCalipers] = useState([])
+ const [circs, setCircs] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ let cancelled = false
+ ;(async () => {
+ try {
+ const [w, ca, ci] = await Promise.all([
+ api.listWeight(120),
+ api.listCaliper(30),
+ api.listCirc(30),
+ ])
+ if (!cancelled) {
+ setWeights(Array.isArray(w) ? w : [])
+ setCalipers(Array.isArray(ca) ? ca : [])
+ setCircs(Array.isArray(ci) ? ci : [])
+ }
+ } catch {
+ if (!cancelled) {
+ setWeights([])
+ setCalipers([])
+ setCircs([])
+ }
+ } finally {
+ if (!cancelled) setLoading(false)
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [refreshTick])
+
+ const sex = activeProfile?.sex || 'm'
+ const height = activeProfile?.height || 178
+ const cutoff = dayjs().subtract(WINDOW_DAYS, 'day').format('YYYY-MM-DD')
+
+ const filtW = [...(weights || [])]
+ .sort((a, b) => a.date.localeCompare(b.date))
+ .filter((d) => d.date >= cutoff)
+ const filtCal = (calipers || []).filter((d) => d.date >= cutoff)
+ const filtCir = (circs || []).filter((d) => d.date >= cutoff)
+
+ const hasWeight = filtW.length >= 2
+ const latestCal = filtCal[0]
+ const prevCal = filtCal[1]
+ const latestCir = filtCir[0]
+ const latestW2 = filtW[filtW.length - 1]
+
+ const withAvg = rollingAvg(filtW, 'weight', 7)
+ const withAvg14 = rollingAvg(filtW, 'weight', 14)
+ const wCd = withAvg.map((d, i) => ({
+ date: fmtDate(d.date),
+ weight: d.weight,
+ avg7: d.weight_avg,
+ avg14: withAvg14[i]?.weight_avg,
+ }))
+ const ws = filtW.map((w) => w.weight)
+ const avgAll = ws.length ? Math.round((ws.reduce((a, b) => a + b, 0) / ws.length) * 10) / 10 : null
+
+ const combined = {
+ ...(latestCal || {}),
+ c_waist: latestCir?.c_waist,
+ c_hip: latestCir?.c_hip,
+ weight: latestW2?.weight,
+ }
+ const rules = getInterpretation(combined, activeProfile || {}, prevCal || null)
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
Bereich Körper
+
+ Fokus letzte {WINDOW_DAYS} Tage · Gewicht mit Ø 7 / Ø 14 Tage wie im Verlauf
+
+
+
+ {!hasWeight && (
+
+ Zu wenig Gewichtsdaten für den Graph.{' '}
+
+ Gewicht erfassen
+
+
+ )}
+
+ {hasWeight && (
+
+
+
+ Gewicht · {filtW.length} Messungen ({WINDOW_DAYS}T)
+
+
+ Verlauf Körper
+
+
+
+
+
+
+
+ {avgAll && (
+
+ )}
+ {activeProfile?.goal_weight && (
+
+ )}
+ [
+ `${v} kg`,
+ n === 'weight' ? 'Täglich' : n === 'avg7' ? 'Ø 7 Tage' : 'Ø 14 Tage',
+ ]}
+ />
+
+
+
+
+
+
+
+
+ Täglich
+
+
+
+ Ø 7T
+
+
+
+
+
+ Ø 14T
+
+
+
+ Ø Zeitraum
+
+
+
+ )}
+
+ {rules.length > 0 && (
+
+
Bewertung · Körper
+
+ Körperfett, Magermasse (FFMI), BMI – gleiche Logik wie auf der Verlauf-Seite (Körper).
+
+ {rules.map((item, i) => (
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/pilot/PilotKpiBoard.jsx b/frontend/src/components/pilot/PilotKpiBoard.jsx
new file mode 100644
index 0000000..0173ed7
--- /dev/null
+++ b/frontend/src/components/pilot/PilotKpiBoard.jsx
@@ -0,0 +1,166 @@
+import { useState, useEffect } from 'react'
+import { Link } from 'react-router-dom'
+import dayjs from 'dayjs'
+import { api } from '../../utils/api'
+import { getBfCategory } from '../../utils/calc'
+import { useProfile } from '../../context/ProfileContext'
+
+const MAX_KPI = 9
+
+function formatRefVal(row) {
+ if (row.value_numeric != null && row.value_numeric !== '') {
+ const n = Number(row.value_numeric)
+ return Number.isFinite(n) ? String(n) : String(row.value_numeric)
+ }
+ return row.value_text != null ? String(row.value_text) : '–'
+}
+
+/**
+ * KPIs: Referenzwerte (Layer-1-Summary) + Körperfett % + Ø Kalorien 7T — max. 9 Kacheln.
+ */
+export default function PilotKpiBoard({ refreshTick = 0 }) {
+ const { activeProfile } = useProfile()
+ const sex = activeProfile?.sex || 'm'
+ const [refs, setRefs] = useState([])
+ const [bf, setBf] = useState(null)
+ const [avgKcal, setAvgKcal] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [err, setErr] = useState(null)
+
+ useEffect(() => {
+ let cancelled = false
+ ;(async () => {
+ try {
+ setLoading(true)
+ const [summary, calipers, nutrition] = await Promise.all([
+ api.listProfileReferenceValuesSummary().catch(() => ({ tiles: [] })),
+ api.listCaliper(3).catch(() => []),
+ api.listNutrition(30).catch(() => []),
+ ])
+ if (cancelled) return
+ const tiles = Array.isArray(summary?.tiles) ? summary.tiles.filter((t) => t?.latest) : []
+ const latestCal = Array.isArray(calipers) && calipers[0]?.body_fat_pct != null ? calipers[0] : null
+ const recentNutr = (nutrition || []).filter((n) => n.date >= dayjs().subtract(7, 'day').format('YYYY-MM-DD'))
+ const kcal =
+ recentNutr.length > 0
+ ? Math.round(recentNutr.reduce((s, n) => s + (n.kcal || 0), 0) / recentNutr.length)
+ : null
+
+ const wantBf = !!latestCal?.body_fat_pct
+ const wantKcal = kcal != null && kcal > 0
+ const extra = (wantBf ? 1 : 0) + (wantKcal ? 1 : 0)
+ const refCap = Math.max(0, MAX_KPI - extra)
+ setRefs(tiles.slice(0, refCap))
+ setBf(
+ wantBf
+ ? {
+ pct: latestCal.body_fat_pct,
+ cat: getBfCategory(latestCal.body_fat_pct, sex),
+ date: latestCal.date,
+ }
+ : null,
+ )
+ setAvgKcal(wantKcal ? kcal : null)
+ setErr(null)
+ } catch (e) {
+ if (!cancelled) {
+ setErr(e.message || 'KPIs konnten nicht geladen werden')
+ setRefs([])
+ }
+ } finally {
+ if (!cancelled) setLoading(false)
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [refreshTick, sex])
+
+ if (loading) {
+ return (
+
+ )
+ }
+ if (err) {
+ return (
+
+ {err}
+
+ )
+ }
+
+ const tiles = []
+
+ refs.forEach((tile) => {
+ const l = tile.latest
+ tiles.push(
+
+
{tile.type_label}
+
+ {formatRefVal(l)}
+ {l.unit ? (
+ {l.unit}
+ ) : null}
+
+
Ref.wert
+
,
+ )
+ })
+
+ if (bf) {
+ tiles.push(
+
+
Körperfett
+
+ {bf.pct}%
+
+
{bf.cat?.label || 'Caliper'}
+
,
+ )
+ }
+
+ if (avgKcal != null) {
+ tiles.push(
+
+
Ø Kalorien (7T)
+
{avgKcal} kcal
+
Ernährung
+
,
+ )
+ }
+
+ if (tiles.length === 0) {
+ return (
+
+
Kennzahlen
+
+ Noch keine Daten.{' '}
+
+ Referenzwerte
+
+ ,{' '}
+
+ Caliper
+
+ ,{' '}
+
+ Ernährung
+
+ .
+
+
+ )
+ }
+
+ return (
+
+
Kennzahlen
+
+ Referenzwerte (bis {MAX_KPI} gesamt inkl. KF% und Ø-Kalorien). Fehlende Typen werden automatisch weggelassen.
+
+
{tiles}
+
+ )
+}
diff --git a/frontend/src/components/pilot/PilotQuickCapture.jsx b/frontend/src/components/pilot/PilotQuickCapture.jsx
new file mode 100644
index 0000000..17cd288
--- /dev/null
+++ b/frontend/src/components/pilot/PilotQuickCapture.jsx
@@ -0,0 +1,198 @@
+import { useState, useEffect } from 'react'
+import { Link } from 'react-router-dom'
+import { Check } from 'lucide-react'
+import dayjs from 'dayjs'
+import { api } from '../../utils/api'
+
+/**
+ * Schnelleingabe: Gewicht + Baseline Vitals (Ruhepuls, HRV, VO₂max) für heute.
+ */
+export default function PilotQuickCapture({ onSaved }) {
+ const today = dayjs().format('YYYY-MM-DD')
+ const [weightInput, setWeightInput] = useState('')
+ const [weightSaving, setWeightSaving] = useState(false)
+ const [weightSaved, setWeightSaved] = useState(false)
+ const [weightErr, setWeightErr] = useState(null)
+
+ const [vForm, setVForm] = useState({
+ id: null,
+ resting_hr: '',
+ hrv: '',
+ vo2_max: '',
+ })
+ const [vSaving, setVSaving] = useState(false)
+ const [vErr, setVErr] = useState(null)
+ const [vOk, setVOk] = useState(false)
+
+ useEffect(() => {
+ api.weightStats().then((s) => {
+ if (s?.latest?.date === today) setWeightInput(String(s.latest.weight))
+ }).catch(() => {})
+ }, [today])
+
+ useEffect(() => {
+ let cancelled = false
+ ;(async () => {
+ try {
+ const existing = await api.getBaselineByDate(today)
+ if (cancelled || !existing?.id) return
+ setVForm({
+ id: existing.id,
+ resting_hr: existing.resting_hr != null ? String(existing.resting_hr) : '',
+ hrv: existing.hrv != null ? String(existing.hrv) : '',
+ vo2_max: existing.vo2_max != null ? String(existing.vo2_max) : '',
+ })
+ } catch (err) {
+ const msg = String(err?.message || '')
+ if (msg.includes('404') || msg.toLowerCase().includes('nicht gefunden')) {
+ setVForm((f) => ({ ...f, id: null, resting_hr: '', hrv: '', vo2_max: '' }))
+ }
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [today])
+
+ const saveWeight = async () => {
+ const w = parseFloat(weightInput)
+ if (!w || w < 20 || w > 300) return
+ setWeightSaving(true)
+ setWeightErr(null)
+ try {
+ await api.upsertWeight(today, w)
+ setWeightSaved(true)
+ onSaved?.()
+ setTimeout(() => setWeightSaved(false), 2000)
+ } catch (e) {
+ setWeightErr(e.message || 'Fehler')
+ } finally {
+ setWeightSaving(false)
+ }
+ }
+
+ const saveVitals = async () => {
+ setVSaving(true)
+ setVErr(null)
+ setVOk(false)
+ try {
+ const payload = { date: today }
+ if (vForm.resting_hr) payload.resting_hr = parseInt(vForm.resting_hr, 10)
+ if (vForm.hrv) payload.hrv = parseInt(vForm.hrv, 10)
+ if (vForm.vo2_max) payload.vo2_max = parseFloat(vForm.vo2_max)
+
+ if (!payload.resting_hr && !payload.hrv && !payload.vo2_max) {
+ setVErr('Mindestens Ruhepuls, HRV oder VO₂max angeben.')
+ setVSaving(false)
+ return
+ }
+
+ if (vForm.id) {
+ await api.updateBaseline(vForm.id, payload)
+ } else {
+ const created = await api.createBaseline(payload)
+ if (created?.id) setVForm((f) => ({ ...f, id: created.id }))
+ }
+ setVOk(true)
+ onSaved?.()
+ setTimeout(() => setVOk(false), 2000)
+ } catch (e) {
+ setVErr(e.message || 'Speichern fehlgeschlagen')
+ } finally {
+ setVSaving(false)
+ }
+ }
+
+ const cellStyle = {
+ flex: '1 1 140px',
+ minWidth: 0,
+ padding: 12,
+ borderRadius: 10,
+ border: '1px solid var(--border)',
+ background: 'var(--surface2)',
+ }
+
+ return (
+
+
Schnelleingabe (heute)
+
+ Gewicht separat; Vitalwerte typischerweise gemeinsam.{' '}
+
+ Volle Vitalwerte-Seite →
+
+
+
+
+
+
Gewicht
+ {weightErr && (
+
{weightErr}
+ )}
+
+
setWeightInput(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && saveWeight()}
+ />
+
+ {weightSaved ? : weightSaving ?
: 'OK'}
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/pilot/PilotRuleCard.jsx b/frontend/src/components/pilot/PilotRuleCard.jsx
new file mode 100644
index 0000000..55bcc6f
--- /dev/null
+++ b/frontend/src/components/pilot/PilotRuleCard.jsx
@@ -0,0 +1,59 @@
+import { useState } from 'react'
+import { ChevronDown, ChevronUp } from 'lucide-react'
+import { getStatusColor, getStatusBg } from '../../utils/interpret'
+
+export default function PilotRuleCard({ item }) {
+ const [open, setOpen] = useState(false)
+ const color = getStatusColor(item.status)
+ return (
+
+
setOpen((o) => !o)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 8,
+ padding: '8px 12px',
+ width: '100%',
+ textAlign: 'left',
+ background: `${getStatusBg(item.status)}88`,
+ border: 'none',
+ cursor: 'pointer',
+ fontFamily: 'var(--font)',
+ }}
+ >
+ {item.icon}
+
+
+ {item.category}
+
+
{item.title}
+
+ {item.value && {item.value} }
+ {open ? : }
+
+ {open && (
+
+ {item.detail}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/pilot/PilotVizAdminCard.jsx b/frontend/src/components/pilot/PilotVizAdminCard.jsx
deleted file mode 100644
index 77aafdc..0000000
--- a/frontend/src/components/pilot/PilotVizAdminCard.jsx
+++ /dev/null
@@ -1,128 +0,0 @@
-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/PilotWelcome.jsx b/frontend/src/components/pilot/PilotWelcome.jsx
new file mode 100644
index 0000000..fa991b7
--- /dev/null
+++ b/frontend/src/components/pilot/PilotWelcome.jsx
@@ -0,0 +1,19 @@
+import dayjs from 'dayjs'
+import 'dayjs/locale/de'
+import { useProfile } from '../../context/ProfileContext'
+
+dayjs.locale('de')
+
+export default function PilotWelcome() {
+ const { activeProfile } = useProfile()
+ return (
+
+
+ Hallo, {activeProfile?.name || 'Nutzer'} 👋
+
+
+ {dayjs().format('dddd, DD. MMMM YYYY')} · Pilot-Übersicht
+
+
+ )
+}
diff --git a/frontend/src/components/pilot/ReferenceValuesSummaryWidget.jsx b/frontend/src/components/pilot/ReferenceValuesSummaryWidget.jsx
deleted file mode 100644
index df30d61..0000000
--- a/frontend/src/components/pilot/ReferenceValuesSummaryWidget.jsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import { useState, useEffect } from 'react'
-import { Link } from 'react-router-dom'
-import { api } from '../../utils/api'
-
-function formatEntryValue(row) {
- if (row.value_numeric != null && row.value_numeric !== '') {
- const n = Number(row.value_numeric)
- return Number.isFinite(n) ? String(n) : String(row.value_numeric)
- }
- return row.value_text != null ? String(row.value_text) : '–'
-}
-
-export default function ReferenceValuesSummaryWidget() {
- const [tiles, setTiles] = useState([])
- const [loading, setLoading] = useState(true)
- const [err, setErr] = useState(null)
-
- useEffect(() => {
- let cancelled = false
- ;(async () => {
- try {
- const data = await api.listProfileReferenceValuesSummary()
- if (!cancelled) {
- setTiles(Array.isArray(data?.tiles) ? data.tiles : [])
- setErr(null)
- }
- } catch (e) {
- if (!cancelled) {
- setErr(e.message || 'Laden fehlgeschlagen')
- setTiles([])
- }
- } finally {
- if (!cancelled) setLoading(false)
- }
- })()
- return () => {
- cancelled = true
- }
- }, [])
-
- if (loading) {
- return (
-
- )
- }
- if (err) {
- return {err}
- }
- if (tiles.length === 0) {
- return (
-
- Noch keine Referenzwerte erfasst.{' '}
-
- Zur Erfassung
-
-
- )
- }
-
- return (
-
- {tiles
- .filter((t) => t?.latest)
- .map((tile) => (
-
-
{tile.type_label}
-
- {formatEntryValue(tile.latest)}
- {tile.latest.unit ? (
-
- {tile.latest.unit}
-
- ) : null}
-
-
- Stand {String(tile.latest.effective_date || '').slice(0, 10)}
-
-
- ))}
-
- )
-}
diff --git a/frontend/src/components/pilot/WeightKpiWidget.jsx b/frontend/src/components/pilot/WeightKpiWidget.jsx
deleted file mode 100644
index 3fd0269..0000000
--- a/frontend/src/components/pilot/WeightKpiWidget.jsx
+++ /dev/null
@@ -1,87 +0,0 @@
-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 e6c1d3b..a657ead 100644
--- a/frontend/src/pages/PilotVizPage.jsx
+++ b/frontend/src/pages/PilotVizPage.jsx
@@ -1,19 +1,24 @@
import { useState } from 'react'
import { FlaskConical } from 'lucide-react'
import { Link } from 'react-router-dom'
-import { resolvePilotWidgetsForRender } from '../pilot/widgetRegistry'
-import { loadPilotLayout } from '../pilot/pilotLayoutStorage'
-import PilotVizAdminCard from '../components/pilot/PilotVizAdminCard'
+import PilotWelcome from '../components/pilot/PilotWelcome'
+import PilotQuickCapture from '../components/pilot/PilotQuickCapture'
+import PilotKpiBoard from '../components/pilot/PilotKpiBoard'
+import PilotBodySection from '../components/pilot/PilotBodySection'
+import PilotActivitySection from '../components/pilot/PilotActivitySection'
/**
- * Pilot: Widget-Schicht (Layer 3b) parallel zum produktiven Dashboard.
+ * Pilot-Übersicht nach Product-Spec:
+ * Willkommen → Schnelleingabe (Gewicht + Vitalwerte) → KPIs (Referenzen + KF% + Ø kcal, max. 9)
+ * → Bereich Körper (Gewicht-Chart 30 T, Ø7/Ø14, Bewertung wie Verlauf)
+ * → Bereich Aktivität (Trainingstyp 30 T, Konsistenz).
*/
export default function PilotVizPage() {
- const [layout, setLayout] = useState(() => loadPilotLayout())
- const widgets = resolvePilotWidgetsForRender(layout)
+ const [refreshTick, setRefreshTick] = useState(0)
+ const bump = () => setRefreshTick((t) => t + 1)
return (
-
+
- Pilot: Visualisierungs-Module
+ Pilot: Übersicht
-
- Testumgebung für die Widget-Registry. Konfiguration unten; produktive Übersicht und Verlauf sind unverändert.
+
+ Konfigurierbare Ziel-Übersicht (Test). Produktives Dashboard und Verlauf unverändert. Nach Speichern von
+ Gewicht oder Vitalwerten werden KPIs und Körperbereich neu geladen.
-
-
- {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/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx
index 59ea368..887db25 100644
--- a/frontend/src/pages/SettingsPage.jsx
+++ b/frontend/src/pages/SettingsPage.jsx
@@ -449,7 +449,8 @@ export default function SettingsPage() {
Pilot: Visualisierungs-Module
- Testumgebung für die Widget-Schicht (Layer 3b). Die Übersicht und der Verlauf bleiben unverändert.
+ Ziel-Übersicht-Pilot: Schnelleingabe, KPIs (Referenzen + KF% + Ø-Kalorien), Körper-Chart,
+ Bewertungen, Aktivität. Produktives Dashboard bleibt unverändert.
{
+ const s = arr
+ .slice(Math.max(0, i - window + 1), i + 1)
+ .map((x) => x[key])
+ .filter((v) => v != null)
+ return s.length
+ ? { ...d, [`${key}_avg`]: Math.round((s.reduce((a, b) => a + b, 0) / s.length) * 10) / 10 }
+ : d
+ })
+}
+
+export const fmtDate = (d) => dayjs(d).format('DD.MM')
diff --git a/frontend/src/pilot/pilotLayoutStorage.js b/frontend/src/pilot/pilotLayoutStorage.js
deleted file mode 100644
index dc25848..0000000
--- a/frontend/src/pilot/pilotLayoutStorage.js
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * 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
deleted file mode 100644
index 7c74ddb..0000000
--- a/frontend/src/pilot/widgetRegistry.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import ReferenceValuesSummaryWidget from '../components/pilot/ReferenceValuesSummaryWidget'
-import GoalsSnapshotWidget from '../components/pilot/GoalsSnapshotWidget'
-import WeightKpiWidget from '../components/pilot/WeightKpiWidget'
-
-/**
- * Pilot: Widget-Registry (Layer 3b-Vorspiel).
- * Reihenfolge-Default: PILOT_WIDGET_IDS_DEFAULT_ORDER
- */
-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 (Layer 1 → /profile-reference-values/summary).',
- Component: ReferenceValuesSummaryWidget,
- },
-}
-
-/** 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])
-}