feat: update app version to 0.9s and enhance body history visualization
- Bumped application version to 0.9s and updated changelog with new features. - Introduced `_weight_trend_kpi` function to analyze weight trends and provide verdicts based on historical data. - Updated `get_body_history_viz_bundle` to include the new weight trend KPI, improving insights on weight changes. - Refactored the History component to utilize the new trend KPI, enhancing user experience with clearer weight trend interpretations.
This commit is contained in:
parent
ba2bd3a4a2
commit
da1e0410cc
|
|
@ -48,6 +48,31 @@ def _iso(d: Any) -> Optional[str]:
|
||||||
return str(d)[:10]
|
return str(d)[:10]
|
||||||
|
|
||||||
|
|
||||||
|
def _weight_trend_kpi(trend_periods: List[Dict[str, Any]]) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Kurzurteil Gewichtstrend (Schwelle ±0,25 kg, Priorität 90T → 30T → erste Periode).
|
||||||
|
Eine Quelle mit dem Verlauf-Bundle — kein paralleles Frontend-Routing mehr.
|
||||||
|
"""
|
||||||
|
if not trend_periods:
|
||||||
|
return {"verdict": "Stabil", "status": "good"}
|
||||||
|
t90 = next((t for t in trend_periods if t.get("label") == "90T"), None)
|
||||||
|
t30 = next((t for t in trend_periods if t.get("label") == "30T"), None)
|
||||||
|
d: Optional[float] = None
|
||||||
|
if t90 is not None and t90.get("diff_kg") is not None:
|
||||||
|
d = float(t90["diff_kg"])
|
||||||
|
elif t30 is not None and t30.get("diff_kg") is not None:
|
||||||
|
d = float(t30["diff_kg"])
|
||||||
|
elif trend_periods[0].get("diff_kg") is not None:
|
||||||
|
d = float(trend_periods[0]["diff_kg"])
|
||||||
|
else:
|
||||||
|
return {"verdict": "Stabil", "status": "good"}
|
||||||
|
if d < -0.25:
|
||||||
|
return {"verdict": "Trend ↓", "status": "good"}
|
||||||
|
if d > 0.25:
|
||||||
|
return {"verdict": "Trend ↑", "status": "warn"}
|
||||||
|
return {"verdict": "Stabil", "status": "good"}
|
||||||
|
|
||||||
|
|
||||||
def get_body_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
def get_body_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Returns chart-ready series and interpretation tiles for the body history tab.
|
Returns chart-ready series and interpretation tiles for the body history tab.
|
||||||
|
|
@ -437,6 +462,7 @@ def get_body_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
||||||
"min_kg": min_w,
|
"min_kg": min_w,
|
||||||
"max_kg": max_w,
|
"max_kg": max_w,
|
||||||
"trend_periods": trend_periods,
|
"trend_periods": trend_periods,
|
||||||
|
"trend_kpi": _weight_trend_kpi(trend_periods),
|
||||||
"data_points": len(w_points),
|
"data_points": len(w_points),
|
||||||
"related_placeholder_keys": [
|
"related_placeholder_keys": [
|
||||||
"weight_aktuell",
|
"weight_aktuell",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ Semantic Versioning: MAJOR.MINOR.PATCH
|
||||||
- PATCH: Bugfix, kleine Änderung, Refactor
|
- PATCH: Bugfix, kleine Änderung, Refactor
|
||||||
"""
|
"""
|
||||||
|
|
||||||
APP_VERSION = "0.9r"
|
APP_VERSION = "0.9s"
|
||||||
BUILD_DATE = "2026-04-20"
|
BUILD_DATE = "2026-04-20"
|
||||||
DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import
|
DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import
|
||||||
|
|
||||||
|
|
@ -36,6 +36,15 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.9s",
|
||||||
|
"date": "2026-04-20",
|
||||||
|
"changes": [
|
||||||
|
"Phase B: body-history-viz weight.trend_kpi (Gewichtstrend-Urteil im data_layer/body_viz)",
|
||||||
|
"History Körper-KPIs: keine Client-Schwellen für WHR/WHtR; KF%-Farbe über Interpretations-Status",
|
||||||
|
"Kcal vs. Gewicht: kein Frontend-TDEE-Fallback; Hinweis bei <5 gemeinsamen Tagen",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.9r",
|
"version": "0.9r",
|
||||||
"date": "2026-04-20",
|
"date": "2026-04-20",
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
|
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
|
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
|
||||||
import { getBfCategory } from '../utils/calc'
|
|
||||||
import { getStatusColor, getStatusBg } from '../utils/interpret'
|
import { getStatusColor, getStatusBg } from '../utils/interpret'
|
||||||
import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme'
|
import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme'
|
||||||
import Markdown from '../utils/Markdown'
|
import Markdown from '../utils/Markdown'
|
||||||
|
|
@ -22,12 +21,6 @@ import dayjs from 'dayjs'
|
||||||
import 'dayjs/locale/de'
|
import 'dayjs/locale/de'
|
||||||
dayjs.locale('de')
|
dayjs.locale('de')
|
||||||
|
|
||||||
function rollingAvg(arr, key, window=7) {
|
|
||||||
return arr.map((d,i) => {
|
|
||||||
const s = arr.slice(Math.max(0,i-window+1),i+1).map(x=>x[key]).filter(v=>v!=null)
|
|
||||||
return s.length ? {...d,[`${key}_avg`]:Math.round(s.reduce((a,b)=>a+b)/s.length*10)/10} : d
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const fmtDate = d => dayjs(d).format('DD.MM')
|
const fmtDate = d => dayjs(d).format('DD.MM')
|
||||||
|
|
||||||
function NavToCaliper() {
|
function NavToCaliper() {
|
||||||
|
|
@ -94,23 +87,14 @@ function verdictShort(status) {
|
||||||
return 'Achtung'
|
return 'Achtung'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Eine KPI-Kachelzeile aus Summary + Interpretationsregeln (ohne Duplikate zur reinen Bewertungsliste). */
|
/** Eine KPI-Kachelzeile aus Summary + Interpretationsregeln — Trend-Urteil aus Bundle ``weight.trend_kpi`` (Layer 1). */
|
||||||
function buildBodyKpiTiles({
|
function buildBodyKpiTiles({
|
||||||
summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints, sex, bfCat, goalW,
|
summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints, weightTrendKpi, goalW,
|
||||||
}) {
|
}) {
|
||||||
const tiles = []
|
const tiles = []
|
||||||
|
|
||||||
if (summary.weight_kg != null) {
|
if (summary.weight_kg != null) {
|
||||||
const t90 = trendPeriods.find(t => t.label === '90T')
|
const wt = weightTrendKpi || { verdict: 'Stabil', status: 'good' }
|
||||||
const t30 = trendPeriods.find(t => t.label === '30T')
|
|
||||||
const d = t90?.diff_kg ?? t30?.diff_kg ?? trendPeriods[0]?.diff_kg
|
|
||||||
let st = 'good'
|
|
||||||
let vs = 'Stabil'
|
|
||||||
if (d != null) {
|
|
||||||
if (d < -0.25) { st = 'good'; vs = 'Trend ↓' }
|
|
||||||
else if (d > 0.25) { st = 'warn'; vs = 'Trend ↑' }
|
|
||||||
else { st = 'good'; vs = 'Stabil' }
|
|
||||||
}
|
|
||||||
const trendBits = trendPeriods.length
|
const trendBits = trendPeriods.length
|
||||||
? trendPeriods.map(t => `${t.label} ${t.diff_kg > 0 ? '+' : ''}${t.diff_kg} kg`).join(' · ')
|
? trendPeriods.map(t => `${t.label} ${t.diff_kg > 0 ? '+' : ''}${t.diff_kg} kg`).join(' · ')
|
||||||
: ''
|
: ''
|
||||||
|
|
@ -128,8 +112,8 @@ function buildBodyKpiTiles({
|
||||||
icon: '⚖️',
|
icon: '⚖️',
|
||||||
value: `${summary.weight_kg} kg`,
|
value: `${summary.weight_kg} kg`,
|
||||||
sublabel: dataPoints ? `${dataPoints} Messwerte` : '',
|
sublabel: dataPoints ? `${dataPoints} Messwerte` : '',
|
||||||
verdict: vs,
|
verdict: wt.verdict,
|
||||||
status: st,
|
status: wt.status,
|
||||||
hoverTop: 'Gewicht',
|
hoverTop: 'Gewicht',
|
||||||
hoverBody,
|
hoverBody,
|
||||||
keys: ['weight_aktuell', 'weight_trend'],
|
keys: ['weight_aktuell', 'weight_trend'],
|
||||||
|
|
@ -143,8 +127,8 @@ function buildBodyKpiTiles({
|
||||||
category: 'Körperfett',
|
category: 'Körperfett',
|
||||||
icon: '🫧',
|
icon: '🫧',
|
||||||
value: `${summary.body_fat_pct}%`,
|
value: `${summary.body_fat_pct}%`,
|
||||||
valueColor: bfCat?.color,
|
valueColor: kfRule ? getStatusColor(kfRule.status) : undefined,
|
||||||
sublabel: bfCat?.label || summary.bf_category_label || '',
|
sublabel: summary.bf_category_label || '',
|
||||||
verdict: verdictShort(kfRule?.status || 'good'),
|
verdict: verdictShort(kfRule?.status || 'good'),
|
||||||
status: kfRule?.status || 'good',
|
status: kfRule?.status || 'good',
|
||||||
hoverTop: kfRule?.title || 'Körperfettanteil',
|
hoverTop: kfRule?.title || 'Körperfettanteil',
|
||||||
|
|
@ -186,34 +170,32 @@ function buildBodyKpiTiles({
|
||||||
}
|
}
|
||||||
|
|
||||||
const whrRule = rules.find(r => r.category === 'Fettverteilung')
|
const whrRule = rules.find(r => r.category === 'Fettverteilung')
|
||||||
if (summary.whr != null) {
|
if (summary.whr != null && whrRule) {
|
||||||
const ok = summary.whr < (sex === 'm' ? 0.9 : 0.85)
|
|
||||||
tiles.push({
|
tiles.push({
|
||||||
key: 'whr',
|
key: 'whr',
|
||||||
category: 'Fettverteilung',
|
category: 'Fettverteilung',
|
||||||
icon: '📐',
|
icon: '📐',
|
||||||
value: String(summary.whr),
|
value: String(summary.whr),
|
||||||
sublabel: 'WHR · Taille ÷ Hüfte',
|
sublabel: 'WHR · Taille ÷ Hüfte',
|
||||||
verdict: whrRule ? verdictShort(whrRule.status) : (ok ? 'Gut' : 'Hinweis'),
|
verdict: verdictShort(whrRule.status),
|
||||||
status: whrRule?.status || (ok ? 'good' : 'warn'),
|
status: whrRule.status,
|
||||||
hoverTop: whrRule?.title || 'Waist-Hip-Ratio',
|
hoverTop: whrRule.title || 'Waist-Hip-Ratio',
|
||||||
hoverBody: [whrRule?.detail, !whrRule && `Ziel unter ${sex === 'm' ? '0,90' : '0,85'}.`, whrRule?.related_placeholder_keys?.length ? `Registry: ${whrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
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')
|
const whtrRule = rules.find(r => r.category === 'Taille/Größe')
|
||||||
if (summary.whtr != null) {
|
if (summary.whtr != null && whtrRule) {
|
||||||
const ok = summary.whtr < 0.5
|
|
||||||
tiles.push({
|
tiles.push({
|
||||||
key: 'whtr',
|
key: 'whtr',
|
||||||
category: 'Taille/Größe',
|
category: 'Taille/Größe',
|
||||||
icon: '📏',
|
icon: '📏',
|
||||||
value: String(summary.whtr),
|
value: String(summary.whtr),
|
||||||
sublabel: 'WHtR · Taille ÷ Größe',
|
sublabel: 'WHtR · Taille ÷ Größe',
|
||||||
verdict: whtrRule ? verdictShort(whtrRule.status) : (ok ? 'Gut' : 'Hinweis'),
|
verdict: verdictShort(whtrRule.status),
|
||||||
status: whtrRule?.status || (ok ? 'good' : 'warn'),
|
status: whtrRule.status,
|
||||||
hoverTop: whtrRule?.title || 'Waist-to-Height-Ratio',
|
hoverTop: whtrRule.title || 'Waist-to-Height-Ratio',
|
||||||
hoverBody: [whtrRule?.detail, !whtrRule && 'Ziel unter 0,50 (WHO).', whtrRule?.related_placeholder_keys?.length ? `Registry: ${whtrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
hoverBody: [whtrRule.detail, whtrRule.related_placeholder_keys?.length ? `Registry: ${whtrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -399,8 +381,6 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
|
||||||
const [vizLoading, setVizLoading] = useState(true)
|
const [vizLoading, setVizLoading] = useState(true)
|
||||||
const [vizError, setVizError] = useState(null)
|
const [vizError, setVizError] = useState(null)
|
||||||
|
|
||||||
const sex = profile?.sex || 'm'
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
api.listGoalsGrouped()
|
api.listGoalsGrouped()
|
||||||
|
|
@ -470,7 +450,6 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
|
||||||
belly: r.belly,
|
belly: r.belly,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const bfCat = summary.body_fat_pct != null ? getBfCategory(summary.body_fat_pct, sex) : null
|
|
||||||
const goalW = viz?.profile?.goal_weight_kg ?? profile?.goal_weight
|
const goalW = viz?.profile?.goal_weight_kg ?? profile?.goal_weight
|
||||||
const goalBf = viz?.profile?.goal_bf_pct ?? profile?.goal_bf_pct
|
const goalBf = viz?.profile?.goal_bf_pct ?? profile?.goal_bf_pct
|
||||||
|
|
||||||
|
|
@ -492,8 +471,7 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
|
||||||
maxW,
|
maxW,
|
||||||
avgAll,
|
avgAll,
|
||||||
dataPoints: w?.data_points,
|
dataPoints: w?.data_points,
|
||||||
sex,
|
weightTrendKpi: w?.trend_kpi,
|
||||||
bfCat,
|
|
||||||
goalW,
|
goalW,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -785,75 +763,31 @@ function KcalVsWeightLegend({ showTdee }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kalorien (Ø 7T) vs. Gewicht — Daten aus Layer-2b-Bundle (nutrition_metrics / TDEE wie Data Layer). */
|
/** Kalorien (Ø 7T) vs. Gewicht — nur Layer-2b-Bundle (nutrition_metrics); kein Frontend-TDEE-Fallback. */
|
||||||
function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffDate, allTime }) {
|
function KcalVsWeightChart({ vizKcalWeight }) {
|
||||||
if (vizKcalWeight?.points?.length >= 5) {
|
const n = vizKcalWeight?.points?.length ?? 0
|
||||||
const tdee = vizKcalWeight.tdee_reference_kcal
|
if (n < 5) {
|
||||||
const kcalVsW = vizKcalWeight.points.map(d => ({
|
if (n === 0) return null
|
||||||
...d,
|
|
||||||
date: fmtDate(d.date),
|
|
||||||
}))
|
|
||||||
const n = vizKcalWeight.common_days_count ?? kcalVsW.length
|
|
||||||
const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null
|
|
||||||
const kcalDomain = kcalVsWeightKcalDomain(kcalVsW, tdeeLabel)
|
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ marginBottom: 12 }}>
|
<div className="card" style={{ marginBottom: 12, padding: '12px 14px' }}>
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
Kalorien (Ø 7 Tage) vs. Gewicht
|
Kalorien (Ø 7 Tage) vs. Gewicht
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
|
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||||
Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg.
|
Für dieses Diagramm werden mindestens 5 Tage mit Kalorien- und Gewichtsdaten benötigt ({n} im Zeitraum).
|
||||||
</div>
|
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
|
||||||
<LineChart data={kcalVsW} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
|
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(kcalVsW.length / 6) - 1)} />
|
|
||||||
<YAxis yAxisId="kcal" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={kcalDomain} />
|
|
||||||
<YAxis yAxisId="weight" 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, n) => [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']}
|
|
||||||
/>
|
|
||||||
{tdeeLabel != null && (
|
|
||||||
<ReferenceLine
|
|
||||||
yAxisId="kcal"
|
|
||||||
y={tdeeLabel}
|
|
||||||
stroke={TDEE_REF_LINE_COLOR}
|
|
||||||
strokeDasharray="6 5"
|
|
||||||
strokeWidth={2}
|
|
||||||
isFront
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EA580C" strokeWidth={2.5} dot={false} name="kcal_avg" />
|
|
||||||
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#2563EB" strokeWidth={2.5} dot={{ r: 2, fill: '#2563EB' }} name="weight" />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
<KcalVsWeightLegend showTdee={tdeeLabel != null} />
|
|
||||||
<div style={{ fontSize: 10, color: 'var(--text3)', textAlign: 'center', marginTop: 8 }}>
|
|
||||||
{tdeeLabel != null
|
|
||||||
? `TDEE ~${tdeeLabel} kcal · ${n} gemeinsame Tage`
|
|
||||||
: `Keine TDEE-Referenz (Gewicht/Demografie) · ${n} gemeinsame Tage`}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = (corrRows || []).filter(d => {
|
const tdee = vizKcalWeight.tdee_reference_kcal
|
||||||
if (!d.kcal || d.weight == null) return false
|
const kcalVsW = vizKcalWeight.points.map(d => ({
|
||||||
const ds = typeof d.date === 'string' ? d.date.slice(0, 10) : dayjs(d.date).format('YYYY-MM-DD')
|
...d,
|
||||||
return allTime || ds >= cutoffDate
|
date: fmtDate(d.date),
|
||||||
})
|
}))
|
||||||
if (raw.length < 5) return null
|
const commonDays = vizKcalWeight.common_days_count ?? kcalVsW.length
|
||||||
|
const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null
|
||||||
const sex = profile?.sex || 'm'
|
const kcalDomain = kcalVsWeightKcalDomain(kcalVsW, tdeeLabel)
|
||||||
const height = profile?.height || 178
|
|
||||||
const latestW = raw[raw.length - 1]?.weight || 80
|
|
||||||
const age = profile?.dob ? Math.floor((Date.now() - new Date(profile.dob)) / (365.25 * 24 * 3600 * 1000)) : 35
|
|
||||||
const bmr = sex === 'm' ? 10 * latestW + 6.25 * height - 5 * age + 5 : 10 * latestW + 6.25 * height - 5 * age - 161
|
|
||||||
const tdee = Math.round(bmr * 1.4)
|
|
||||||
const kcalVsW = rollingAvg(raw.map(d => ({ ...d, date: fmtDate(d.date) })), 'kcal')
|
|
||||||
const kcalDomainFb = kcalVsWeightKcalDomain(kcalVsW, tdee)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ marginBottom: 12 }}>
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
|
|
@ -866,27 +800,31 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD
|
||||||
<LineChart data={kcalVsW} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
|
<LineChart data={kcalVsW} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(kcalVsW.length / 6) - 1)} />
|
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(kcalVsW.length / 6) - 1)} />
|
||||||
<YAxis yAxisId="kcal" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={kcalDomainFb} />
|
<YAxis yAxisId="kcal" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={kcalDomain} />
|
||||||
<YAxis yAxisId="weight" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
<YAxis yAxisId="weight" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
|
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
|
||||||
formatter={(v, n) => [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']}
|
formatter={(v, name) => [`${Math.round(v)} ${name === 'weight' ? 'kg' : 'kcal'}`, name === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']}
|
||||||
/>
|
|
||||||
<ReferenceLine
|
|
||||||
yAxisId="kcal"
|
|
||||||
y={tdee}
|
|
||||||
stroke={TDEE_REF_LINE_COLOR}
|
|
||||||
strokeDasharray="6 5"
|
|
||||||
strokeWidth={2}
|
|
||||||
isFront
|
|
||||||
/>
|
/>
|
||||||
|
{tdeeLabel != null && (
|
||||||
|
<ReferenceLine
|
||||||
|
yAxisId="kcal"
|
||||||
|
y={tdeeLabel}
|
||||||
|
stroke={TDEE_REF_LINE_COLOR}
|
||||||
|
strokeDasharray="6 5"
|
||||||
|
strokeWidth={2}
|
||||||
|
isFront
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EA580C" strokeWidth={2.5} dot={false} name="kcal_avg" />
|
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EA580C" strokeWidth={2.5} dot={false} name="kcal_avg" />
|
||||||
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#2563EB" strokeWidth={2.5} dot={{ r: 2, fill: '#2563EB' }} name="weight" />
|
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#2563EB" strokeWidth={2.5} dot={{ r: 2, fill: '#2563EB' }} name="weight" />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<KcalVsWeightLegend showTdee />
|
<KcalVsWeightLegend showTdee={tdeeLabel != null} />
|
||||||
<div style={{ fontSize: 10, color: 'var(--text3)', textAlign: 'center', marginTop: 8 }}>
|
<div style={{ fontSize: 10, color: 'var(--text3)', textAlign: 'center', marginTop: 8 }}>
|
||||||
TDEE ~{tdee} kcal (Fallback Mifflin ×1,4) · {raw.length} gemeinsame Tage
|
{tdeeLabel != null
|
||||||
|
? `TDEE ~${tdeeLabel} kcal · ${commonDays} gemeinsame Tage`
|
||||||
|
: `Keine TDEE-Referenz (Gewicht/Demografie) · ${commonDays} gemeinsame Tage`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -894,7 +832,7 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD
|
||||||
|
|
||||||
// ── Nutrition Section ─────────────────────────────────────────────────────────
|
// ── Nutrition Section ─────────────────────────────────────────────────────────
|
||||||
/** Layer 2b: Kennzahlen und Reihen nur aus GET /charts/nutrition-history-viz (nutrition_metrics). */
|
/** Layer 2b: Kennzahlen und Reihen nur aus GET /charts/nutrition-history-viz (nutrition_metrics). */
|
||||||
function NutritionSection({ profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
function NutritionSection({ insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
||||||
const [period, setPeriod] = useState(30)
|
const [period, setPeriod] = useState(30)
|
||||||
const [groupedGoals, setGroupedGoals] = useState(null)
|
const [groupedGoals, setGroupedGoals] = useState(null)
|
||||||
const [viz, setViz] = useState(null)
|
const [viz, setViz] = useState(null)
|
||||||
|
|
@ -1000,13 +938,7 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
|
||||||
|
|
||||||
<KpiTilesOverview tiles={kpiTiles} />
|
<KpiTilesOverview tiles={kpiTiles} />
|
||||||
|
|
||||||
<KcalVsWeightChart
|
<KcalVsWeightChart vizKcalWeight={viz.kcal_vs_weight} />
|
||||||
vizKcalWeight={viz.kcal_vs_weight}
|
|
||||||
corrData={[]}
|
|
||||||
profile={profile}
|
|
||||||
cutoffDate=""
|
|
||||||
allTime={period === 9999}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{balDaily.length > 0 && tdeeRef != null && (
|
{balDaily.length > 0 && tdeeRef != null && (
|
||||||
<div className="card" style={{ marginBottom: 12 }}>
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
|
|
@ -1813,7 +1745,7 @@ export default function History() {
|
||||||
<div className="history-content">
|
<div className="history-content">
|
||||||
{tab==='overview' && <HistoryOverviewSection {...sp}/>}
|
{tab==='overview' && <HistoryOverviewSection {...sp}/>}
|
||||||
{tab==='body' && <BodySection profile={profile} {...sp}/>}
|
{tab==='body' && <BodySection profile={profile} {...sp}/>}
|
||||||
{tab==='nutrition' && <NutritionSection profile={profile} {...sp}/>}
|
{tab==='nutrition' && <NutritionSection {...sp}/>}
|
||||||
{tab==='activity' && <ActivitySection activityLastDate={activityLastDate} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
|
{tab==='activity' && <ActivitySection activityLastDate={activityLastDate} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
|
||||||
{tab==='photos' && <PhotoGrid/>}
|
{tab==='photos' && <PhotoGrid/>}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user