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()} + /> + +
+
+ +
+
+ Vitalwerte (Baseline) +
+ {vErr &&
{vErr}
} +
+ setVForm((f) => ({ ...f, resting_hr: e.target.value }))} + /> + setVForm((f) => ({ ...f, hrv: e.target.value }))} + /> + setVForm((f) => ({ ...f, vo2_max: e.target.value }))} + /> +
+ +
+
+
+ ) +} 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 ( +
+ + {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. -

- -
    - {layout.order.map((id, index) => { - const def = PILOT_WIDGET_DEFS[id] - if (!def) return null - const on = !!layout.enabled[id] - return ( -
  • -
    - - -
    -
    -
    {def.title}
    - {id} -
    - -
  • - ) - })} -
- -
- - - 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]) -}