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({
|
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
|
||||||
"body_overview",
|
"body_overview",
|
||||||
|
"body_history_viz",
|
||||||
"activity_overview",
|
"activity_overview",
|
||||||
"kpi_board",
|
"kpi_board",
|
||||||
"quick_capture",
|
"quick_capture",
|
||||||
|
|
@ -52,6 +53,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
||||||
|
|
||||||
if widget_id == "body_overview":
|
if widget_id == "body_overview":
|
||||||
return _validate_chart_days_only(raw, label="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":
|
if widget_id == "activity_overview":
|
||||||
return _validate_chart_days_only(raw, label="activity_overview")
|
return _validate_chart_days_only(raw, label="activity_overview")
|
||||||
if widget_id == "kpi_board":
|
if widget_id == "kpi_board":
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@ def test_body_chart_days_bounds():
|
||||||
validate_widget_entry_config("body_overview", {"chart_days": 91})
|
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():
|
def test_welcome_config_rejected_unknown_key():
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
validate_widget_entry_config("welcome", {"x": 1})
|
validate_widget_entry_config("welcome", {"x": 1})
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ MODULE_VERSIONS = {
|
||||||
"importdata": "1.0.0",
|
"importdata": "1.0.0",
|
||||||
"membership": "2.1.0",
|
"membership": "2.1.0",
|
||||||
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
|
"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
|
"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)
|
"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",
|
"description": "Gewicht & Kennzahlen (optional: config chart_days 7–90); Feature weight_entries",
|
||||||
"requires_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",
|
"id": "activity_overview",
|
||||||
"title": "Aktivität",
|
"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 */
|
/** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */
|
||||||
const CHART_DAYS_WIDGET_IDS = new Set([
|
const CHART_DAYS_WIDGET_IDS = new Set([
|
||||||
'body_overview',
|
'body_overview',
|
||||||
|
'body_history_viz',
|
||||||
'activity_overview',
|
'activity_overview',
|
||||||
'nutrition_detail_charts',
|
'nutrition_detail_charts',
|
||||||
'recovery_charts_panel',
|
'recovery_charts_panel',
|
||||||
|
|
|
||||||
|
|
@ -17,49 +17,14 @@ import FitnessDashboardOverview from '../components/FitnessDashboardOverview'
|
||||||
import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts'
|
import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts'
|
||||||
import RecoveryDashboardOverview from '../components/RecoveryDashboardOverview'
|
import RecoveryDashboardOverview from '../components/RecoveryDashboardOverview'
|
||||||
import KpiTilesOverview from '../components/KpiTilesOverview'
|
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 from 'dayjs'
|
||||||
import 'dayjs/locale/de'
|
import 'dayjs/locale/de'
|
||||||
dayjs.locale('de')
|
dayjs.locale('de')
|
||||||
|
|
||||||
const fmtDate = d => dayjs(d).format('DD.MM')
|
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 }) {
|
function RuleCard({ item }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const color = getStatusColor(item.status)
|
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 }) {
|
function NutritionGoalsStrip({ grouped }) {
|
||||||
const nav = useNavigate()
|
const nav = useNavigate()
|
||||||
const goals = (grouped?.nutrition || []).filter(g => g.status === 'active').slice(0, 4)
|
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 }) {
|
function InsightBox({ insights, slugs, onRequest, loading }) {
|
||||||
const [expanded, setExpanded] = useState(null)
|
const [expanded, setExpanded] = useState(null)
|
||||||
const relevant = insights?.filter(i=>slugs.includes(i.scope))||[]
|
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). */
|
/** TDEE-Linie muss in der kcal-Y-Domain liegen (sonst unsichtbar trotz Legende). */
|
||||||
function kcalVsWeightKcalDomain(points, tdeeRef) {
|
function kcalVsWeightKcalDomain(points, tdeeRef) {
|
||||||
const vals = (points || [])
|
const vals = (points || [])
|
||||||
|
|
@ -1844,7 +1303,19 @@ export default function History() {
|
||||||
</nav>
|
</nav>
|
||||||
<div className="history-content">
|
<div className="history-content">
|
||||||
{tab==='overview' && <HistoryOverviewSection {...sp}/>}
|
{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==='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/>}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import ProfileGoalsProgressWidget from '../components/dashboard-widgets/ProfileG
|
||||||
import TrendKcalWeightWidget from '../components/dashboard-widgets/TrendKcalWeightWidget'
|
import TrendKcalWeightWidget from '../components/dashboard-widgets/TrendKcalWeightWidget'
|
||||||
import NutritionActivitySummaryWidget from '../components/dashboard-widgets/NutritionActivitySummaryWidget'
|
import NutritionActivitySummaryWidget from '../components/dashboard-widgets/NutritionActivitySummaryWidget'
|
||||||
import NutritionDetailChartsWidget from '../components/dashboard-widgets/NutritionDetailChartsWidget'
|
import NutritionDetailChartsWidget from '../components/dashboard-widgets/NutritionDetailChartsWidget'
|
||||||
|
import BodyHistoryVizWidget from '../components/dashboard-widgets/BodyHistoryVizWidget'
|
||||||
import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget'
|
import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget'
|
||||||
import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget'
|
import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget'
|
||||||
import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget'
|
import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget'
|
||||||
|
|
@ -57,6 +58,14 @@ export function ensurePilotLabWidgetsRegistered() {
|
||||||
chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days),
|
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({
|
registerDashboardWidget({
|
||||||
id: 'activity_overview',
|
id: 'activity_overview',
|
||||||
Component: PilotActivitySection,
|
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