feat: Remove deprecated pilot widgets and layout management
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 23s

- Deleted unused components: GoalsSnapshotWidget, ReferenceValuesSummaryWidget, WeightKpiWidget, and PilotVizAdminCard.
- Removed associated layout storage and widget registry logic to streamline the pilot visualization module.
- Updated PilotVizPage to integrate new components for improved user experience and functionality.
This commit is contained in:
Lars 2026-04-07 11:07:33 +02:00
parent 8c8f595385
commit c0cb995a7b
15 changed files with 837 additions and 504 deletions

View File

@ -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 (
<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 { 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: 45 Einheiten/Woche.'
: 'Mehr Regelmäßigkeit empfohlen.',
value: `${consistency}%`,
},
]
if (loading) {
return (
<div className="card section-gap" style={{ textAlign: 'center', padding: 24 }}>
<div className="spinner" />
</div>
)
}
return (
<div className="section-gap" style={{ marginBottom: 24 }}>
<div
style={{
gridColumn: '1 / -1',
marginBottom: 12,
paddingBottom: 8,
borderBottom: '2px solid var(--border)',
}}
>
<h2 style={{ fontSize: 17, fontWeight: 700, margin: 0, color: 'var(--text1)' }}>Bereich Aktivität</h2>
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '6px 0 0', lineHeight: 1.5 }}>
Trainingstyp-Verteilung {PERIOD} Tage · Bewertung Konsistenz wie im Verlauf
</p>
</div>
{globalQualityLevel && globalQualityLevel !== 'all' && (
<div
style={{
marginBottom: 12,
padding: '8px 12px',
borderRadius: 8,
background: 'var(--surface2)',
border: '1px solid var(--border)',
fontSize: 12,
color: 'var(--text2)',
}}
>
Aktiver Qualitätsfilter im Profil Aktivitätsdaten entsprechend gefiltert.
<Link to="/settings" style={{ marginLeft: 8, color: 'var(--accent)' }}>
Einstellungen
</Link>
</div>
)}
<div className="card section-gap">
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Trainingstyp-Verteilung</div>
<TrainingTypeDistribution days={PERIOD} />
<div style={{ marginTop: 8, textAlign: 'right' }}>
<Link to="/history" state={{ tab: 'activity' }} style={{ fontSize: 12, color: 'var(--accent)' }}>
Vollständiger Verlauf Aktivität
</Link>
</div>
</div>
<div className="card section-gap">
<div className="card-title">Bewertung · Aktivität</div>
{filtA.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>
Noch keine Aktivitäten.{' '}
<Link to="/activity" style={{ color: 'var(--accent)' }}>
Training erfassen
</Link>
</p>
) : (
actRules.map((item, i) => <PilotRuleCard key={i} item={item} />)
)}
</div>
</div>
)
}

View File

