- Introduced the `history_overview_viz` widget to the dashboard, allowing users to visualize consolidated history data across various metrics. - Updated widget configuration to include `history_overview_viz` in the allowed widgets and added validation for its configuration. - Enhanced the widget catalog with details for the new `history_overview_viz` entry. - Implemented default values and validation logic for the widget's configuration, ensuring proper handling of user inputs. - Added tests to ensure proper validation of the `history_overview_viz` widget configuration. - Bumped application version to reflect the addition of the new widget.
522 lines
22 KiB
JavaScript
522 lines
22 KiB
JavaScript
import { useState, useEffect } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import {
|
||
BarChart,
|
||
Bar,
|
||
XAxis,
|
||
YAxis,
|
||
Tooltip,
|
||
ResponsiveContainer,
|
||
CartesianGrid,
|
||
ReferenceLine,
|
||
ComposedChart,
|
||
ScatterChart,
|
||
Scatter,
|
||
Line,
|
||
Cell,
|
||
} from 'recharts'
|
||
import { api } from '../../utils/api'
|
||
import { getStatusColor, getStatusBg } from '../../utils/interpret'
|
||
import { EmptySection, PeriodSelector, SectionHeader } from './historyPageChrome'
|
||
import { HISTORY_OVERVIEW_VIZ_PAGE_FULL, normalizeHistoryOverviewVizConfig } from '../../widgetSystem/historyOverviewVizConfig'
|
||
|
||
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 lagDetailsToCurve(meta) {
|
||
let ld = meta?.lag_details
|
||
if (!Array.isArray(ld) || ld.length === 0) {
|
||
const m = String(meta?.metric || '').toUpperCase()
|
||
if (m === 'HRV' && Array.isArray(meta?.lag_details_hrv)) ld = meta.lag_details_hrv
|
||
else if (m === 'RHR' && Array.isArray(meta?.lag_details_rhr)) ld = meta.lag_details_rhr
|
||
else {
|
||
const h = meta?.lag_details_hrv
|
||
const r = meta?.lag_details_rhr
|
||
const hl = Array.isArray(h) ? h.length : 0
|
||
const rl = Array.isArray(r) ? r.length : 0
|
||
if (hl >= rl && hl > 0) ld = h
|
||
else if (rl > 0) ld = r
|
||
else ld = []
|
||
}
|
||
}
|
||
if (!Array.isArray(ld) || ld.length === 0) return []
|
||
return ld
|
||
.map((d) => ({
|
||
lag: Number(d?.lag),
|
||
r: d?.r == null || d?.r === '' ? null : Number(d.r),
|
||
n_pairs: d?.n_pairs != null ? Number(d.n_pairs) : null,
|
||
}))
|
||
.filter((d) => Number.isFinite(d.lag) && d.r != null && Number.isFinite(d.r))
|
||
.sort((a, b) => a.lag - b.lag)
|
||
}
|
||
|
||
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 curve = lagDetailsToCurve(meta)
|
||
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'
|
||
const bestLag = meta.best_lag_days != null ? Number(meta.best_lag_days) : null
|
||
const maxLagAxis = curve.length ? Math.max(14, ...curve.map((d) => d.lag), bestLag || 0) : 28
|
||
|
||
return (
|
||
<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 ? ` · bestes Lag ${meta.best_lag_days} T` : ''}
|
||
{meta.metric ? ` · ${meta.metric}` : ''}
|
||
{meta.confidence ? ` · ${meta.confidence}` : ''}
|
||
</div>
|
||
{!hasChart ? (
|
||
<>
|
||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: curve.length ? 8 : 0 }}>
|
||
{meta.message || 'Keine Daten für diese Korrelation.'}
|
||
</div>
|
||
{curve.length > 0 && (
|
||
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 6 }}>
|
||
Lag-Sweep (kein Lag mit ≥15 Paaren): r über Lags — nur zur Einordnung.
|
||
</div>
|
||
)}
|
||
{curve.length > 0 && (
|
||
<ResponsiveContainer width="100%" height={120}>
|
||
<ComposedChart data={curve} margin={{ top: 4, right: 6, bottom: 4, left: -14 }}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||
<XAxis dataKey="lag" type="number" domain={[0, maxLagAxis]} tick={{ fontSize: 9, fill: 'var(--text3)' }} label={{ value: 'Lag (T)', fontSize: 9, fill: 'var(--text3)', offset: -2 }} />
|
||
<YAxis dataKey="r" domain={[-1, 1]} tick={{ fontSize: 9, fill: 'var(--text3)' }} width={36} label={{ value: 'r', fontSize: 9, fill: 'var(--text3)', angle: -90 }} />
|
||
<ReferenceLine y={0} stroke="var(--text3)" strokeDasharray="4 4" />
|
||
<Tooltip
|
||
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 10 }}
|
||
formatter={(v, _n, item) => [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]}
|
||
/>
|
||
<Line type="monotone" dataKey="r" stroke={accent} strokeWidth={2} dot={{ r: 3, fill: accent }} isAnimationActive={false} />
|
||
</ComposedChart>
|
||
</ResponsiveContainer>
|
||
)}
|
||
</>
|
||
) : curve.length >= 1 ? (
|
||
<>
|
||
<div style={{ fontSize: 9, color: 'var(--text3)', marginBottom: 4 }}>
|
||
Kurve: Pearson-r je Lag (Tage); starker Punkt = gewähltes bestes Lag.
|
||
</div>
|
||
<ResponsiveContainer width="100%" height={132}>
|
||
<ComposedChart data={curve} margin={{ top: 4, right: 6, bottom: 4, left: -14 }}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||
<XAxis dataKey="lag" type="number" domain={[0, maxLagAxis]} tick={{ fontSize: 9, fill: 'var(--text3)' }} />
|
||
<YAxis dataKey="r" domain={[-1, 1]} tick={{ fontSize: 9, fill: 'var(--text3)' }} width={36} />
|
||
<ReferenceLine y={0} stroke="var(--text3)" strokeDasharray="4 4" />
|
||
<Tooltip
|
||
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 10 }}
|
||
formatter={(v, _n, item) => [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]}
|
||
/>
|
||
<Line
|
||
type="monotone"
|
||
dataKey="r"
|
||
stroke={accent}
|
||
strokeWidth={2}
|
||
isAnimationActive={false}
|
||
dot={(props) => {
|
||
const { cx, cy, payload: pl } = props
|
||
if (cx == null || cy == null || !pl) return null
|
||
const isBest = bestLag != null && Number(pl.lag) === bestLag
|
||
return (
|
||
<circle
|
||
cx={cx}
|
||
cy={cy}
|
||
r={isBest ? 6 : 3.5}
|
||
fill={isBest ? 'var(--surface)' : accent}
|
||
stroke={isBest ? accent : 'none'}
|
||
strokeWidth={isBest ? 2.5 : 0}
|
||
/>
|
||
)
|
||
}}
|
||
/>
|
||
</ComposedChart>
|
||
</ResponsiveContainer>
|
||
</>
|
||
) : (
|
||
<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>
|
||
)
|
||
}
|
||
|
||
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>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Verlauf «Gesamt» / Dashboard-Widget: Layer-2b history-overview-viz (+ chart_payloads C1–C4).
|
||
*
|
||
* @param {object} props
|
||
* @param {import('react').ReactNode} [props.footer]
|
||
* @param {number} [props.externalPeriod] — feste Tage (Widget); sonst interner PeriodSelector (30…9999)
|
||
* @param {boolean} [props.hidePeriodSelector]
|
||
* @param {boolean} [props.embedded]
|
||
* @param {Record<string, unknown>} [props.visibility] — normalisierte Widget-Config; undefined = Verlauf volle Ansicht
|
||
*/
|
||
export default function HistoryOverviewVizSection({
|
||
footer = null,
|
||
externalPeriod,
|
||
hidePeriodSelector = false,
|
||
embedded = false,
|
||
visibility: visibilityProp,
|
||
}) {
|
||
const navigate = useNavigate()
|
||
const [period, setPeriod] = useState(30)
|
||
const [bundle, setBundle] = useState(null)
|
||
const [err, setErr] = useState(null)
|
||
const [loading, setLoading] = useState(true)
|
||
|
||
const effPeriod = externalPeriod != null ? externalPeriod : period
|
||
const daysReq = effPeriod === 9999 ? 3650 : effPeriod
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
setLoading(true)
|
||
|
||
const attachCharts = (overview, c1, c2, c3, c4) => {
|
||
if (!cancelled) {
|
||
setBundle({ overview, chartC1: c1, chartC2: c2, chartC3: c3, chartC4: c4 })
|
||
setErr(null)
|
||
}
|
||
}
|
||
|
||
const run = async () => {
|
||
try {
|
||
const overview = await api.getHistoryOverviewViz(daysReq)
|
||
const cp = overview?.chart_payloads
|
||
if (cp && cp.c1_weight_energy != null && cp.c2_protein_lbm != null && cp.c3_load_vitals != null && cp.c4_recovery_performance != null) {
|
||
attachCharts(overview, cp.c1_weight_energy, cp.c2_protein_lbm, cp.c3_load_vitals, cp.c4_recovery_performance)
|
||
} else {
|
||
const [chartC1, chartC2, chartC3, chartC4] = await Promise.all([
|
||
api.getWeightEnergyCorrelationChart(14),
|
||
api.getLbmProteinCorrelationChart(14),
|
||
api.getLoadVitalsCorrelationChart(14),
|
||
api.getRecoveryPerformanceChart(),
|
||
])
|
||
attachCharts(overview, chartC1, chartC2, chartC3, chartC4)
|
||
}
|
||
} catch (e) {
|
||
if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen')
|
||
} finally {
|
||
if (!cancelled) setLoading(false)
|
||
}
|
||
}
|
||
|
||
run()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [daysReq])
|
||
|
||
if (loading) {
|
||
return (
|
||
<div>
|
||
{!embedded && <SectionHeader title="📊 Gesamtansicht" />}
|
||
{!hidePeriodSelector && externalPeriod == null && <PeriodSelector value={period} onChange={setPeriod} />}
|
||
<div className="spinner" style={{ margin: 24 }} />
|
||
</div>
|
||
)
|
||
}
|
||
if (err) {
|
||
return (
|
||
<div>
|
||
{!embedded && <SectionHeader title="📊 Gesamtansicht" />}
|
||
{!hidePeriodSelector && externalPeriod == null && <PeriodSelector value={period} onChange={setPeriod} />}
|
||
<div className="card" style={{ color: 'var(--danger)', marginTop: 8 }}>{err}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 c4drivers = lag.recovery_performance?.drivers || []
|
||
const sections = data?.sections || []
|
||
const confUi = overviewConfidenceUi(data?.confidence)
|
||
const vis =
|
||
visibilityProp != null ? normalizeHistoryOverviewVizConfig(visibilityProp) : HISTORY_OVERVIEW_VIZ_PAGE_FULL
|
||
|
||
return (
|
||
<div>
|
||
{!embedded && <SectionHeader title="📊 Gesamtansicht" />}
|
||
{!hidePeriodSelector && externalPeriod == null && <PeriodSelector value={period} onChange={setPeriod} />}
|
||
|
||
{vis.show_confidence_banner && (
|
||
<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>
|
||
)}
|
||
|
||
{vis.show_intro_blurb && (
|
||
<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 C1–C4 entsprechen denselben Chart.js-Payloads wie <code style={{ fontSize: 10 }}>/api/charts/*</code> (bei aktuellem Backend im Overview-Bundle enthalten).
|
||
</p>
|
||
)}
|
||
|
||
{vis.show_area_summaries && (sections.length === 0 ? (
|
||
<EmptySection text="Keine Bereichsdaten." />
|
||
) : (
|
||
<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,
|
||
}}
|
||
>
|
||
<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>
|
||
|
||
{(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>
|
||
)}
|
||
|
||
{(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>
|
||
))}
|
||
|
||
{vis.show_correlation_c1_c3 && (
|
||
<>
|
||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text1)', margin: '18px 0 10px' }}>Lag-Korrelationen (C1–C3)</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>
|
||
</>
|
||
)}
|
||
|
||
{vis.show_drivers_c4 && (
|
||
<>
|
||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text1)', margin: '18px 0 10px' }}>Einflussfaktoren (C4)</div>
|
||
<DriversImpactTile payload={chartC4} driversFallback={c4drivers} />
|
||
</>
|
||
)}
|
||
|
||
{footer}
|
||
</div>
|
||
)
|
||
}
|