feat: add body_history_viz widget and enhance configuration handling
- Introduced the `body_history_viz` widget to the dashboard, allowing users to visualize body history data. - Updated widget configuration to include `body_history_viz` in the allowed widgets and added validation for its configuration. - Enhanced the widget catalog with details for the new `body_history_viz` entry. - Added tests to ensure proper validation of the `body_history_viz` widget configuration. - Updated application version to reflect the addition of the new widget.
This commit is contained in:
parent
3eb7ef3ae6
commit
2453da0da1
1202
.claude/docs/working/SHINKAN_PROJECT_SETUP.md
Normal file
1202
.claude/docs/working/SHINKAN_PROJECT_SETUP.md
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -14,6 +14,7 @@ MAX_WIDGET_CONFIG_JSON_BYTES = 3072
|
|||
|
||||
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
|
||||
"body_overview",
|
||||
"body_history_viz",
|
||||
"activity_overview",
|
||||
"kpi_board",
|
||||
"quick_capture",
|
||||
|
|
@ -52,6 +53,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
|||
|
||||
if widget_id == "body_overview":
|
||||
return _validate_chart_days_only(raw, label="body_overview")
|
||||
if widget_id == "body_history_viz":
|
||||
return _validate_chart_days_only(raw, label="body_history_viz")
|
||||
if widget_id == "activity_overview":
|
||||
return _validate_chart_days_only(raw, label="activity_overview")
|
||||
if widget_id == "kpi_board":
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ def test_body_chart_days_bounds():
|
|||
validate_widget_entry_config("body_overview", {"chart_days": 91})
|
||||
|
||||
|
||||
def test_body_history_viz_chart_days():
|
||||
assert validate_widget_entry_config("body_history_viz", {}) == {}
|
||||
assert validate_widget_entry_config("body_history_viz", {"chart_days": 60}) == {"chart_days": 60}
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("body_history_viz", {"chart_days": 5})
|
||||
|
||||
|
||||
def test_welcome_config_rejected_unknown_key():
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("welcome", {"x": 1})
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ MODULE_VERSIONS = {
|
|||
"importdata": "1.0.0",
|
||||
"membership": "2.1.0",
|
||||
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
|
||||
"app_dashboard": "1.11.0", # Entitlements: DB-Override widget→features (AND), sonst Katalog
|
||||
"app_dashboard": "1.12.0", # Widget body_history_viz (Verlauf body-history-viz Bundle)
|
||||
"csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise
|
||||
"admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
|||
"description": "Gewicht & Kennzahlen (optional: config chart_days 7–90); Feature weight_entries",
|
||||
"requires_feature": "weight_entries",
|
||||
},
|
||||
{
|
||||
"id": "body_history_viz",
|
||||
"title": "Körper (Verlauf-Bundle)",
|
||||
"description": "Wie Verlauf → Körper: GET /charts/body-history-viz (optional chart_days 7–90); Feature weight_entries",
|
||||
"requires_feature": "weight_entries",
|
||||
},
|
||||
{
|
||||
"id": "activity_overview",
|
||||
"title": "Aktivität",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import BodyHistoryVizSection from '../history/BodyHistoryVizSection'
|
||||
import { useProfile } from '../../context/ProfileContext'
|
||||
import { BODY_CHART_DAYS_DEFAULT, normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays'
|
||||
|
||||
/**
|
||||
* Verlauf → Körper als Dashboard-Widget: GET /charts/body-history-viz (Layer 2b), optional chart_days 7–90.
|
||||
* @param {{ refreshTick?: number, chartDays?: number }} props
|
||||
*/
|
||||
export default function BodyHistoryVizWidget({ refreshTick = 0, chartDays }) {
|
||||
const nav = useNavigate()
|
||||
const { activeProfile } = useProfile()
|
||||
const days = chartDays != null ? normalizeBodyChartDays(chartDays) : BODY_CHART_DAYS_DEFAULT
|
||||
|
||||
return (
|
||||
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Körper (Verlauf-Bundle)</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>body-history-viz · {days} Tage</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => nav('/history', { state: { tab: 'body' } })}>
|
||||
Verlauf →
|
||||
</button>
|
||||
</div>
|
||||
<BodyHistoryVizSection key={`${refreshTick}-${days}`} profile={activeProfile} externalPeriod={days} embedded />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
552
frontend/src/components/history/BodyHistoryVizSection.jsx
Normal file
552
frontend/src/components/history/BodyHistoryVizSection.jsx
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
ReferenceLine,
|
||||
ComposedChart,
|
||||
} from 'recharts'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/de'
|
||||
import { api } from '../../utils/api'
|
||||
import { getStatusColor } from '../../utils/interpret'
|
||||
import KpiTilesOverview from '../KpiTilesOverview'
|
||||
import { EmptySection, NavToCaliper, NavToCircum, PeriodSelector, SectionHeader } from './historyPageChrome'
|
||||
|
||||
dayjs.locale('de')
|
||||
|
||||
const fmtDate = (d) => dayjs(d).format('DD.MM')
|
||||
|
||||
function verdictShort(status) {
|
||||
if (status === 'good') return 'Gut'
|
||||
if (status === 'warn') return 'Hinweis'
|
||||
return 'Achtung'
|
||||
}
|
||||
|
||||
/** KPI-Kacheln aus Summary + Interpretationstiles — Trend aus Bundle ``weight.trend_kpi`` (Layer 2b). */
|
||||
function buildBodyKpiTiles({
|
||||
summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints, weightTrendKpi, goalW,
|
||||
}) {
|
||||
const tiles = []
|
||||
|
||||
if (summary.weight_kg != null) {
|
||||
const wt = weightTrendKpi || { verdict: 'Stabil', status: 'good' }
|
||||
const trendBits = trendPeriods.length
|
||||
? trendPeriods.map((t) => `${t.label} ${t.diff_kg > 0 ? '+' : ''}${t.diff_kg} kg`).join(' · ')
|
||||
: ''
|
||||
const hoverBody = [
|
||||
'Gewicht im gewählten Zeitraum (letzter Messwert).',
|
||||
avgAll != null ? `Durchschnitt: ${avgAll} kg` : null,
|
||||
minW != null && maxW != null ? `Min. / Max.: ${minW} – ${maxW} kg` : null,
|
||||
trendBits ? `Änderung: ${trendBits}` : null,
|
||||
goalW != null ? `Profil-Zielgewicht: ${goalW} kg` : null,
|
||||
].filter(Boolean).join('\n')
|
||||
|
||||
tiles.push({
|
||||
key: 'weight',
|
||||
category: 'Gewicht',
|
||||
icon: '⚖️',
|
||||
value: `${summary.weight_kg} kg`,
|
||||
sublabel: dataPoints ? `${dataPoints} Messwerte` : '',
|
||||
verdict: wt.verdict,
|
||||
status: wt.status,
|
||||
hoverTop: 'Gewicht',
|
||||
hoverBody,
|
||||
keys: ['weight_aktuell', 'weight_trend'],
|
||||
})
|
||||
}
|
||||
|
||||
const kfRule = rules.find((r) => r.category === 'Körperfett')
|
||||
if (summary.body_fat_pct != null) {
|
||||
tiles.push({
|
||||
key: 'bf',
|
||||
category: 'Körperfett',
|
||||
icon: '🫧',
|
||||
value: `${summary.body_fat_pct}%`,
|
||||
valueColor: kfRule ? getStatusColor(kfRule.status) : undefined,
|
||||
sublabel: summary.bf_category_label || '',
|
||||
verdict: verdictShort(kfRule?.status || 'good'),
|
||||
status: kfRule?.status || 'good',
|
||||
hoverTop: kfRule?.title || 'Körperfettanteil',
|
||||
hoverBody: [kfRule?.detail, kfRule?.related_placeholder_keys?.length ? `Registry: ${kfRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
}
|
||||
|
||||
const mmRule = rules.find((r) => r.category === 'Muskelmasse')
|
||||
if (summary.lean_mass_kg != null || summary.ffmi != null) {
|
||||
const valParts = []
|
||||
if (summary.lean_mass_kg != null) valParts.push(`${summary.lean_mass_kg} kg`)
|
||||
if (summary.ffmi != null) valParts.push(`FFMI ${summary.ffmi}`)
|
||||
tiles.push({
|
||||
key: 'lean_ffmi',
|
||||
category: 'Magermasse',
|
||||
icon: '💪',
|
||||
value: valParts.join(' · ') || '—',
|
||||
sublabel: 'Lean / FFMI',
|
||||
verdict: mmRule ? verdictShort(mmRule.status) : '—',
|
||||
status: mmRule?.status || 'good',
|
||||
hoverTop: mmRule?.title || 'Muskelmasse',
|
||||
hoverBody: [mmRule?.detail, mmRule?.related_placeholder_keys?.length ? `Registry: ${mmRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
}
|
||||
|
||||
const bmiRule = rules.find((r) => r.category === 'BMI')
|
||||
if (bmiRule) {
|
||||
tiles.push({
|
||||
key: 'bmi',
|
||||
category: 'BMI',
|
||||
icon: '📋',
|
||||
value: bmiRule.value || '—',
|
||||
sublabel: 'Body-Mass-Index',
|
||||
verdict: verdictShort(bmiRule.status),
|
||||
status: bmiRule.status,
|
||||
hoverTop: bmiRule.title,
|
||||
hoverBody: [bmiRule.detail, bmiRule.related_placeholder_keys?.length ? `Registry: ${bmiRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
}
|
||||
|
||||
const whrRule = rules.find((r) => r.category === 'Fettverteilung')
|
||||
if (summary.whr != null && whrRule) {
|
||||
tiles.push({
|
||||
key: 'whr',
|
||||
category: 'Fettverteilung',
|
||||
icon: '📐',
|
||||
value: String(summary.whr),
|
||||
sublabel: 'WHR · Taille ÷ Hüfte',
|
||||
verdict: verdictShort(whrRule.status),
|
||||
status: whrRule.status,
|
||||
hoverTop: whrRule.title || 'Waist-Hip-Ratio',
|
||||
hoverBody: [whrRule.detail, whrRule.related_placeholder_keys?.length ? `Registry: ${whrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
}
|
||||
|
||||
const whtrRule = rules.find((r) => r.category === 'Taille/Größe')
|
||||
if (summary.whtr != null && whtrRule) {
|
||||
tiles.push({
|
||||
key: 'whtr',
|
||||
category: 'Taille/Größe',
|
||||
icon: '📏',
|
||||
value: String(summary.whtr),
|
||||
sublabel: 'WHtR · Taille ÷ Größe',
|
||||
verdict: verdictShort(whtrRule.status),
|
||||
status: whtrRule.status,
|
||||
hoverTop: whtrRule.title || 'Waist-to-Height-Ratio',
|
||||
hoverBody: [whtrRule.detail, whtrRule.related_placeholder_keys?.length ? `Registry: ${whtrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
}
|
||||
|
||||
const lastRule = rules.find((r) => r.category.startsWith('Seit letzter'))
|
||||
if (lastRule) {
|
||||
tiles.push({
|
||||
key: 'delta',
|
||||
category: 'Messvergleich',
|
||||
icon: '📊',
|
||||
value: lastRule.value || '—',
|
||||
sublabel: 'seit Vorperiode',
|
||||
verdict: verdictShort(lastRule.status),
|
||||
status: lastRule.status,
|
||||
hoverTop: lastRule.title,
|
||||
hoverBody: [lastRule.detail, lastRule.related_placeholder_keys?.length ? `Registry: ${lastRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
}
|
||||
|
||||
return tiles
|
||||
}
|
||||
|
||||
function BodyGoalsStrip({ grouped }) {
|
||||
const nav = useNavigate()
|
||||
const goals = (grouped?.body || []).filter((g) => g.status === 'active').slice(0, 4)
|
||||
if (!goals.length) return null
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Körperbezogene Ziele</div>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/goals')}>
|
||||
Ziele <ChevronRight size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{goals.map((g) => (
|
||||
<div
|
||||
key={g.id}
|
||||
style={{
|
||||
flex: '1 1 140px',
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 10px',
|
||||
borderTop: `3px solid ${g.is_primary ? 'var(--accent)' : 'var(--border2)'}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text2)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{g.name || g.label_de || g.goal_type}
|
||||
</div>
|
||||
<div style={{ marginTop: 4, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(0, g.progress_pct ?? 0))}%`,
|
||||
height: '100%',
|
||||
background: 'var(--accent)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
|
||||
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}
|
||||
{g.unit ? ` ${g.unit}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verlauf → Körper: nur GET /api/charts/body-history-viz (Layer 2b).
|
||||
* @param {object} props
|
||||
* @param {object} props.profile — aktives Profil (Zielgewicht-Fallback)
|
||||
* @param {number} [props.externalPeriod] — festes Fenster (Dashboard); sonst interner Zeitraum + PeriodSelector
|
||||
* @param {import('react').ReactNode} [props.footer] — z. B. KI-InsightBox im Verlauf
|
||||
* @param {boolean} [props.embedded] — true im Dashboard-Widget: keine große Section-Überschrift (Karte hat eigenen Titel)
|
||||
*/
|
||||
export default function BodyHistoryVizSection({ profile, externalPeriod, footer = null, embedded = false }) {
|
||||
const [internalPeriod, setInternalPeriod] = useState(90)
|
||||
const period = externalPeriod !== undefined ? externalPeriod : internalPeriod
|
||||
const showPeriodSelector = externalPeriod === undefined
|
||||
|
||||
const [groupedGoals, setGroupedGoals] = useState(null)
|
||||
const [viz, setViz] = useState(null)
|
||||
const [vizLoading, setVizLoading] = useState(true)
|
||||
const [vizError, setVizError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
api.listGoalsGrouped()
|
||||
.then((g) => {
|
||||
if (!cancelled) setGroupedGoals(g)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setGroupedGoals({})
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setVizLoading(true)
|
||||
setVizError(null)
|
||||
api.getBodyHistoryViz(period)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setViz(data)
|
||||
setVizLoading(false)
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setVizError(e.message || 'Laden fehlgeschlagen')
|
||||
setVizLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [period])
|
||||
|
||||
const w = viz?.weight
|
||||
const cal = viz?.caliper
|
||||
const circ = viz?.circumference
|
||||
const summary = viz?.summary || {}
|
||||
|
||||
const wCd = (w?.series || []).map((row) => ({
|
||||
date: fmtDate(row.date),
|
||||
weight: row.weight,
|
||||
avg7: row.avg7,
|
||||
avg14: row.avg14,
|
||||
}))
|
||||
const hasWeight = (w?.data_points || 0) >= 2
|
||||
const avgAll = w?.overall_avg_kg
|
||||
const minW = w?.min_kg
|
||||
const maxW = w?.max_kg
|
||||
const trendPeriods = w?.trend_periods || []
|
||||
|
||||
const bfCd = (cal?.series || []).map((s) => ({
|
||||
date: fmtDate(s.date),
|
||||
bf: s.body_fat_pct,
|
||||
}))
|
||||
|
||||
const propChartData = (circ?.proportion_series || []).map((p) => ({
|
||||
date: fmtDate(p.date),
|
||||
vTaper: p.v_taper_cm,
|
||||
vTaper_avg: p.v_taper_cm_avg,
|
||||
belly: p.belly_cm,
|
||||
}))
|
||||
const showBellyOnProp = propChartData.some((d) => d.belly != null && d.belly !== undefined)
|
||||
|
||||
const idxSeriesRaw = circ?.index_series || []
|
||||
const idxSeries = idxSeriesRaw.map((row) => ({ ...row, date: fmtDate(row.date) }))
|
||||
const idxOk = circ?.index_usable
|
||||
|
||||
const cirCd = (circ?.fallback_multiline || []).map((r) => ({
|
||||
date: fmtDate(r.date),
|
||||
waist: r.waist,
|
||||
hip: r.hip,
|
||||
belly: r.belly,
|
||||
}))
|
||||
|
||||
const goalW = viz?.profile?.goal_weight_kg ?? profile?.goal_weight
|
||||
const goalBf = viz?.profile?.goal_bf_pct ?? profile?.goal_bf_pct
|
||||
|
||||
const rules = (viz?.interpretation_tiles || []).map((t) => ({
|
||||
category: t.category,
|
||||
icon: t.icon,
|
||||
status: t.status,
|
||||
title: t.title,
|
||||
detail: t.detail,
|
||||
value: t.value,
|
||||
related_placeholder_keys: t.related_placeholder_keys,
|
||||
}))
|
||||
|
||||
const kpiTiles = buildBodyKpiTiles({
|
||||
summary,
|
||||
rules,
|
||||
trendPeriods,
|
||||
minW,
|
||||
maxW,
|
||||
avgAll,
|
||||
dataPoints: w?.data_points,
|
||||
weightTrendKpi: w?.trend_kpi,
|
||||
goalW,
|
||||
})
|
||||
|
||||
const hasAnyData =
|
||||
(w?.data_points > 0) ||
|
||||
(cal?.data_points > 0) ||
|
||||
(cirCd.length > 0)
|
||||
|
||||
if (vizLoading && !viz) {
|
||||
return (
|
||||
<div>
|
||||
{!embedded && <SectionHeader title="⚖️ Körper" />}
|
||||
<div className="empty-state">
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (vizError) {
|
||||
return (
|
||||
<div>
|
||||
{!embedded && <SectionHeader title="⚖️ Körper" />}
|
||||
<div style={{ padding: 16, color: 'var(--danger)' }}>{vizError}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!hasAnyData) {
|
||||
return (
|
||||
<div>
|
||||
{!embedded && <SectionHeader title="⚖️ Körper" />}
|
||||
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
|
||||
<EmptySection text="Noch keine Körperdaten im gewählten Zeitraum." to="/weight" toLabel="Gewicht eintragen" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!embedded && <SectionHeader title="⚖️ Körper" lastUpdated={viz?.last_updated} />}
|
||||
{embedded && viz?.last_updated && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10 }}>
|
||||
Stand {dayjs(viz.last_updated).format('DD.MM.YY')}
|
||||
</div>
|
||||
)}
|
||||
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
|
||||
|
||||
<BodyGoalsStrip grouped={groupedGoals} />
|
||||
|
||||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
||||
Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: <strong>Verlauf → Fitness</strong>.
|
||||
</p>
|
||||
|
||||
{viz?.meta?.layer_2a_alignment && (
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>
|
||||
{viz.meta.layer_2a_alignment}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<KpiTilesOverview tiles={kpiTiles} />
|
||||
|
||||
{vizLoading && <div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>Aktualisiere…</div>}
|
||||
|
||||
{hasWeight && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>
|
||||
Gewicht · {w?.data_points || 0} Einträge
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => { window.location.href = '/weight' }}>
|
||||
Daten <ChevronRight size={10} />
|
||||
</button>
|
||||
</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 != null && (
|
||||
<ReferenceLine y={avgAll} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1} label={{ value: `Ø ${avgAll}`, fontSize: 9, fill: 'var(--text3)', position: 'right' }} />
|
||||
)}
|
||||
{goalW != null && (
|
||||
<ReferenceLine y={goalW} stroke="var(--accent)" strokeDasharray="5 3" strokeWidth={1.5} label={{ value: `Ziel ${goalW}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', stroke: '#378ADD', strokeWidth: 1 }} activeDot={{ r: 5 }} 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)' }}>
|
||||
<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' }} />Ø Gesamt</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bfCd.length >= 2 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Körperfett (Caliper)</div>
|
||||
<NavToCaliper />
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={170}>
|
||||
<LineChart data={bfCd} 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} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v) => [`${v}%`, 'KF%']} />
|
||||
{goalBf != null && <ReferenceLine y={goalBf} stroke="#D85A30" strokeDasharray="5 3" strokeWidth={1.5} label={{ value: `Ziel ${goalBf}%`, fontSize: 9, fill: '#D85A30', position: 'right' }} />}
|
||||
<Line type="monotone" dataKey="bf" stroke="#D85A30" strokeWidth={2.5} dot={{ r: 4, fill: '#D85A30' }} name="bf" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 6, lineHeight: 1.4 }}>Magermasse aus Gewicht und KF% — zweite Kurve entfällt.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{propChartData.length >= 2 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 8 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Silhouette & Proportion</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginTop: 4 }}>
|
||||
<strong>V-Taper (Brust − Taille)</strong> in cm.
|
||||
{showBellyOnProp && (
|
||||
<>
|
||||
<strong> Bauch</strong> (rechte Achse).
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<NavToCircum />
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<ComposedChart data={propChartData} margin={{ top: 4, right: showBellyOnProp ? 4 : 8, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<YAxis yAxisId="taper" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
{showBellyOnProp && <YAxis yAxisId="belly" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />}
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
|
||||
formatter={(v, name) => {
|
||||
if (name === 'vTaper' || name === 'vTaper_avg') return [`${v} cm`, name === 'vTaper_avg' ? 'Ø V-Taper (3 Messungen)' : 'Brust − Taille']
|
||||
if (name === 'belly') return [`${v} cm`, 'Bauch']
|
||||
return [v, name]
|
||||
}}
|
||||
/>
|
||||
<Line yAxisId="taper" type="monotone" dataKey="vTaper" stroke="#1D9E75" strokeWidth={2} dot={{ r: 3 }} name="vTaper" />
|
||||
<Line yAxisId="taper" type="monotone" dataKey="vTaper_avg" stroke="#1D9E75" strokeWidth={1.5} strokeDasharray="5 4" dot={false} name="vTaper_avg" />
|
||||
{showBellyOnProp && <Line yAxisId="belly" type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{ r: 3 }} connectNulls name="belly" />}
|
||||
</ComposedChart>
|
||||
</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: '#1D9E75', verticalAlign: 'middle', marginRight: 3 }} />Brust − Taille</span>
|
||||
<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 4" /></svg></span>gleitender Mittelwert</span>
|
||||
{showBellyOnProp && <span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#D4537E', verticalAlign: 'middle', marginRight: 3 }} />Bauch (cm)</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{idxOk && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Relative Entwicklung der Umfänge</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4, lineHeight: 1.4 }}>Index 100 = erste Messung im Zeitraum.</div>
|
||||
</div>
|
||||
<NavToCircum />
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<LineChart data={idxSeries} 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} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
<ReferenceLine y={100} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1} />
|
||||
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} Index`, n === 'chest_idx' ? 'Brust' : n === 'waist_idx' ? 'Taille' : 'Bauch']} />
|
||||
{idxSeries.some((d) => d.chest_idx != null) && <Line type="monotone" dataKey="chest_idx" stroke="#1D9E75" strokeWidth={2} dot={{ r: 2 }} connectNulls name="chest_idx" />}
|
||||
{idxSeries.some((d) => d.waist_idx != null) && <Line type="monotone" dataKey="waist_idx" stroke="#EF9F27" strokeWidth={2} dot={{ r: 2 }} connectNulls name="waist_idx" />}
|
||||
{idxSeries.some((d) => d.belly_idx != null) && <Line type="monotone" dataKey="belly_idx" stroke="#D4537E" strokeWidth={2} dot={{ r: 2 }} connectNulls name="belly_idx" />}
|
||||
</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: '#1D9E75', verticalAlign: 'middle', marginRight: 3 }} />Brust</span>
|
||||
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#EF9F27', verticalAlign: 'middle', marginRight: 3 }} />Taille</span>
|
||||
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#D4537E', verticalAlign: 'middle', marginRight: 3 }} />Bauch</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{propChartData.length < 2 && cirCd.length >= 2 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Umfänge (Taille / Hüfte / Bauch)</div>
|
||||
<NavToCircum />
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>Mit Brust- und Taillenumfang erscheint die Proportionen-Ansicht oben.</div>
|
||||
<ResponsiveContainer width="100%" height={150}>
|
||||
<LineChart data={cirCd} 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} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} cm`, n]} />
|
||||
<Line type="monotone" dataKey="waist" stroke="#EF9F27" strokeWidth={2} dot={{ r: 3 }} name="Taille" />
|
||||
<Line type="monotone" dataKey="hip" stroke="#7F77DD" strokeWidth={2} dot={{ r: 3 }} name="Hüfte" />
|
||||
{cirCd.some((d) => d.belly) && <Line type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{ r: 3 }} name="Bauch" />}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
frontend/src/components/history/historyPageChrome.jsx
Normal file
87
frontend/src/components/history/historyPageChrome.jsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export function NavToCaliper() {
|
||||
const nav = useNavigate()
|
||||
return (
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/caliper')}>
|
||||
Caliper-Daten <ChevronRight size={10} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavToCircum() {
|
||||
const nav = useNavigate()
|
||||
return (
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/circum')}>
|
||||
Umfang-Daten <ChevronRight size={10} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptySection({ text, to, toLabel }) {
|
||||
const nav = useNavigate()
|
||||
return (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text3)', fontSize: 13 }}>
|
||||
<div style={{ marginBottom: 12 }}>{text}</div>
|
||||
{to && (
|
||||
<button type="button" className="btn btn-primary" onClick={() => nav(to)}>
|
||||
{toLabel || 'Daten erfassen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SectionHeader({ title, to, toLabel, lastUpdated }) {
|
||||
const nav = useNavigate()
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h2 style={{ fontSize: 17, fontWeight: 700, margin: 0 }}>{title}</h2>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{lastUpdated && <span style={{ fontSize: 11, color: 'var(--text3)' }}>{dayjs(lastUpdated).format('DD.MM.YY')}</span>}
|
||||
{to && (
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '5px 10px' }} onClick={() => nav(to)}>
|
||||
{toLabel || 'Daten'} <ChevronRight size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PeriodSelector({ value, onChange }) {
|
||||
const opts = [
|
||||
{ v: 30, l: '30 Tage' },
|
||||
{ v: 90, l: '90 Tage' },
|
||||
{ v: 180, l: '6 Monate' },
|
||||
{ v: 365, l: '1 Jahr' },
|
||||
{ v: 9999, l: 'Alles' },
|
||||
]
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 12 }}>
|
||||
{opts.map((o) => (
|
||||
<button
|
||||
key={o.v}
|
||||
type="button"
|
||||
onClick={() => onChange(o.v)}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
borderRadius: 12,
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
border: '1.5px solid',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font)',
|
||||
background: value === o.v ? 'var(--accent)' : 'transparent',
|
||||
borderColor: value === o.v ? 'var(--accent)' : 'var(--border2)',
|
||||
color: value === o.v ? 'white' : 'var(--text2)',
|
||||
}}
|
||||
>
|
||||
{o.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSys
|
|||
/** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */
|
||||
const CHART_DAYS_WIDGET_IDS = new Set([
|
||||
'body_overview',
|
||||
'body_history_viz',
|
||||
'activity_overview',
|
||||
'nutrition_detail_charts',
|
||||
'recovery_charts_panel',
|
||||
|
|
|
|||
|
|
@ -17,49 +17,14 @@ import FitnessDashboardOverview from '../components/FitnessDashboardOverview'
|
|||
import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts'
|
||||
import RecoveryDashboardOverview from '../components/RecoveryDashboardOverview'
|
||||
import KpiTilesOverview from '../components/KpiTilesOverview'
|
||||
import BodyHistoryVizSection from '../components/history/BodyHistoryVizSection'
|
||||
import { EmptySection, NavToCaliper, NavToCircum, PeriodSelector, SectionHeader } from '../components/history/historyPageChrome'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/de'
|
||||
dayjs.locale('de')
|
||||
|
||||
const fmtDate = d => dayjs(d).format('DD.MM')
|
||||
|
||||
function NavToCaliper() {
|
||||
const nav = useNavigate()
|
||||
return <button className="btn btn-secondary" style={{fontSize:10,padding:'2px 8px'}}
|
||||
onClick={()=>nav('/caliper')}>Caliper-Daten <ChevronRight size={10}/></button>
|
||||
}
|
||||
function NavToCircum() {
|
||||
const nav = useNavigate()
|
||||
return <button className="btn btn-secondary" style={{fontSize:10,padding:'2px 8px'}}
|
||||
onClick={()=>nav('/circum')}>Umfang-Daten <ChevronRight size={10}/></button>
|
||||
}
|
||||
function EmptySection({ text, to, toLabel }) {
|
||||
const nav = useNavigate()
|
||||
return (
|
||||
<div style={{padding:32,textAlign:'center',color:'var(--text3)',fontSize:13}}>
|
||||
<div style={{marginBottom:12}}>{text}</div>
|
||||
{to && <button className="btn btn-primary" onClick={()=>nav(to)}>{toLabel||'Daten erfassen'}</button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ title, to, toLabel, lastUpdated }) {
|
||||
const nav = useNavigate()
|
||||
return (
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:14}}>
|
||||
<h2 style={{fontSize:17,fontWeight:700,margin:0}}>{title}</h2>
|
||||
<div style={{display:'flex',gap:8,alignItems:'center'}}>
|
||||
{lastUpdated && <span style={{fontSize:11,color:'var(--text3)'}}>{dayjs(lastUpdated).format('DD.MM.YY')}</span>}
|
||||
{to && (
|
||||
<button className="btn btn-secondary" style={{fontSize:12,padding:'5px 10px'}} onClick={()=>nav(to)}>
|
||||
{toLabel||'Daten'} <ChevronRight size={12}/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RuleCard({ item }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const color = getStatusColor(item.status)
|
||||
|
|
@ -81,142 +46,6 @@ function RuleCard({ item }) {
|
|||
)
|
||||
}
|
||||
|
||||
function verdictShort(status) {
|
||||
if (status === 'good') return 'Gut'
|
||||
if (status === 'warn') return 'Hinweis'
|
||||
return 'Achtung'
|
||||
}
|
||||
|
||||
/** Eine KPI-Kachelzeile aus Summary + Interpretationsregeln — Trend-Urteil aus Bundle ``weight.trend_kpi`` (Layer 1). */
|
||||
function buildBodyKpiTiles({
|
||||
summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints, weightTrendKpi, goalW,
|
||||
}) {
|
||||
const tiles = []
|
||||
|
||||
if (summary.weight_kg != null) {
|
||||
const wt = weightTrendKpi || { verdict: 'Stabil', status: 'good' }
|
||||
const trendBits = trendPeriods.length
|
||||
? trendPeriods.map(t => `${t.label} ${t.diff_kg > 0 ? '+' : ''}${t.diff_kg} kg`).join(' · ')
|
||||
: ''
|
||||
const hoverBody = [
|
||||
'Gewicht im gewählten Zeitraum (letzter Messwert).',
|
||||
avgAll != null ? `Durchschnitt: ${avgAll} kg` : null,
|
||||
minW != null && maxW != null ? `Min. / Max.: ${minW} – ${maxW} kg` : null,
|
||||
trendBits ? `Änderung: ${trendBits}` : null,
|
||||
goalW != null ? `Profil-Zielgewicht: ${goalW} kg` : null,
|
||||
].filter(Boolean).join('\n')
|
||||
|
||||
tiles.push({
|
||||
key: 'weight',
|
||||
category: 'Gewicht',
|
||||
icon: '⚖️',
|
||||
value: `${summary.weight_kg} kg`,
|
||||
sublabel: dataPoints ? `${dataPoints} Messwerte` : '',
|
||||
verdict: wt.verdict,
|
||||
status: wt.status,
|
||||
hoverTop: 'Gewicht',
|
||||
hoverBody,
|
||||
keys: ['weight_aktuell', 'weight_trend'],
|
||||
})
|
||||
}
|
||||
|
||||
const kfRule = rules.find(r => r.category === 'Körperfett')
|
||||
if (summary.body_fat_pct != null) {
|
||||
tiles.push({
|
||||
key: 'bf',
|
||||
category: 'Körperfett',
|
||||
icon: '🫧',
|
||||
value: `${summary.body_fat_pct}%`,
|
||||
valueColor: kfRule ? getStatusColor(kfRule.status) : undefined,
|
||||
sublabel: summary.bf_category_label || '',
|
||||
verdict: verdictShort(kfRule?.status || 'good'),
|
||||
status: kfRule?.status || 'good',
|
||||
hoverTop: kfRule?.title || 'Körperfettanteil',
|
||||
hoverBody: [kfRule?.detail, kfRule?.related_placeholder_keys?.length ? `Registry: ${kfRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
}
|
||||
|
||||
const mmRule = rules.find(r => r.category === 'Muskelmasse')
|
||||
if (summary.lean_mass_kg != null || summary.ffmi != null) {
|
||||
const valParts = []
|
||||
if (summary.lean_mass_kg != null) valParts.push(`${summary.lean_mass_kg} kg`)
|
||||
if (summary.ffmi != null) valParts.push(`FFMI ${summary.ffmi}`)
|
||||
tiles.push({
|
||||
key: 'lean_ffmi',
|
||||
category: 'Magermasse',
|
||||
icon: '💪',
|
||||
value: valParts.join(' · ') || '—',
|
||||
sublabel: 'Lean / FFMI',
|
||||
verdict: mmRule ? verdictShort(mmRule.status) : '—',
|
||||
status: mmRule?.status || 'good',
|
||||
hoverTop: mmRule?.title || 'Muskelmasse',
|
||||
hoverBody: [mmRule?.detail, mmRule?.related_placeholder_keys?.length ? `Registry: ${mmRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
}
|
||||
|
||||
const bmiRule = rules.find(r => r.category === 'BMI')
|
||||
if (bmiRule) {
|
||||
tiles.push({
|
||||
key: 'bmi',
|
||||
category: 'BMI',
|
||||
icon: '📋',
|
||||
value: bmiRule.value || '—',
|
||||
sublabel: 'Body-Mass-Index',
|
||||
verdict: verdictShort(bmiRule.status),
|
||||
status: bmiRule.status,
|
||||
hoverTop: bmiRule.title,
|
||||
hoverBody: [bmiRule.detail, bmiRule.related_placeholder_keys?.length ? `Registry: ${bmiRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
}
|
||||
|
||||
const whrRule = rules.find(r => r.category === 'Fettverteilung')
|
||||
if (summary.whr != null && whrRule) {
|
||||
tiles.push({
|
||||
key: 'whr',
|
||||
category: 'Fettverteilung',
|
||||
icon: '📐',
|
||||
value: String(summary.whr),
|
||||
sublabel: 'WHR · Taille ÷ Hüfte',
|
||||
verdict: verdictShort(whrRule.status),
|
||||
status: whrRule.status,
|
||||
hoverTop: whrRule.title || 'Waist-Hip-Ratio',
|
||||
hoverBody: [whrRule.detail, whrRule.related_placeholder_keys?.length ? `Registry: ${whrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
}
|
||||
|
||||
const whtrRule = rules.find(r => r.category === 'Taille/Größe')
|
||||
if (summary.whtr != null && whtrRule) {
|
||||
tiles.push({
|
||||
key: 'whtr',
|
||||
category: 'Taille/Größe',
|
||||
icon: '📏',
|
||||
value: String(summary.whtr),
|
||||
sublabel: 'WHtR · Taille ÷ Größe',
|
||||
verdict: verdictShort(whtrRule.status),
|
||||
status: whtrRule.status,
|
||||
hoverTop: whtrRule.title || 'Waist-to-Height-Ratio',
|
||||
hoverBody: [whtrRule.detail, whtrRule.related_placeholder_keys?.length ? `Registry: ${whtrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
}
|
||||
|
||||
const lastRule = rules.find(r => r.category.startsWith('Seit letzter'))
|
||||
if (lastRule) {
|
||||
tiles.push({
|
||||
key: 'delta',
|
||||
category: 'Messvergleich',
|
||||
icon: '📊',
|
||||
value: lastRule.value || '—',
|
||||
sublabel: 'seit Vorperiode',
|
||||
verdict: verdictShort(lastRule.status),
|
||||
status: lastRule.status,
|
||||
hoverTop: lastRule.title,
|
||||
hoverBody: [lastRule.detail, lastRule.related_placeholder_keys?.length ? `Registry: ${lastRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
}
|
||||
|
||||
return tiles
|
||||
}
|
||||
|
||||
function NutritionGoalsStrip({ grouped }) {
|
||||
const nav = useNavigate()
|
||||
const goals = (grouped?.nutrition || []).filter(g => g.status === 'active').slice(0, 4)
|
||||
|
|
@ -262,51 +91,6 @@ function NutritionGoalsStrip({ grouped }) {
|
|||
)
|
||||
}
|
||||
|
||||
function BodyGoalsStrip({ grouped }) {
|
||||
const nav = useNavigate()
|
||||
const goals = (grouped?.body || []).filter(g => g.status === 'active').slice(0, 4)
|
||||
if (!goals.length) return null
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Körperbezogene Ziele</div>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/goals')}>
|
||||
Ziele <ChevronRight size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{goals.map(g => (
|
||||
<div
|
||||
key={g.id}
|
||||
style={{
|
||||
flex: '1 1 140px',
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 10px',
|
||||
borderTop: `3px solid ${g.is_primary ? 'var(--accent)' : 'var(--border2)'}`,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: 11, fontWeight: 600, color: 'var(--text2)',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>{g.name || g.label_de || g.goal_type}</div>
|
||||
<div style={{ marginTop: 4, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
width: `${Math.min(100, Math.max(0, g.progress_pct ?? 0))}%`,
|
||||
height: '100%',
|
||||
background: 'var(--accent)',
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
|
||||
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InsightBox({ insights, slugs, onRequest, loading }) {
|
||||
const [expanded, setExpanded] = useState(null)
|
||||
const relevant = insights?.filter(i=>slugs.includes(i.scope))||[]
|
||||
|
|
@ -354,331 +138,6 @@ function InsightBox({ insights, slugs, onRequest, loading }) {
|
|||
)
|
||||
}
|
||||
|
||||
// ── Period selector ───────────────────────────────────────────────────────────
|
||||
function PeriodSelector({ value, onChange }) {
|
||||
const opts = [{v:30,l:'30 Tage'},{v:90,l:'90 Tage'},{v:180,l:'6 Monate'},{v:365,l:'1 Jahr'},{v:9999,l:'Alles'}]
|
||||
return (
|
||||
<div style={{display:'flex',gap:4,marginBottom:12}}>
|
||||
{opts.map(o=>(
|
||||
<button key={o.v} onClick={()=>onChange(o.v)}
|
||||
style={{padding:'4px 10px',borderRadius:12,fontSize:11,fontWeight:500,border:'1.5px solid',
|
||||
cursor:'pointer',fontFamily:'var(--font)',
|
||||
background:value===o.v?'var(--accent)':'transparent',
|
||||
borderColor:value===o.v?'var(--accent)':'var(--border2)',
|
||||
color:value===o.v?'white':'var(--text2)'}}>
|
||||
{o.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Body Section — Layer 2b: Daten nur aus GET /api/charts/body-history-viz ──
|
||||
function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
||||
const [period, setPeriod] = useState(90)
|
||||
const [groupedGoals, setGroupedGoals] = useState(null)
|
||||
const [viz, setViz] = useState(null)
|
||||
const [vizLoading, setVizLoading] = useState(true)
|
||||
const [vizError, setVizError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
api.listGoalsGrouped()
|
||||
.then(g => { if (!cancelled) setGroupedGoals(g) })
|
||||
.catch(() => { if (!cancelled) setGroupedGoals({}) })
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setVizLoading(true)
|
||||
setVizError(null)
|
||||
api.getBodyHistoryViz(period)
|
||||
.then(data => {
|
||||
if (!cancelled) {
|
||||
setViz(data)
|
||||
setVizLoading(false)
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
if (!cancelled) {
|
||||
setVizError(e.message || 'Laden fehlgeschlagen')
|
||||
setVizLoading(false)
|
||||
}
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [period])
|
||||
|
||||
const w = viz?.weight
|
||||
const cal = viz?.caliper
|
||||
const circ = viz?.circumference
|
||||
const summary = viz?.summary || {}
|
||||
|
||||
const wCd = (w?.series || []).map(row => ({
|
||||
date: fmtDate(row.date),
|
||||
weight: row.weight,
|
||||
avg7: row.avg7,
|
||||
avg14: row.avg14,
|
||||
}))
|
||||
const hasWeight = (w?.data_points || 0) >= 2
|
||||
const avgAll = w?.overall_avg_kg
|
||||
const minW = w?.min_kg
|
||||
const maxW = w?.max_kg
|
||||
const trendPeriods = w?.trend_periods || []
|
||||
|
||||
const bfCd = (cal?.series || []).map(s => ({
|
||||
date: fmtDate(s.date),
|
||||
bf: s.body_fat_pct,
|
||||
}))
|
||||
|
||||
const propChartData = (circ?.proportion_series || []).map(p => ({
|
||||
date: fmtDate(p.date),
|
||||
vTaper: p.v_taper_cm,
|
||||
vTaper_avg: p.v_taper_cm_avg,
|
||||
belly: p.belly_cm,
|
||||
}))
|
||||
const showBellyOnProp = propChartData.some(d => d.belly != null && d.belly !== undefined)
|
||||
|
||||
const idxSeriesRaw = circ?.index_series || []
|
||||
const idxSeries = idxSeriesRaw.map(row => ({ ...row, date: fmtDate(row.date) }))
|
||||
const idxOk = circ?.index_usable
|
||||
|
||||
const cirCd = (circ?.fallback_multiline || []).map(r => ({
|
||||
date: fmtDate(r.date),
|
||||
waist: r.waist,
|
||||
hip: r.hip,
|
||||
belly: r.belly,
|
||||
}))
|
||||
|
||||
const goalW = viz?.profile?.goal_weight_kg ?? profile?.goal_weight
|
||||
const goalBf = viz?.profile?.goal_bf_pct ?? profile?.goal_bf_pct
|
||||
|
||||
const rules = (viz?.interpretation_tiles || []).map(t => ({
|
||||
category: t.category,
|
||||
icon: t.icon,
|
||||
status: t.status,
|
||||
title: t.title,
|
||||
detail: t.detail,
|
||||
value: t.value,
|
||||
related_placeholder_keys: t.related_placeholder_keys,
|
||||
}))
|
||||
|
||||
const kpiTiles = buildBodyKpiTiles({
|
||||
summary,
|
||||
rules,
|
||||
trendPeriods,
|
||||
minW,
|
||||
maxW,
|
||||
avgAll,
|
||||
dataPoints: w?.data_points,
|
||||
weightTrendKpi: w?.trend_kpi,
|
||||
goalW,
|
||||
})
|
||||
|
||||
const hasAnyData =
|
||||
(w?.data_points > 0) ||
|
||||
(cal?.data_points > 0) ||
|
||||
(cirCd.length > 0)
|
||||
|
||||
if (vizLoading && !viz) {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="⚖️ Körper" />
|
||||
<div className="empty-state"><div className="spinner" /></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (vizError) {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="⚖️ Körper" />
|
||||
<div style={{ padding: 16, color: 'var(--danger)' }}>{vizError}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!hasAnyData) {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="⚖️ Körper" />
|
||||
<PeriodSelector value={period} onChange={setPeriod} />
|
||||
<EmptySection text="Noch keine Körperdaten im gewählten Zeitraum." to="/weight" toLabel="Gewicht eintragen" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="⚖️ Körper" lastUpdated={viz?.last_updated} />
|
||||
<PeriodSelector value={period} onChange={setPeriod} />
|
||||
|
||||
<BodyGoalsStrip grouped={groupedGoals} />
|
||||
|
||||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
||||
Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: <strong>Verlauf → Fitness</strong>.
|
||||
</p>
|
||||
|
||||
{viz?.meta?.layer_2a_alignment && (
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>
|
||||
{viz.meta.layer_2a_alignment}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<KpiTilesOverview tiles={kpiTiles} />
|
||||
|
||||
{vizLoading && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>Aktualisiere…</div>
|
||||
)}
|
||||
|
||||
{hasWeight && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>
|
||||
Gewicht · {w?.data_points || 0} Einträge
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => { window.location.href = '/weight' }}>
|
||||
Daten <ChevronRight size={10} />
|
||||
</button>
|
||||
</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 != null && (
|
||||
<ReferenceLine y={avgAll} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1} label={{ value: `Ø ${avgAll}`, fontSize: 9, fill: 'var(--text3)', position: 'right' }} />
|
||||
)}
|
||||
{goalW != null && (
|
||||
<ReferenceLine y={goalW} stroke="var(--accent)" strokeDasharray="5 3" strokeWidth={1.5} label={{ value: `Ziel ${goalW}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', stroke: '#378ADD', strokeWidth: 1 }} activeDot={{ r: 5 }} 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)' }}>
|
||||
<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' }} />Ø Gesamt</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bfCd.length >= 2 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Körperfett (Caliper)</div>
|
||||
<NavToCaliper />
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={170}>
|
||||
<LineChart data={bfCd} 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} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={v => [`${v}%`, 'KF%']} />
|
||||
{goalBf != null && <ReferenceLine y={goalBf} stroke="#D85A30" strokeDasharray="5 3" strokeWidth={1.5} label={{ value: `Ziel ${goalBf}%`, fontSize: 9, fill: '#D85A30', position: 'right' }} />}
|
||||
<Line type="monotone" dataKey="bf" stroke="#D85A30" strokeWidth={2.5} dot={{ r: 4, fill: '#D85A30' }} name="bf" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 6, lineHeight: 1.4 }}>Magermasse aus Gewicht und KF% — zweite Kurve entfällt.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{propChartData.length >= 2 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 8 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Silhouette & Proportion</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginTop: 4 }}>
|
||||
<strong>V-Taper (Brust − Taille)</strong> in cm.
|
||||
{showBellyOnProp && <><strong> Bauch</strong> (rechte Achse).</>}
|
||||
</div>
|
||||
</div>
|
||||
<NavToCircum />
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<ComposedChart data={propChartData} margin={{ top: 4, right: showBellyOnProp ? 4 : 8, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<YAxis yAxisId="taper" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
{showBellyOnProp && <YAxis yAxisId="belly" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />}
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
|
||||
formatter={(v, name) => {
|
||||
if (name === 'vTaper' || name === 'vTaper_avg') return [`${v} cm`, name === 'vTaper_avg' ? 'Ø V-Taper (3 Messungen)' : 'Brust − Taille']
|
||||
if (name === 'belly') return [`${v} cm`, 'Bauch']
|
||||
return [v, name]
|
||||
}}
|
||||
/>
|
||||
<Line yAxisId="taper" type="monotone" dataKey="vTaper" stroke="#1D9E75" strokeWidth={2} dot={{ r: 3 }} name="vTaper" />
|
||||
<Line yAxisId="taper" type="monotone" dataKey="vTaper_avg" stroke="#1D9E75" strokeWidth={1.5} strokeDasharray="5 4" dot={false} name="vTaper_avg" />
|
||||
{showBellyOnProp && <Line yAxisId="belly" type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{ r: 3 }} connectNulls name="belly" />}
|
||||
</ComposedChart>
|
||||
</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: '#1D9E75', verticalAlign: 'middle', marginRight: 3 }} />Brust − Taille</span>
|
||||
<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 4" /></svg></span>gleitender Mittelwert</span>
|
||||
{showBellyOnProp && <span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#D4537E', verticalAlign: 'middle', marginRight: 3 }} />Bauch (cm)</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{idxOk && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Relative Entwicklung der Umfänge</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4, lineHeight: 1.4 }}>Index 100 = erste Messung im Zeitraum.</div>
|
||||
</div>
|
||||
<NavToCircum />
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<LineChart data={idxSeries} 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} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
<ReferenceLine y={100} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1} />
|
||||
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} Index`, n === 'chest_idx' ? 'Brust' : n === 'waist_idx' ? 'Taille' : 'Bauch']} />
|
||||
{idxSeries.some(d => d.chest_idx != null) && <Line type="monotone" dataKey="chest_idx" stroke="#1D9E75" strokeWidth={2} dot={{ r: 2 }} connectNulls name="chest_idx" />}
|
||||
{idxSeries.some(d => d.waist_idx != null) && <Line type="monotone" dataKey="waist_idx" stroke="#EF9F27" strokeWidth={2} dot={{ r: 2 }} connectNulls name="waist_idx" />}
|
||||
{idxSeries.some(d => d.belly_idx != null) && <Line type="monotone" dataKey="belly_idx" stroke="#D4537E" strokeWidth={2} dot={{ r: 2 }} connectNulls name="belly_idx" />}
|
||||
</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: '#1D9E75', verticalAlign: 'middle', marginRight: 3 }} />Brust</span>
|
||||
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#EF9F27', verticalAlign: 'middle', marginRight: 3 }} />Taille</span>
|
||||
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#D4537E', verticalAlign: 'middle', marginRight: 3 }} />Bauch</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{propChartData.length < 2 && cirCd.length >= 2 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Umfänge (Taille / Hüfte / Bauch)</div>
|
||||
<NavToCircum />
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>Mit Brust- und Taillenumfang erscheint die Proportionen-Ansicht oben.</div>
|
||||
<ResponsiveContainer width="100%" height={150}>
|
||||
<LineChart data={cirCd} 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} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} cm`, n]} />
|
||||
<Line type="monotone" dataKey="waist" stroke="#EF9F27" strokeWidth={2} dot={{ r: 3 }} name="Taille" />
|
||||
<Line type="monotone" dataKey="hip" stroke="#7F77DD" strokeWidth={2} dot={{ r: 3 }} name="Hüfte" />
|
||||
{cirCd.some(d => d.belly) && <Line type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{ r: 3 }} name="Bauch" />}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InsightBox insights={insights} slugs={filterActiveSlugs(['pipeline', 'koerper', 'gesundheit', 'ziele'])} onRequest={onRequest} loading={loadingSlug} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** TDEE-Linie muss in der kcal-Y-Domain liegen (sonst unsichtbar trotz Legende). */
|
||||
function kcalVsWeightKcalDomain(points, tdeeRef) {
|
||||
const vals = (points || [])
|
||||
|
|
@ -1844,7 +1303,19 @@ export default function History() {
|
|||
</nav>
|
||||
<div className="history-content">
|
||||
{tab==='overview' && <HistoryOverviewSection {...sp}/>}
|
||||
{tab==='body' && <BodySection profile={profile} {...sp}/>}
|
||||
{tab==='body' && (
|
||||
<BodyHistoryVizSection
|
||||
profile={profile}
|
||||
footer={(
|
||||
<InsightBox
|
||||
insights={insights}
|
||||
slugs={filterActiveSlugs(['pipeline', 'koerper', 'gesundheit', 'ziele'])}
|
||||
onRequest={requestInsight}
|
||||
loading={loadingSlug}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{tab==='nutrition' && <NutritionSection {...sp}/>}
|
||||
{tab==='activity' && <ActivitySection activityLastDate={activityLastDate} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
|
||||
{tab==='photos' && <PhotoGrid/>}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import ProfileGoalsProgressWidget from '../components/dashboard-widgets/ProfileG
|
|||
import TrendKcalWeightWidget from '../components/dashboard-widgets/TrendKcalWeightWidget'
|
||||
import NutritionActivitySummaryWidget from '../components/dashboard-widgets/NutritionActivitySummaryWidget'
|
||||
import NutritionDetailChartsWidget from '../components/dashboard-widgets/NutritionDetailChartsWidget'
|
||||
import BodyHistoryVizWidget from '../components/dashboard-widgets/BodyHistoryVizWidget'
|
||||
import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget'
|
||||
import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget'
|
||||
import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget'
|
||||
|
|
@ -57,6 +58,14 @@ export function ensurePilotLabWidgetsRegistered() {
|
|||
chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days),
|
||||
}),
|
||||
})
|
||||
registerDashboardWidget({
|
||||
id: 'body_history_viz',
|
||||
Component: BodyHistoryVizWidget,
|
||||
mapProps: (ctx) => ({
|
||||
refreshTick: ctx.refreshTick,
|
||||
chartDays: ctx.layoutEntry?.config?.chart_days,
|
||||
}),
|
||||
})
|
||||
registerDashboardWidget({
|
||||
id: 'activity_overview',
|
||||
Component: PilotActivitySection,
|
||||
|
|
|
|||
BIN
shinkan-dev-screenshot.png
Normal file
BIN
shinkan-dev-screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
40
test-shinkan.js
Normal file
40
test-shinkan.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
|
||||
console.log('=== Testing Shinkan Frontend ===\n');
|
||||
|
||||
try {
|
||||
await page.goto('http://192.168.2.49:3098', { waitUntil: 'networkidle', timeout: 10000 });
|
||||
|
||||
const title = await page.title();
|
||||
console.log('📄 Title:', title);
|
||||
|
||||
const h1 = await page.textContent('h1').catch(() => null);
|
||||
console.log('🥋 Heading:', h1);
|
||||
|
||||
const bodyText = await page.evaluate(() => document.body.innerText);
|
||||
console.log('\n📝 Page Content:\n' + '='.repeat(60));
|
||||
console.log(bodyText);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const buttons = await page.locator('button').count();
|
||||
const forms = await page.locator('form').count();
|
||||
const inputs = await page.locator('input').count();
|
||||
|
||||
console.log('\n🔍 Elements Found:');
|
||||
console.log(' - Buttons:', buttons);
|
||||
console.log(' - Forms:', forms);
|
||||
console.log(' - Inputs:', inputs);
|
||||
|
||||
await page.screenshot({ path: 'shinkan-dev-screenshot.png', fullPage: true });
|
||||
console.log('\n📸 Screenshot: shinkan-dev-screenshot.png');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error.message);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
Loading…
Reference in New Issue
Block a user