@ -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 (
<div className="card section-gap" style={{ textAlign: 'center', padding: 24 }}>
<div className="spinner" />
</div>
)
}
return (
<div className="section-gap" style={{ marginBottom: 24 }}>
<div
style={{
gridColumn: '1 / -1',
marginBottom: 12,
paddingBottom: 8,
borderBottom: '2px solid var(--border)',
}}
>
<h2 style={{ fontSize: 17, fontWeight: 700, margin: 0, color: 'var(--text1)' }}>Bereich Körper</h2>
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '6px 0 0', lineHeight: 1.5 }}>
Fokus letzte {WINDOW_DAYS} Tage · Gewicht mit Ø 7 / Ø 14 Tage wie im Verlauf
</p>
</div>
{!hasWeight && (
<div className="card" style={{ padding: 20, fontSize: 13, color: 'var(--text2)' }}>
Zu wenig Gewichtsdaten für den Graph.{' '}
<Link to="/weight" style={{ color: 'var(--accent)' }}>
Gewicht erfassen
</Link>
</div>
)}
{hasWeight && (
<div className="card section-gap">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>
Gewicht · {filtW.length} Messungen ({WINDOW_DAYS}T)
</div>
<Link to="/history" state={{ tab: 'body' }} className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px', textDecoration: 'none' }}>
Verlauf Körper <ChevronRight size={10} />
</Link>
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={wCd} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis
dataKey="date"
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
interval={Math.max(0, Math.floor(wCd.length / 6) - 1)}
/>
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
{avgAll && (
<ReferenceLine
y={avgAll}
stroke="var(--text3)"
strokeDasharray="4 4"
strokeWidth={1}
label={{ value: `Ø ${avgAll}`, fontSize: 9, fill: 'var(--text3)', position: 'right' }}
/>
)}
{activeProfile?.goal_weight && (
<ReferenceLine
y={activeProfile.goal_weight}
stroke="var(--accent)"
strokeDasharray="5 3"
strokeWidth={1.5}
label={{
value: `Ziel ${activeProfile.goal_weight}kg`,
fontSize: 9,
fill: 'var(--accent)',
position: 'right',
}}
/>
)}
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, n) => [
`${v} kg`,
n === 'weight' ? 'Täglich' : n === 'avg7' ? 'Ø 7 Tage' : 'Ø 14 Tage',
]}
/>
<Line type="monotone" dataKey="weight" stroke="#378ADD88" strokeWidth={1.5} dot={{ r: 3, fill: '#378ADD' }} name="weight" />
<Line type="monotone" dataKey="avg7" stroke="#378ADD" strokeWidth={2.5} dot={false} name="avg7" />
<Line type="monotone" dataKey="avg14" stroke="#1D9E75" strokeWidth={2} dot={false} strokeDasharray="6 3" name="avg14" />
</LineChart>
</ResponsiveContainer>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
<span>
<span style={{ display: 'inline-block', width: 12, height: 2, background: '#378ADD88', verticalAlign: 'middle', marginRight: 3 }} />
Täglich
</span>
<span>
<span style={{ display: 'inline-block', width: 12, height: 2, background: '#378ADD', verticalAlign: 'middle', marginRight: 3 }} />
Ø 7T
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
<svg width="14" height="4">
<line x1="0" y1="2" x2="14" y2="2" stroke="#1D9E75" strokeWidth="2" strokeDasharray="5 3" />
</svg>
Ø 14T
</span>
<span>
<span
style={{
display: 'inline-block',
width: 12,
height: 2,
background: 'var(--text3)',
verticalAlign: 'middle',
marginRight: 3,
borderTop: '2px dashed',
}}
/>
Ø Zeitraum
</span>
</div>
</div>
)}
{rules.length > 0 && (
<div className="card section-gap">
<div className="card-title">Bewertung · Körper</div>
<p style={{ fontSize: 12, color: 'var(--text2)', marginTop: 4, marginBottom: 10, lineHeight: 1.5 }}>
Körperfett, Magermasse (FFMI), BMI gleiche Logik wie auf der Verlauf-Seite (Körper).
</p>
{rules.map((item, i) => (
<PilotRuleCard key={i} item={item} />
))}
</div>
)}
</div>
)
}

View File

