feat: enhance History page with new scatter chart and correlation insights
All checks were successful
Deploy Development / deploy (push) Successful in 57s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s

- Added ScatterChart and related functions to visualize correlation data, improving user understanding of relationships between metrics.
- Introduced new utility functions for processing chart data and determining status tones, enhancing the clarity of visual representations.
- Updated the NutritionSection to include additional insights on calorie balance and protein vs. lean mass, providing a more comprehensive overview of nutrition trends.
This commit is contained in:
Lars 2026-04-20 14:31:35 +02:00
parent 7ac9752c3d
commit 5d67a77a12

View File

@ -4,7 +4,8 @@ import { useProfile } from '../context/ProfileContext'
import {
LineChart, Line, BarChart, Bar,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
ReferenceLine, PieChart, Pie, Cell, ComposedChart
ReferenceLine, PieChart, Pie, Cell, ComposedChart,
ScatterChart, Scatter,
} from 'recharts'
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
import { api } from '../utils/api'
@ -990,6 +991,9 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '}
<strong>Kalorien vs. Gewicht</strong> und TDEE-Referenz entsprechen MifflinSt Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg).
{' '}
<strong>Kalorienbilanz</strong>, <strong>Protein vs. Magermasse</strong> und den Block{' '}
<strong>«Kurz-Einordnung»</strong> finden Sie hier früher im eigenen Reiter «Korrelation» (jetzt Data-Layer-Bundle).
</p>
<NutritionGoalsStrip grouped={groupedGoals} />
@ -1242,36 +1246,163 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
)
}
function LagCorrelationMetaCard({ title, block }) {
if (!block) return null
const ok = block.available
function overviewSectionTone(sec) {
const kpis = sec.kpi_short || []
if (kpis.some((k) => k.status === 'bad')) return 'bad'
if (kpis.some((k) => k.status === 'warn')) return 'warn'
const interp = sec.interpretation_short || []
if (interp.some((x) => x.status === 'bad')) return 'bad'
if (interp.some((x) => x.status === 'warn')) return 'warn'
const heur = sec.heuristic_short || []
if (heur.some((h) => h.status === 'warn')) return 'warn'
return 'good'
}
function overviewConfidenceUi(conf) {
if (conf === 'high') return { label: 'Datenlage: gut', tone: 'good', hint: 'Ausreichend Messpunkte für sinnvolle Kurzinfos.' }
if (conf === 'medium') return { label: 'Datenlage: mittel', tone: 'warn', hint: 'Einzelne Bereiche sind noch dünn besetzt.' }
return { label: 'Datenlage: dünn', tone: 'bad', hint: 'Mehr Einträge verbessern die Aussagekraft.' }
}
function chartJsScatterPoints(payload) {
const raw = payload?.data?.datasets?.[0]?.data || []
if (!Array.isArray(raw)) return []
return raw.map((p) => ({ x: Number(p.x), y: Number(p.y) }))
}
function driverBarFromStatus(st) {
const s = String(st || '').toLowerCase()
if (s.includes('hinder')) return { v: -1, fill: 'var(--danger)' }
if (s.includes('förder') || s.includes('foerder')) return { v: 1, fill: 'var(--accent)' }
return { v: 0.15, fill: '#6B7280' }
}
function chartJsBarRows(payload, fallbackDrivers) {
const labels = payload?.data?.labels || []
const values = payload?.data?.datasets?.[0]?.data || []
const colors = payload?.data?.datasets?.[0]?.backgroundColor
if (labels.length && values.length) {
return labels.map((name, i) => ({
name: name.length > 42 ? `${name.slice(0, 40)}` : name,
value: Number(values[i]),
fill: Array.isArray(colors) ? colors[i] : Number(values[i]) < 0 ? '#EF4444' : '#1D9E75',
}))
}
if (fallbackDrivers?.length) {
return fallbackDrivers.map((d) => {
const { v, fill } = driverBarFromStatus(d.status)
return {
name: String(d.factor || '—').length > 40 ? `${String(d.factor).slice(0, 38)}` : String(d.factor || '—'),
value: v,
fill,
subtitle: d.reason,
}
})
}
return []
}
function CorrelationScatterTile({ title, accent, payload }) {
const meta = payload?.metadata || {}
const pts = chartJsScatterPoints(payload)
const hasChart = pts.length > 0 && meta.correlation != null
const r = Number(meta.correlation)
const strength =
!Number.isFinite(r) ? 'bad' : Math.abs(r) >= 0.35 ? 'good' : Math.abs(r) >= 0.15 ? 'warn' : 'bad'
return (
<div className="card" style={{ marginBottom: 8, padding: '10px 12px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 4 }}>{title}</div>
{!ok ? (
<div style={{ fontSize: 11, color: 'var(--text3)' }}>Nicht genug Daten für diese Auswertung.</div>
<div
className="card"
style={{
marginBottom: 0,
padding: 10,
borderLeft: `4px solid ${getStatusColor(strength)}`,
}}
>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text1)', marginBottom: 4 }}>{title}</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.35, marginBottom: 6 }}>
r = {meta.correlation != null ? Number(meta.correlation).toFixed(3) : '—'}
{meta.best_lag_days != null ? ` · Lag ${meta.best_lag_days} T` : ''}
{meta.metric ? ` · ${meta.metric}` : ''}
{meta.confidence ? ` · ${meta.confidence}` : ''}
</div>
{!hasChart ? (
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{meta.message || 'Keine Daten für diese Korrelation.'}</div>
) : (
<>
<div style={{ fontSize: 12, color: 'var(--text1)' }}>
r {block.correlation != null ? Number(block.correlation).toFixed(3) : '—'}
{block.best_lag_days != null ? ` · Lag ${block.best_lag_days} Tage` : ''}
{block.metric ? ` · ${block.metric}` : ''}
{block.confidence ? ` · ${block.confidence}` : ''}
</div>
{block.interpretation ? (
<div style={{ fontSize: 11, color: 'var(--text2)', marginTop: 6, lineHeight: 1.45 }}>{block.interpretation}</div>
) : null}
</>
<ResponsiveContainer width="100%" height={118}>
<ScatterChart margin={{ top: 2, right: 4, bottom: 2, left: -18 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis type="number" dataKey="x" domain={[0, 28]} tick={{ fontSize: 9, fill: 'var(--text3)' }} />
<YAxis type="number" dataKey="y" domain={[-1, 1]} tick={{ fontSize: 9, fill: 'var(--text3)' }} />
<ReferenceLine y={0} stroke="var(--text3)" strokeDasharray="4 4" />
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 10 }} />
<Scatter name="r" data={pts} fill={accent} />
</ScatterChart>
</ResponsiveContainer>
)}
{meta.interpretation ? (
<div style={{ fontSize: 10, color: 'var(--text2)', marginTop: 6, lineHeight: 1.4 }}>{meta.interpretation}</div>
) : null}
</div>
)
}
// Gesamtansicht (Layer 2b: GET /charts/history-overview-viz)
function DriversImpactTile({ payload, driversFallback }) {
const meta = payload?.metadata || {}
const rows = chartJsBarRows(payload, driversFallback)
if (!rows.length) {
return (
<div className="card" style={{ padding: 12, borderLeft: '4px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 6 }}>C4 Einflussfaktoren</div>
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{meta.message || 'Keine Treiber-Daten.'}</div>
</div>
)
}
const h = Math.min(220, Math.max(96, rows.length * 34))
return (
<div className="card" style={{ padding: 10, borderLeft: '4px solid var(--accent)' }}>
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 6 }}>C4 Einflussfaktoren</div>
<ResponsiveContainer width="100%" height={h}>
<BarChart data={rows} layout="vertical" margin={{ left: 2, right: 6, top: 2, bottom: 2 }}>
<XAxis type="number" domain={[-1.2, 1.2]} tick={{ fontSize: 9 }} />
<YAxis type="category" dataKey="name" width={112} tick={{ fontSize: 9, fill: 'var(--text2)' }} />
<Tooltip
content={({ active, payload: pp }) => {
if (!active || !pp?.length) return null
const p = pp[0].payload
return (
<div
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
padding: '8px 10px',
borderRadius: 8,
fontSize: 11,
maxWidth: 280,
}}
>
<div style={{ fontWeight: 600 }}>{p.name}</div>
{p.subtitle ? <div style={{ marginTop: 4, color: 'var(--text2)', lineHeight: 1.4 }}>{p.subtitle}</div> : null}
</div>
)
}}
/>
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
{rows.map((e, i) => (
<Cell key={i} fill={e.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}
// Gesamtansicht (Layer 2b: overview + Chart-Endpunkte C1C4)
function HistoryOverviewSection({ insights, onRequest, loadingSlug, filterActiveSlugs }) {
const navigate = useNavigate()
const [period, setPeriod] = useState(30)
const [data, setData] = useState(null)
const [bundle, setBundle] = useState(null)
const [err, setErr] = useState(null)
const [loading, setLoading] = useState(true)
@ -1279,10 +1410,16 @@ function HistoryOverviewSection({ insights, onRequest, loadingSlug, filterActive
let cancelled = false
const daysReq = period === 9999 ? 3650 : period
setLoading(true)
api.getHistoryOverviewViz(daysReq)
.then((d) => {
Promise.all([
api.getHistoryOverviewViz(daysReq),
api.getWeightEnergyCorrelationChart(14),
api.getLbmProteinCorrelationChart(14),
api.getLoadVitalsCorrelationChart(14),
api.getRecoveryPerformanceChart(),
])
.then(([overview, chartC1, chartC2, chartC3, chartC4]) => {
if (!cancelled) {
setData(d)
setBundle({ overview, chartC1, chartC2, chartC3, chartC4 })
setErr(null)
}
})
@ -1315,86 +1452,153 @@ function HistoryOverviewSection({ insights, onRequest, loadingSlug, filterActive
)
}
const data = bundle?.overview
const chartC1 = bundle?.chartC1
const chartC2 = bundle?.chartC2
const chartC3 = bundle?.chartC3
const chartC4 = bundle?.chartC4
const lag = data?.lag_correlations || {}
const c4 = lag.recovery_performance
const c4drivers = lag.recovery_performance?.drivers || []
const sections = data?.sections || []
const confUi = overviewConfidenceUi(data?.confidence)
return (
<div>
<SectionHeader title="📊 Gesamtansicht" />
<PeriodSelector value={period} onChange={setPeriod} />
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
Kurzüberblick aus denselben Data-Layer-Bundles wie die Reiter Körper bis Erholung (Issue&nbsp;53). Lag-Korrelationen C1C4
stammen aus <code style={{ fontSize: 10 }}>correlations.py</code> / Chart-Endpunkte.
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 10,
marginBottom: 14,
padding: '10px 12px',
borderRadius: 12,
border: '1px solid var(--border)',
background: getStatusBg(confUi.tone),
borderLeft: `5px solid ${getStatusColor(confUi.tone)}`,
}}
>
<span style={{ fontSize: 20, lineHeight: 1 }}>{confUi.tone === 'good' ? '●' : confUi.tone === 'warn' ? '◐' : '○'}</span>
<div style={{ flex: 1, minWidth: 200 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text1)' }}>{confUi.label}</div>
<div style={{ fontSize: 11, color: 'var(--text2)', marginTop: 2 }}>{confUi.hint}</div>
</div>
</div>
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}>
KPIs und Texte kommen aus den Layer-2b-Bundles (Körper, Ernährung, Fitness, Erholung).{' '}
<strong>Ehem. «Korrelation»-Charts</strong> (Bilanz, Protein/Mager, Kurz-Einordnung) liegen unter{' '}
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '2px 8px' }} onClick={() => navigate('/history', { state: { tab: 'nutrition' } })}>
Ernährung
</button>
. Die Kacheln C1C4 unten nutzen dieselben Chart-Endpunkte wie die API (<code style={{ fontSize: 10 }}>/api/charts/*</code>).
</p>
{sections.map((sec) => (
<div key={sec.id} className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 8 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>{sec.title}</div>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: 11, padding: '4px 10px', flexShrink: 0 }}
onClick={() => navigate('/history', { state: { tab: sec.tab_id } })}
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 10 }}>
{sections.map((sec) => {
const tone = overviewSectionTone(sec)
const stripe = getStatusColor(tone)
const badgeBg = getStatusBg(tone)
return (
<div
key={sec.id}
style={{
borderRadius: 12,
border: '1px solid var(--border)',
borderLeft: `5px solid ${stripe}`,
background: 'var(--surface)',
padding: '12px 12px 12px 14px',
boxShadow: tone === 'bad' ? '0 0 0 1px rgba(216,90,48,0.12)' : undefined,
}}
>
Zum Reiter
</button>
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8 }}>{sec.summary_line}</div>
{(sec.interpretation_short || []).map((it, i) => (
<div key={i} style={{ fontSize: 12, marginBottom: 6, paddingLeft: 4, borderLeft: `3px solid ${getStatusColor(it.status || 'good')}` }}>
<strong style={{ color: 'var(--text1)' }}>{it.title}</strong>
<div style={{ color: 'var(--text2)', marginTop: 2 }}>{it.detail}</div>
</div>
))}
{(sec.kpi_short || []).map((k, i) => (
<div key={i} style={{ fontSize: 12, marginBottom: 4 }}>
<span style={{ color: getStatusColor(k.status || 'good') }}>{k.icon} {k.category}</span>
{' · '}
<span style={{ fontWeight: 600 }}>{k.value}</span>
{k.sublabel ? <span style={{ color: 'var(--text3)', fontSize: 10 }}> {k.sublabel}</span> : null}
</div>
))}
{(sec.heuristic_short || []).map((h, i) => (
<div key={i} style={{ fontSize: 12, marginTop: 6, color: 'var(--text2)' }}>
<strong>{h.title}</strong>
<div style={{ fontSize: 11, marginTop: 2 }}>{h.detail}</div>
</div>
))}
{(sec.insights_short || []).map((ins, i) => (
<div key={i} style={{ fontSize: 12, marginTop: 6, color: 'var(--text2)', lineHeight: 1.45 }}>
<strong>{ins.title}</strong>
<div>{ins.body}</div>
</div>
))}
</div>
))}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 30,
height: 30,
borderRadius: 10,
fontSize: 13,
fontWeight: 800,
color: stripe,
background: badgeBg,
}}
>
{tone === 'good' ? '✓' : tone === 'warn' ? '!' : '!!'}
</span>
<div style={{ fontSize: 15, fontWeight: 700 }}>{sec.title}</div>
</div>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: 11, padding: '4px 10px', flexShrink: 0 }}
onClick={() => navigate('/history', { state: { tab: sec.tab_id } })}
>
Öffnen
</button>
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 10, lineHeight: 1.45 }}>{sec.summary_line}</div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 8 }}>
Lag-Korrelationen (C1C3)
</div>
<LagCorrelationMetaCard title={lag.weight_energy?.label || 'C1'} block={lag.weight_energy} />
<LagCorrelationMetaCard title={lag.protein_lbm?.label || 'C2'} block={lag.protein_lbm} />
<LagCorrelationMetaCard title={lag.load_vitals?.label || 'C3'} block={lag.load_vitals} />
{(sec.kpi_short || []).length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(128px, 1fr))', gap: 8, marginBottom: 8 }}>
{(sec.kpi_short || []).map((k, i) => (
<div
key={i}
style={{
padding: '8px 10px',
borderRadius: 10,
background: getStatusBg(k.status || 'good'),
border: `1px solid ${getStatusColor(k.status || 'good')}55`,
}}
>
<div style={{ fontSize: 9, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{k.category}</div>
<div style={{ fontSize: 14, fontWeight: 700, color: getStatusColor(k.status || 'good'), marginTop: 2 }}>{k.value}</div>
{k.sublabel ? <div style={{ fontSize: 9, color: 'var(--text3)', marginTop: 2 }}>{k.sublabel}</div> : null}
</div>
))}
</div>
)}
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 12 }}>
Einflussfaktoren (C4)
</div>
<div className="card" style={{ marginBottom: 12, padding: '10px 12px' }}>
{c4?.drivers?.length ? (
c4.drivers.map((d, i) => (
<div key={i} style={{ fontSize: 12, marginBottom: 8, lineHeight: 1.45 }}>
<strong>{d.factor}</strong>
<span style={{ color: 'var(--text3)', marginLeft: 6 }}>({d.status})</span>
<div style={{ color: 'var(--text2)', marginTop: 2 }}>{d.reason}</div>
{(sec.interpretation_short || []).map((it, i) => (
<div key={`in-${i}`} style={{ fontSize: 11, marginBottom: 6, paddingLeft: 8, borderLeft: `3px solid ${getStatusColor(it.status || 'good')}` }}>
<strong style={{ color: 'var(--text1)' }}>{it.title}</strong>
<div style={{ color: 'var(--text2)', marginTop: 2, lineHeight: 1.4 }}>{it.detail}</div>
</div>
))}
{(sec.heuristic_short || []).map((h, i) => (
<div key={`he-${i}`} style={{ fontSize: 11, marginTop: 6, padding: '6px 8px', borderRadius: 8, background: 'var(--surface2)' }}>
<strong style={{ color: h.status === 'warn' ? 'var(--warn)' : 'var(--accent)' }}>{h.title}</strong>
<div style={{ fontSize: 10, color: 'var(--text2)', marginTop: 2 }}>{h.detail}</div>
</div>
))}
{(sec.insights_short || []).map((ins, i) => (
<div key={`is-${i}`} style={{ fontSize: 11, marginTop: 6, color: 'var(--text2)', lineHeight: 1.45 }}>
<strong>{ins.title}</strong>
<div>{ins.body}</div>
</div>
))}
</div>
))
) : (
<div style={{ fontSize: 11, color: 'var(--text3)' }}>Keine Treiber-Daten.</div>
)}
)
})}
</div>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text1)', margin: '18px 0 10px' }}>Lag-Korrelationen (C1C3)</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 10 }}>
<CorrelationScatterTile title="C1 Energiebilanz ↔ Gewicht" accent="#1D9E75" payload={chartC1} />
<CorrelationScatterTile title="C2 Protein ↔ Magermasse" accent="#3B82F6" payload={chartC2} />
<CorrelationScatterTile title="C3 Last ↔ HRV/RHR" accent="#F59E0B" payload={chartC3} />
</div>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text1)', margin: '18px 0 10px' }}>Einflussfaktoren (C4)</div>
<DriversImpactTile payload={chartC4} driversFallback={c4drivers} />
<InsightBox insights={insights} slugs={filterActiveSlugs(['gesamt', 'ziele'])} onRequest={onRequest} loading={loadingSlug} />
</div>
)