feat: enhance body_history_viz widget configuration and validation
- Added new configuration options for the `body_history_viz` widget, including visibility settings for various charts and KPIs. - Implemented default values for the widget's configuration to streamline user experience. - Enhanced validation logic to ensure proper handling of configuration inputs, including error handling for unknown keys and visibility requirements. - Updated tests to cover new configuration scenarios and validation rules for the `body_history_viz` widget. - Bumped application version to reflect these changes.
This commit is contained in:
parent
01c0d1745f
commit
20f195aca1
|
|
@ -33,6 +33,32 @@ _QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
|
|||
_KPI_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"})
|
||||
_KPI_REF_TILE_RE = re.compile(r"^ref:[a-z0-9_]{1,64}$")
|
||||
|
||||
_BODY_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
||||
"show_goals_strip",
|
||||
"show_intro_blurb",
|
||||
"show_layer_meta",
|
||||
"show_kpis",
|
||||
"show_weight_chart",
|
||||
"show_body_fat_chart",
|
||||
"show_proportion_chart",
|
||||
"show_circumference_index_chart",
|
||||
"show_circumference_lines_chart",
|
||||
})
|
||||
|
||||
_BODY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
|
||||
"chart_days": 30,
|
||||
"show_goals_strip": False,
|
||||
"show_intro_blurb": False,
|
||||
"show_layer_meta": False,
|
||||
"show_kpis": True,
|
||||
"kpi_detail": "compact",
|
||||
"show_weight_chart": True,
|
||||
"show_body_fat_chart": False,
|
||||
"show_proportion_chart": False,
|
||||
"show_circumference_index_chart": False,
|
||||
"show_circumference_lines_chart": False,
|
||||
}
|
||||
|
||||
|
||||
def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
||||
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8"))
|
||||
|
|
@ -40,21 +66,26 @@ def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
|||
|
||||
def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
||||
if raw is None:
|
||||
return {}
|
||||
raw = {}
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"Widget {widget_id}: config muss ein Objekt sein")
|
||||
if _config_json_size_bytes(raw) > MAX_WIDGET_CONFIG_JSON_BYTES:
|
||||
raise ValueError(f"Widget {widget_id}: config zu groß (max. {MAX_WIDGET_CONFIG_JSON_BYTES} Byte JSON)")
|
||||
if not raw:
|
||||
return {}
|
||||
|
||||
if widget_id not in WIDGETS_ALLOWING_CONFIG:
|
||||
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
||||
if raw:
|
||||
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
||||
return {}
|
||||
|
||||
if not raw:
|
||||
if widget_id == "body_history_viz":
|
||||
return _validate_body_history_viz_config({})
|
||||
return {}
|
||||
|
||||
if widget_id == "body_overview":
|
||||
return _validate_chart_days_only(raw, label="body_overview")
|
||||
if widget_id == "body_history_viz":
|
||||
return _validate_chart_days_only(raw, label="body_history_viz")
|
||||
return _validate_body_history_viz_config(raw)
|
||||
if widget_id == "activity_overview":
|
||||
return _validate_chart_days_only(raw, label="activity_overview")
|
||||
if widget_id == "kpi_board":
|
||||
|
|
@ -153,6 +184,44 @@ def _parse_chart_days(v: Any, label: str) -> int:
|
|||
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
|
||||
|
||||
|
||||
def _validate_body_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
label = "body_history_viz"
|
||||
allowed = _BODY_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
|
||||
unknown = set(raw) - allowed
|
||||
if unknown:
|
||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
||||
out: dict[str, Any] = dict(_BODY_HISTORY_VIZ_DEFAULTS)
|
||||
for k in _BODY_HISTORY_VIZ_BOOL_KEYS:
|
||||
if k not in raw:
|
||||
continue
|
||||
v = raw[k]
|
||||
if not isinstance(v, bool):
|
||||
raise ValueError(f"{label}: {k} muss boolean sein")
|
||||
out[k] = v
|
||||
if "kpi_detail" in raw:
|
||||
kd = raw["kpi_detail"]
|
||||
if kd not in ("compact", "full"):
|
||||
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
|
||||
out["kpi_detail"] = kd
|
||||
if "chart_days" in raw:
|
||||
v = _parse_chart_days(raw["chart_days"], label)
|
||||
if v < 7 or v > 90:
|
||||
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
||||
out["chart_days"] = v
|
||||
if not out["show_kpis"] and not any(
|
||||
out[k]
|
||||
for k in (
|
||||
"show_weight_chart",
|
||||
"show_body_fat_chart",
|
||||
"show_proportion_chart",
|
||||
"show_circumference_index_chart",
|
||||
"show_circumference_lines_chart",
|
||||
)
|
||||
):
|
||||
raise ValueError(f"{label}: mindestens KPIs oder ein Chart muss sichtbar sein")
|
||||
return out
|
||||
|
||||
|
||||
def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]:
|
||||
allowed = frozenset({"chart_days"})
|
||||
unknown = set(raw) - allowed
|
||||
|
|
|
|||
|
|
@ -14,13 +14,36 @@ def test_body_chart_days_bounds():
|
|||
validate_widget_entry_config("body_overview", {"chart_days": 91})
|
||||
|
||||
|
||||
def test_body_history_viz_chart_days():
|
||||
assert validate_widget_entry_config("body_history_viz", {}) == {}
|
||||
assert validate_widget_entry_config("body_history_viz", {"chart_days": 60}) == {"chart_days": 60}
|
||||
def test_body_history_viz_empty_expands_defaults():
|
||||
d = validate_widget_entry_config("body_history_viz", {})
|
||||
assert d["chart_days"] == 30
|
||||
assert d["show_kpis"] is True
|
||||
assert d["show_weight_chart"] is True
|
||||
assert d["kpi_detail"] == "compact"
|
||||
assert d["show_body_fat_chart"] is False
|
||||
|
||||
|
||||
def test_body_history_viz_chart_days_and_merge():
|
||||
d = validate_widget_entry_config("body_history_viz", {"chart_days": 60})
|
||||
assert d["chart_days"] == 60
|
||||
assert d["show_goals_strip"] is False
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("body_history_viz", {"chart_days": 5})
|
||||
|
||||
|
||||
def test_body_history_viz_requires_visible_block():
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config(
|
||||
"body_history_viz",
|
||||
{"show_kpis": False, "show_weight_chart": False},
|
||||
)
|
||||
|
||||
|
||||
def test_body_history_viz_unknown_key():
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("body_history_viz", {"evil": True})
|
||||
|
||||
|
||||
def test_welcome_config_rejected_unknown_key():
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("welcome", {"x": 1})
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ MODULE_VERSIONS = {
|
|||
"importdata": "1.0.0",
|
||||
"membership": "2.1.0",
|
||||
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
|
||||
"app_dashboard": "1.12.1", # GET layout: merge_missing_catalog_widgets (neue Katalog-IDs sichtbar)
|
||||
"app_dashboard": "1.13.0", # body_history_viz: Sichtbarkeits-Config + Defaults schlank
|
||||
"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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
|||
{
|
||||
"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",
|
||||
"description": "Layer-2b body-history-viz: schlanker Standard (KPI kompakt + Gewicht); optional Blöcke/Charts per config (show_* , kpi_detail); chart_days 7–90; Feature weight_entries",
|
||||
"requires_feature": "weight_entries",
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
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'
|
||||
import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays'
|
||||
import { normalizeBodyHistoryVizConfig } from '../../widgetSystem/bodyHistoryVizConfig'
|
||||
|
||||
/**
|
||||
* Verlauf → Körper als Dashboard-Widget: GET /charts/body-history-viz (Layer 2b), optional chart_days 7–90.
|
||||
* @param {{ refreshTick?: number, chartDays?: number }} props
|
||||
* Verlauf → Körper als Dashboard-Widget: GET /charts/body-history-viz (Layer 2b), Umfang über Layout-Config.
|
||||
* @param {{ refreshTick?: number, bodyHistoryVizConfig?: Record<string, unknown> }} props
|
||||
*/
|
||||
export default function BodyHistoryVizWidget({ refreshTick = 0, chartDays }) {
|
||||
export default function BodyHistoryVizWidget({ refreshTick = 0, bodyHistoryVizConfig }) {
|
||||
const nav = useNavigate()
|
||||
const { activeProfile } = useProfile()
|
||||
const days = chartDays != null ? normalizeBodyChartDays(chartDays) : BODY_CHART_DAYS_DEFAULT
|
||||
const cfg = normalizeBodyHistoryVizConfig(bodyHistoryVizConfig)
|
||||
const days = normalizeBodyChartDays(cfg.chart_days)
|
||||
|
||||
return (
|
||||
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||
|
|
@ -23,7 +25,13 @@ export default function BodyHistoryVizWidget({ refreshTick = 0, chartDays }) {
|
|||
Verlauf →
|
||||
</button>
|
||||
</div>
|
||||
<BodyHistoryVizSection key={`${refreshTick}-${days}`} profile={activeProfile} externalPeriod={days} embedded />
|
||||
<BodyHistoryVizSection
|
||||
key={`${refreshTick}-${days}`}
|
||||
profile={activeProfile}
|
||||
externalPeriod={days}
|
||||
embedded
|
||||
visibility={cfg}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,12 +17,25 @@ import 'dayjs/locale/de'
|
|||
import { api } from '../../utils/api'
|
||||
import { getStatusColor } from '../../utils/interpret'
|
||||
import KpiTilesOverview from '../KpiTilesOverview'
|
||||
import {
|
||||
BODY_HISTORY_VIZ_HISTORY_FULL,
|
||||
filterBodyHistoryKpiTiles,
|
||||
} from '../../widgetSystem/bodyHistoryVizConfig'
|
||||
import { EmptySection, NavToCaliper, NavToCircum, PeriodSelector, SectionHeader } from './historyPageChrome'
|
||||
|
||||
dayjs.locale('de')
|
||||
|
||||
const fmtDate = (d) => dayjs(d).format('DD.MM')
|
||||
|
||||
/** Recharts: in schmalen Flex-Spalten sonst Breite 0. */
|
||||
function ChartFrame({ heightPx, children }) {
|
||||
return (
|
||||
<div style={{ width: '100%', minWidth: 0, height: heightPx }} className="body-history-viz__chart-frame">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function verdictShort(status) {
|
||||
if (status === 'good') return 'Gut'
|
||||
if (status === 'warn') return 'Hinweis'
|
||||
|
|
@ -222,8 +235,15 @@ function BodyGoalsStrip({ grouped }) {
|
|||
* @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)
|
||||
* @param {object} [props.visibility] — Sichtbarkeit (Dashboard-Config); fehlt → wie Verlauf (alles an, kpi_detail full)
|
||||
*/
|
||||
export default function BodyHistoryVizSection({ profile, externalPeriod, footer = null, embedded = false }) {
|
||||
export default function BodyHistoryVizSection({ profile, externalPeriod, footer = null, embedded = false, visibility }) {
|
||||
const display = visibility === undefined ? BODY_HISTORY_VIZ_HISTORY_FULL : visibility
|
||||
const chartHMain = embedded ? 176 : 200
|
||||
const chartHSecondary = embedded ? 152 : 170
|
||||
const chartHIndex = embedded ? 160 : 180
|
||||
const chartHFallback = embedded ? 140 : 150
|
||||
|
||||
const [internalPeriod, setInternalPeriod] = useState(90)
|
||||
const period = externalPeriod !== undefined ? externalPeriod : internalPeriod
|
||||
const showPeriodSelector = externalPeriod === undefined
|
||||
|
|
@ -234,6 +254,10 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
|
|||
const [vizError, setVizError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!display.show_goals_strip) {
|
||||
setGroupedGoals({})
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
api.listGoalsGrouped()
|
||||
.then((g) => {
|
||||
|
|
@ -245,7 +269,7 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
}, [display.show_goals_strip])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -334,6 +358,7 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
|
|||
weightTrendKpi: w?.trend_kpi,
|
||||
goalW,
|
||||
})
|
||||
const kpiTilesShown = display.show_kpis ? filterBodyHistoryKpiTiles(kpiTiles, display.kpi_detail || 'full') : []
|
||||
|
||||
const hasAnyData =
|
||||
(w?.data_points > 0) ||
|
||||
|
|
@ -378,23 +403,25 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
|
|||
)}
|
||||
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
|
||||
|
||||
<BodyGoalsStrip grouped={groupedGoals} />
|
||||
{display.show_goals_strip && <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>
|
||||
{display.show_intro_blurb && (
|
||||
<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 && (
|
||||
{display.show_layer_meta && 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} />
|
||||
{kpiTilesShown.length > 0 && <KpiTilesOverview tiles={kpiTilesShown} />}
|
||||
|
||||
{vizLoading && <div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>Aktualisiere…</div>}
|
||||
|
||||
{hasWeight && (
|
||||
{display.show_weight_chart && 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)' }}>
|
||||
|
|
@ -404,7 +431,8 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
|
|||
Daten <ChevronRight size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<ChartFrame heightPx={chartHMain}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<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)} />
|
||||
|
|
@ -420,7 +448,8 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
|
|||
<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>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
<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>
|
||||
|
|
@ -433,13 +462,14 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
|
|||
</div>
|
||||
)}
|
||||
|
||||
{bfCd.length >= 2 && (
|
||||
{display.show_body_fat_chart && 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}>
|
||||
<ChartFrame heightPx={chartHSecondary}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<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} />
|
||||
|
|
@ -448,12 +478,13 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
|
|||
{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>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
<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 && (
|
||||
{display.show_proportion_chart && propChartData.length >= 2 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 8 }}>
|
||||
<div>
|
||||
|
|
@ -469,7 +500,8 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
|
|||
</div>
|
||||
<NavToCircum />
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<ChartFrame heightPx={chartHMain}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<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} />
|
||||
|
|
@ -487,7 +519,8 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
|
|||
<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>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
<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>
|
||||
|
|
@ -496,7 +529,7 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
|
|||
</div>
|
||||
)}
|
||||
|
||||
{idxOk && (
|
||||
{display.show_circumference_index_chart && idxOk && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div>
|
||||
|
|
@ -505,7 +538,8 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
|
|||
</div>
|
||||
<NavToCircum />
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<ChartFrame heightPx={chartHIndex}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<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} />
|
||||
|
|
@ -516,7 +550,8 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
|
|||
{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>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
<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>
|
||||
|
|
@ -525,14 +560,15 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
|
|||
</div>
|
||||
)}
|
||||
|
||||
{propChartData.length < 2 && cirCd.length >= 2 && (
|
||||
{display.show_circumference_lines_chart && 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}>
|
||||
<ChartFrame heightPx={chartHFallback}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<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} />
|
||||
|
|
@ -542,7 +578,8 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
|
|||
<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>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from '../widgetSystem/bodyChartDays'
|
||||
import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
|
||||
import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
|
||||
import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor'
|
||||
import {
|
||||
moveWidget,
|
||||
moveWidgetToIndex,
|
||||
|
|
@ -496,6 +497,23 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{w.id === 'body_history_viz' && (
|
||||
<BodyHistoryVizConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '../widgetSystem/bodyChartDays'
|
||||
import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
|
||||
import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
|
||||
import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor'
|
||||
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
|
||||
|
||||
/** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */
|
||||
|
|
@ -319,11 +320,13 @@ export default function DashboardLabPage() {
|
|||
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
|
||||
{w.id === 'body_overview'
|
||||
? 'Körper-Chart'
|
||||
: w.id === 'activity_overview'
|
||||
? 'Aktivität (Verteilung & Konsistenz)'
|
||||
: w.id === 'nutrition_detail_charts'
|
||||
? 'Ernährung — Charts'
|
||||
: 'Erholung — Charts'}{' '}
|
||||
: w.id === 'body_history_viz'
|
||||
? 'Körper (Verlauf-Bundle)'
|
||||
: w.id === 'activity_overview'
|
||||
? 'Aktivität (Verteilung & Konsistenz)'
|
||||
: w.id === 'nutrition_detail_charts'
|
||||
? 'Ernährung — Charts'
|
||||
: 'Erholung — Charts'}{' '}
|
||||
— Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX}
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -335,11 +338,13 @@ export default function DashboardLabPage() {
|
|||
aria-label={
|
||||
w.id === 'body_overview'
|
||||
? 'Körper-Chart Zeitraum in Tagen'
|
||||
: w.id === 'activity_overview'
|
||||
? 'Aktivität Zeitraum in Tagen'
|
||||
: w.id === 'nutrition_detail_charts'
|
||||
? 'Ernährungs-Charts Zeitraum in Tagen'
|
||||
: 'Erholungs-Charts Zeitraum in Tagen'
|
||||
: w.id === 'body_history_viz'
|
||||
? 'Körper Verlauf-Bundle Zeitraum in Tagen'
|
||||
: w.id === 'activity_overview'
|
||||
? 'Aktivität Zeitraum in Tagen'
|
||||
: w.id === 'nutrition_detail_charts'
|
||||
? 'Ernährungs-Charts Zeitraum in Tagen'
|
||||
: 'Erholungs-Charts Zeitraum in Tagen'
|
||||
}
|
||||
value={
|
||||
chartDaysDraftByWidgetId[w.id] !== undefined
|
||||
|
|
@ -375,6 +380,23 @@ export default function DashboardLabPage() {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{w.id === 'body_history_viz' && (
|
||||
<BodyHistoryVizConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
91
frontend/src/widgetSystem/BodyHistoryVizConfigEditor.jsx
Normal file
91
frontend/src/widgetSystem/BodyHistoryVizConfigEditor.jsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { BODY_HISTORY_VIZ_WIDGET_DEFAULTS, normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig'
|
||||
|
||||
const CHART_TOGGLES = [
|
||||
{ key: 'show_weight_chart', label: 'Gewichts-Chart' },
|
||||
{ key: 'show_body_fat_chart', label: 'Körperfett (Caliper)' },
|
||||
{ key: 'show_proportion_chart', label: 'Silhouette & Proportion' },
|
||||
{ key: 'show_circumference_index_chart', label: 'Umfänge — Index' },
|
||||
{ key: 'show_circumference_lines_chart', label: 'Umfänge — Linien (Fallback)' },
|
||||
]
|
||||
|
||||
const OTHER_TOGGLES = [
|
||||
{ key: 'show_goals_strip', label: 'Körper-Ziele (Strip)' },
|
||||
{ key: 'show_intro_blurb', label: 'Hinweistext unter Zielen' },
|
||||
{ key: 'show_layer_meta', label: 'Layer-2a-Hinweis (Meta)' },
|
||||
{ key: 'show_kpis', label: 'KPI-Kacheln' },
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {{ config: Record<string, unknown>, onChange: (next: Record<string, unknown>) => void }} props
|
||||
*/
|
||||
export default function BodyHistoryVizConfigEditor({ config, onChange }) {
|
||||
const merged = normalizeBodyHistoryVizConfig(config)
|
||||
|
||||
const patch = (partial) => {
|
||||
const next = { ...merged, ...partial }
|
||||
const def = BODY_HISTORY_VIZ_WIDGET_DEFAULTS
|
||||
const stored = {}
|
||||
for (const k of Object.keys(def)) {
|
||||
if (next[k] !== def[k]) stored[k] = next[k]
|
||||
}
|
||||
onChange(stored)
|
||||
}
|
||||
|
||||
const setBool = (key, checked) => {
|
||||
patch({ [key]: checked })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 10, marginLeft: 28 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8, lineHeight: 1.5 }}>
|
||||
<strong>Körper (Verlauf-Bundle):</strong> welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker
|
||||
Standard (nur KPI kompakt + Gewicht).
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>KPI-Umfang</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 10, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="body_hist_kpi_detail"
|
||||
checked={merged.kpi_detail === 'compact'}
|
||||
onChange={() => patch({ kpi_detail: 'compact' })}
|
||||
/>
|
||||
<span>Kompakt (Gewicht, KF%, Magermasse)</span>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 12, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="body_hist_kpi_detail"
|
||||
checked={merged.kpi_detail === 'full'}
|
||||
onChange={() => patch({ kpi_detail: 'full' })}
|
||||
/>
|
||||
<span>Voll (wie Verlauf — alle Kacheln)</span>
|
||||
</label>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>Bereiche</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{OTHER_TOGGLES.map(({ key, label }) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', margin: '12px 0 6px' }}>Charts</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{CHART_TOGGLES.map(({ key, label }) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: 10, fontSize: 12, padding: '6px 12px' }}
|
||||
onClick={() => onChange({})}
|
||||
>
|
||||
Auf schlanken Standard zurück
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
frontend/src/widgetSystem/bodyHistoryVizConfig.js
Normal file
81
frontend/src/widgetSystem/bodyHistoryVizConfig.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Sichtbarkeit / Umfang für body_history_viz (sync mit backend dashboard_widget_config).
|
||||
* `null` / fehlend → Verlauf: alles sichtbar (vollständige Parität zur History-Seite).
|
||||
*/
|
||||
|
||||
/** Verlauf-Tab Körper: volle Parität (kein Layout-Config). */
|
||||
export const BODY_HISTORY_VIZ_HISTORY_FULL = {
|
||||
chart_days: 90,
|
||||
show_goals_strip: true,
|
||||
show_intro_blurb: true,
|
||||
show_layer_meta: true,
|
||||
show_kpis: true,
|
||||
kpi_detail: 'full',
|
||||
show_weight_chart: true,
|
||||
show_body_fat_chart: true,
|
||||
show_proportion_chart: true,
|
||||
show_circumference_index_chart: true,
|
||||
show_circumference_lines_chart: true,
|
||||
}
|
||||
|
||||
/** Default für Dashboard-Widget (schlank). */
|
||||
export const BODY_HISTORY_VIZ_WIDGET_DEFAULTS = {
|
||||
chart_days: 30,
|
||||
show_goals_strip: false,
|
||||
show_intro_blurb: false,
|
||||
show_layer_meta: false,
|
||||
show_kpis: true,
|
||||
kpi_detail: 'compact',
|
||||
show_weight_chart: true,
|
||||
show_body_fat_chart: false,
|
||||
show_proportion_chart: false,
|
||||
show_circumference_index_chart: false,
|
||||
show_circumference_lines_chart: false,
|
||||
}
|
||||
|
||||
const BOOL_KEYS = [
|
||||
'show_goals_strip',
|
||||
'show_intro_blurb',
|
||||
'show_layer_meta',
|
||||
'show_kpis',
|
||||
'show_weight_chart',
|
||||
'show_body_fat_chart',
|
||||
'show_proportion_chart',
|
||||
'show_circumference_index_chart',
|
||||
'show_circumference_lines_chart',
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>|null|undefined} raw — aus Layout-Config (Backend liefert nach Save ggf. alle Keys)
|
||||
* @returns {typeof BODY_HISTORY_VIZ_WIDGET_DEFAULTS}
|
||||
*/
|
||||
export function normalizeBodyHistoryVizConfig(raw) {
|
||||
const base = { ...BODY_HISTORY_VIZ_WIDGET_DEFAULTS }
|
||||
if (!raw || typeof raw !== 'object') return base
|
||||
for (const k of BOOL_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(raw, k)) {
|
||||
base[k] = raw[k] === true
|
||||
}
|
||||
}
|
||||
if (raw.kpi_detail === 'full' || raw.kpi_detail === 'compact') {
|
||||
base.kpi_detail = raw.kpi_detail
|
||||
}
|
||||
if (raw.chart_days != null) {
|
||||
const n = Number(raw.chart_days)
|
||||
if (Number.isFinite(n)) {
|
||||
base.chart_days = Math.min(90, Math.max(7, Math.round(n)))
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
const COMPACT_KPI_KEYS = new Set(['weight', 'bf', 'lean_ffmi'])
|
||||
|
||||
/**
|
||||
* @param {Array<{ key: string }>} tiles
|
||||
* @param {'compact'|'full'} detail
|
||||
*/
|
||||
export function filterBodyHistoryKpiTiles(tiles, detail) {
|
||||
if (detail === 'full' || !Array.isArray(tiles)) return tiles
|
||||
return tiles.filter((t) => COMPACT_KPI_KEYS.has(t.key))
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import TrendKcalWeightWidget from '../components/dashboard-widgets/TrendKcalWeig
|
|||
import NutritionActivitySummaryWidget from '../components/dashboard-widgets/NutritionActivitySummaryWidget'
|
||||
import NutritionDetailChartsWidget from '../components/dashboard-widgets/NutritionDetailChartsWidget'
|
||||
import BodyHistoryVizWidget from '../components/dashboard-widgets/BodyHistoryVizWidget'
|
||||
import { normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig'
|
||||
import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget'
|
||||
import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget'
|
||||
import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget'
|
||||
|
|
@ -63,7 +64,7 @@ export function ensurePilotLabWidgetsRegistered() {
|
|||
Component: BodyHistoryVizWidget,
|
||||
mapProps: (ctx) => ({
|
||||
refreshTick: ctx.refreshTick,
|
||||
chartDays: ctx.layoutEntry?.config?.chart_days,
|
||||
bodyHistoryVizConfig: normalizeBodyHistoryVizConfig(ctx.layoutEntry?.config),
|
||||
}),
|
||||
})
|
||||
registerDashboardWidget({
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user