@ -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 (
<div className="card section-gap" style={{ textAlign: 'center', padding: 24 }}>
<div className="spinner" />
</div>
)
}
if (err) {
return (
<div className="card section-gap" style={{ color: 'var(--danger)', fontSize: 13 }}>
{err}
</div>
)
}
const tiles = []
refs.forEach((tile) => {
const l = tile.latest
tiles.push(
<div key={`ref-${tile.type_key}`} className="card" style={{ margin: 0, padding: 12, boxSizing: 'border-box' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)' }}>{tile.type_label}</div>
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 4 }}>
{formatRefVal(l)}
{l.unit ? (
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text2)', marginLeft: 4 }}>{l.unit}</span>
) : null}
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>Ref.wert</div>
</div>,
)
})
if (bf) {
tiles.push(
<div key="kpi-bf" className="card" style={{ margin: 0, padding: 12, boxSizing: 'border-box' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)' }}>Körperfett</div>
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 4, color: bf.cat?.color || 'var(--text1)' }}>
{bf.pct}%
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>{bf.cat?.label || 'Caliper'}</div>
</div>,
)
}
if (avgKcal != null) {
tiles.push(
<div key="kpi-kcal" className="card" style={{ margin: 0, padding: 12, boxSizing: 'border-box' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)' }}>Ø Kalorien (7T)</div>
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 4, color: '#EF9F27' }}>{avgKcal} kcal</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>Ernährung</div>
</div>,
)
}
if (tiles.length === 0) {
return (
<div className="card section-gap">
<div className="card-title">Kennzahlen</div>
<p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>
Noch keine Daten.{' '}
<Link to="/settings/reference-values" style={{ color: 'var(--accent)' }}>
Referenzwerte
</Link>
,{' '}
<Link to="/caliper" style={{ color: 'var(--accent)' }}>
Caliper
</Link>
,{' '}
<Link to="/nutrition" style={{ color: 'var(--accent)' }}>
Ernährung
</Link>
.
</p>
</div>
)
}
return (
<div className="card section-gap">
<div className="card-title">Kennzahlen</div>
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.5 }}>
Referenzwerte (bis {MAX_KPI} gesamt inkl. KF% und Ø-Kalorien). Fehlende Typen werden automatisch weggelassen.
</p>
<div className="ref-value-tiles-grid">{tiles}</div>
</div>
)
}

View File

@ -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 (
<div className="card section-gap">
<div className="card-title">Schnelleingabe (heute)</div>
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.5 }}>
Gewicht separat; Vitalwerte typischerweise gemeinsam.{' '}
<Link to="/vitals" style={{ color: 'var(--accent)', fontSize: 12 }}>
Volle Vitalwerte-Seite
</Link>
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
<div style={cellStyle}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>Gewicht</div>
{weightErr && (
<div style={{ fontSize: 11, color: 'var(--danger)', marginBottom: 6 }}>{weightErr}</div>
)}
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="number"
className="form-input"
min={20}
max={300}
step={0.1}
style={{ flex: 1, minWidth: 72 }}
placeholder="kg"
value={weightInput}
onChange={(e) => setWeightInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && saveWeight()}
/>
<button
type="button"
className="btn btn-primary"
style={{ padding: '6px 12px' }}
disabled={weightSaving}
onClick={saveWeight}
>
{weightSaved ? <Check size={15} /> : weightSaving ? <div className="spinner" style={{ width: 14, height: 14 }} /> : 'OK'}
</button>
</div>
</div>
<div style={{ ...cellStyle, flex: '2 1 280px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Vitalwerte (Baseline)
</div>
{vErr && <div style={{ fontSize: 11, color: 'var(--danger)', marginBottom: 6 }}>{vErr}</div>}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(88px, 1fr))', gap: 8 }}>
<input
type="number"
className="form-input"
placeholder="Ruhepuls"
value={vForm.resting_hr}
onChange={(e) => setVForm((f) => ({ ...f, resting_hr: e.target.value }))}
/>
<input
type="number"
className="form-input"
placeholder="HRV"
value={vForm.hrv}
onChange={(e) => setVForm((f) => ({ ...f, hrv: e.target.value }))}
/>
<input
type="number"
className="form-input"
step={0.1}
placeholder="VO₂max"
value={vForm.vo2_max}
onChange={(e) => setVForm((f) => ({ ...f, vo2_max: e.target.value }))}
/>
</div>
<button
type="button"
className="btn btn-primary btn-full"
style={{ marginTop: 10 }}
disabled={vSaving}
onClick={saveVitals}
>
{vOk ? '✓ Gespeichert' : vSaving ? '…' : 'Vitalwerte speichern'}
</button>
</div>
</div>
</div>
)
}

View File

