feat: Remove deprecated pilot widgets and layout management
- 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:
parent
8c8f595385
commit
c0cb995a7b
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
128
frontend/src/components/pilot/PilotActivitySection.jsx
Normal file
128
frontend/src/components/pilot/PilotActivitySection.jsx
Normal 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: 4–5 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>
|
||||
)
|
||||
}
|
||||
228
frontend/src/components/pilot/PilotBodySection.jsx
Normal file
228
frontend/src/components/pilot/PilotBodySection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
166
frontend/src/components/pilot/PilotKpiBoard.jsx
Normal file
166
frontend/src/components/pilot/PilotKpiBoard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
198
frontend/src/components/pilot/PilotQuickCapture.jsx
Normal file
198
frontend/src/components/pilot/PilotQuickCapture.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
frontend/src/components/pilot/PilotRuleCard.jsx
Normal file
59
frontend/src/components/pilot/PilotRuleCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
19
frontend/src/components/pilot/PilotWelcome.jsx
Normal file
19
frontend/src/components/pilot/PilotWelcome.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
16
frontend/src/pilot/pilotChartUtils.js
Normal file
16
frontend/src/pilot/pilotChartUtils.js
Normal 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')
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user