@ -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 (
<div style={{ border: `1px solid ${color}33`, borderRadius: 8, marginBottom: 6, overflow: 'hidden' }}>
<button
type="button"
onClick={() => 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)',
}}
>
<span style={{ fontSize: 15 }}>{item.icon}</span>
<div style={{ flex: 1 }}>
<div
style={{
fontSize: 11,
fontWeight: 600,
color,
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
{item.category}
</div>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text1)' }}>{item.title}</div>
</div>
{item.value && <span style={{ fontSize: 14, fontWeight: 700, color }}>{item.value}</span>}
{open ? <ChevronUp size={14} color="var(--text3)" /> : <ChevronDown size={14} color="var(--text3)" />}
</button>
{open && (
<div
style={{
padding: '8px 12px',
fontSize: 12,
color: 'var(--text2)',
lineHeight: 1.6,
borderTop: `1px solid ${color}22`,
}}
>
{item.detail}
</div>
)}
</div>
)
}

View File

@ -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 (
<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,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 (
<div className="card section-gap" style={{ marginBottom: 16 }}>
<h2 style={{ fontSize: 20, fontWeight: 800, margin: 0, color: 'var(--text1)' }}>
Hallo, {activeProfile?.name || 'Nutzer'} 👋
</h2>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '6px 0 0' }}>
{dayjs().format('dddd, DD. MMMM YYYY')} · Pilot-Übersicht
</p>
</div>
)
}

View File

@ -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 (
<div style={{ padding: 20, textAlign: 'center' }}>
<div className="spinner" />
</div>
)
}
if (err) {
return <div style={{ fontSize: 13, color: 'var(--danger)' }}>{err}</div>
}
if (tiles.length === 0) {
return (
<p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>
Noch keine Referenzwerte erfasst.{' '}
<Link to="/settings/reference-values" style={{ color: 'var(--accent)' }}>
Zur Erfassung
</Link>
</p>
)
}
return (
<div className="ref-value-tiles-grid">
{tiles
.filter((t) => t?.latest)
.map((tile) => (
<div
key={tile.type_key}
className="card"
style={{ margin: 0, padding: 14, boxSizing: 'border-box' }}
>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text2)' }}>{tile.type_label}</div>
<div style={{ fontSize: 20, fontWeight: 700, marginTop: 6 }}>
{formatEntryValue(tile.latest)}
{tile.latest.unit ? (
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text2)', marginLeft: 6 }}>
{tile.latest.unit}
</span>
) : null}
</div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 6 }}>
Stand {String(tile.latest.effective_date || '').slice(0, 10)}
</div>
</div>
))}
</div>
)
}

View File

@ -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 (
<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,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 (
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 900, margin: '0 auto' }}>
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 920, margin: '0 auto' }}>
<div style={{ marginBottom: 20 }}>
<Link
to="/settings"
@ -24,37 +29,19 @@ export default function PilotVizPage() {
</Link>
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<FlaskConical size={26} color="var(--accent)" />
Pilot: Visualisierungs-Module
Pilot: Übersicht
</h1>
<p style={{ fontSize: 14, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
Testumgebung für die Widget-Registry. Konfiguration unten; produktive Übersicht und Verlauf sind unverändert.
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
Konfigurierbare Ziel-Übersicht (Test). Produktives Dashboard und Verlauf unverändert. Nach Speichern von
Gewicht oder Vitalwerten werden KPIs und Körperbereich neu geladen.
</p>
</div>
<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>
)
})
)}
<PilotWelcome />
<PilotQuickCapture onSaved={bump} />
<PilotKpiBoard refreshTick={refreshTick} />
<PilotBodySection refreshTick={refreshTick} />
<PilotActivitySection refreshTick={refreshTick} />
</div>
)
}

View File

@ -449,7 +449,8 @@ export default function SettingsPage() {
Pilot: Visualisierungs-Module
</div>
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.5 }}>
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.
</p>
<Link
to="/pilot/viz"

View File

@ -0,0 +1,16 @@
import dayjs from 'dayjs'
/** Gleiche Logik wie History.jsx für rollierende Mittel */
export function rollingAvg(arr, key, window = 7) {
return arr.map((d, i) => {
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')

View File

@ -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
}

View File

@ -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])
}