From 3d498d03c1410c5283fb38afb595849cc1ed5f20 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 7 Apr 2026 14:19:45 +0200 Subject: [PATCH] feat: Enhance dashboard widget configuration and introduce new widgets - Updated the dashboard layout schema to include new widgets: DashboardGreeting, QuickWeightToday, BodyStatStrip, StatusPills, ProfileGoalsProgress, TrendKcalWeight, NutritionActivitySummary, RecoverySleepRest, and TrainingTypeDistribution. - Improved widget configuration validation to support new features, including chart days for trend and distribution widgets. - Refactored the default lab layout to align with the updated widget catalog and ensure proper default activation. - Bumped app_dashboard version to 1.6.0 to reflect the addition of new widgets and configuration enhancements. --- CLAUDE.md | 1 + backend/dashboard_layout_schema.py | 5 +- backend/dashboard_widget_config.py | 26 +- backend/tests/test_dashboard_widget_config.py | 18 ++ backend/tests/test_widget_catalog.py | 6 +- backend/version.py | 2 +- backend/widget_catalog.py | 67 ++++- frontend/src/components/DashboardStatKit.jsx | 134 ++++++++++ frontend/src/components/QuickWeightEntry.jsx | 113 +++++++++ .../src/components/TrendKcalWeightChart.jsx | 183 ++++++++++++++ .../AiPipelineInsightWidget.jsx | 107 ++++++++ .../dashboard-widgets/BodyStatStripWidget.jsx | 108 ++++++++ .../DashboardGreetingWidget.jsx | 34 +++ .../GoalsFocusTeaserWidget.jsx | 47 ++++ .../NutritionActivitySummaryWidget.jsx | 115 +++++++++ .../ProfileGoalsProgressWidget.jsx | 109 ++++++++ .../QuickWeightTodayWidget.jsx | 20 ++ .../RecoverySleepRestWidget.jsx | 20 ++ .../dashboard-widgets/StatusPillsWidget.jsx | 89 +++++++ .../TrainingTypeDistributionWidget.jsx | 25 ++ .../TrendKcalWeightWidget.jsx | 95 +++++++ frontend/src/pages/Dashboard.jsx | 237 +----------------- frontend/src/widgetSystem/defaultLabLayout.js | 6 +- .../widgetSystem/registerPilotLabWidgets.js | 76 ++++++ scripts/gitea/MCP_SETUP.md | 3 + scripts/gitea/README.md | 19 +- scripts/gitea/gitea_api.py | 39 +++ scripts/gitea/mcp_server_gitea.py | 26 +- 28 files changed, 1487 insertions(+), 243 deletions(-) create mode 100644 frontend/src/components/DashboardStatKit.jsx create mode 100644 frontend/src/components/QuickWeightEntry.jsx create mode 100644 frontend/src/components/TrendKcalWeightChart.jsx create mode 100644 frontend/src/components/dashboard-widgets/AiPipelineInsightWidget.jsx create mode 100644 frontend/src/components/dashboard-widgets/BodyStatStripWidget.jsx create mode 100644 frontend/src/components/dashboard-widgets/DashboardGreetingWidget.jsx create mode 100644 frontend/src/components/dashboard-widgets/GoalsFocusTeaserWidget.jsx create mode 100644 frontend/src/components/dashboard-widgets/NutritionActivitySummaryWidget.jsx create mode 100644 frontend/src/components/dashboard-widgets/ProfileGoalsProgressWidget.jsx create mode 100644 frontend/src/components/dashboard-widgets/QuickWeightTodayWidget.jsx create mode 100644 frontend/src/components/dashboard-widgets/RecoverySleepRestWidget.jsx create mode 100644 frontend/src/components/dashboard-widgets/StatusPillsWidget.jsx create mode 100644 frontend/src/components/dashboard-widgets/TrainingTypeDistributionWidget.jsx create mode 100644 frontend/src/components/dashboard-widgets/TrendKcalWeightWidget.jsx diff --git a/CLAUDE.md b/CLAUDE.md index 6f9408c..60e3b6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ - ✅ Bestehende Issues aktualisieren (Status, Beschreibung) - ✅ Issues bei Fertigstellung schließen - 🎯 Gitea: http://192.168.2.144:3000/Lars/mitai-jinkendo/issues +- Gitea **MCP** vs **CLI**: kurze Lese-/Kommentar-/PATCH-Vorgänge im Agent über MCP (`gitea_*`); **Beschreibung aus Datei**, sehr lange Bodies oder Skripte → `python scripts/gitea/gitea_api.py issues edit … --body-file` — `scripts/gitea/README.md` **Dokumentation:** - Code-Änderungen in CLAUDE.md dokumentieren diff --git a/backend/dashboard_layout_schema.py b/backend/dashboard_layout_schema.py index 60b099c..de3fced 100644 --- a/backend/dashboard_layout_schema.py +++ b/backend/dashboard_layout_schema.py @@ -10,7 +10,7 @@ from typing import Any, Literal from pydantic import BaseModel, Field, field_validator, model_validator from dashboard_widget_config import validate_widget_entry_config -from widget_catalog import ALLOWED_WIDGET_IDS, WIDGET_CATALOG +from widget_catalog import ALLOWED_WIDGET_IDS, DEFAULT_LAB_WIDGET_IDS, WIDGET_CATALOG # Abwärtskompatibel (Tests importieren weiterhin aus diesem Modul) __all__ = [ @@ -23,9 +23,10 @@ __all__ = [ def default_layout_dict() -> dict[str, Any]: + on = DEFAULT_LAB_WIDGET_IDS return { "version": 1, - "widgets": [{"id": e["id"], "enabled": True} for e in WIDGET_CATALOG], + "widgets": [{"id": e["id"], "enabled": e["id"] in on} for e in WIDGET_CATALOG], } diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py index 273e387..432df73 100644 --- a/backend/dashboard_widget_config.py +++ b/backend/dashboard_widget_config.py @@ -12,7 +12,13 @@ from typing import Any MAX_WIDGET_CONFIG_JSON_BYTES = 3072 -WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({"body_overview", "activity_overview", "kpi_board"}) +WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({ + "body_overview", + "activity_overview", + "kpi_board", + "trend_kcal_weight", + "training_type_distribution", +}) _KPI_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"}) _KPI_REF_TILE_RE = re.compile(r"^ref:[a-z0-9_]{1,64}$") @@ -41,6 +47,10 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: return _validate_chart_days_only(raw, label="activity_overview") if widget_id == "kpi_board": return _validate_kpi_board_config(raw) + if widget_id == "trend_kcal_weight": + return _validate_chart_days_only(raw, label="trend_kcal_weight") + if widget_id == "training_type_distribution": + return _validate_distribution_days_only(raw) raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt") @@ -118,3 +128,17 @@ def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, A if v < 7 or v > 90: raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen") return {"chart_days": v} + + +def _validate_distribution_days_only(raw: dict[str, Any]) -> dict[str, Any]: + label = "training_type_distribution" + allowed = frozenset({"distribution_days"}) + unknown = set(raw) - allowed + if unknown: + raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}") + if "distribution_days" not in raw: + return {} + v = _parse_chart_days(raw["distribution_days"], label) + if v < 7 or v > 120: + raise ValueError(f"{label}: distribution_days muss zwischen 7 und 120 liegen") + return {"distribution_days": v} diff --git a/backend/tests/test_dashboard_widget_config.py b/backend/tests/test_dashboard_widget_config.py index f0eb5e5..875238b 100644 --- a/backend/tests/test_dashboard_widget_config.py +++ b/backend/tests/test_dashboard_widget_config.py @@ -45,6 +45,24 @@ def test_kpi_board_tiles(): validate_widget_entry_config("kpi_board", {"extra": 1}) +def test_trend_kcal_weight_chart_days(): + assert validate_widget_entry_config("trend_kcal_weight", {}) == {} + assert validate_widget_entry_config("trend_kcal_weight", {"chart_days": 30}) == {"chart_days": 30} + with pytest.raises(ValueError): + validate_widget_entry_config("trend_kcal_weight", {"chart_days": 6}) + + +def test_training_type_distribution_days(): + assert validate_widget_entry_config("training_type_distribution", {}) == {} + assert validate_widget_entry_config( + "training_type_distribution", {"distribution_days": 28} + ) == {"distribution_days": 28} + with pytest.raises(ValueError): + validate_widget_entry_config("training_type_distribution", {"distribution_days": 5}) + with pytest.raises(ValueError): + validate_widget_entry_config("training_type_distribution", {"distribution_days": 200}) + + def test_kpi_board_legacy_chart_days_dropped(): """Nur chart_days (Alt-Layouts) → automatische Kachelwahl, kein Ø-Kal-Fenster mehr.""" assert validate_widget_entry_config("kpi_board", {"chart_days": 14}) == {} diff --git a/backend/tests/test_widget_catalog.py b/backend/tests/test_widget_catalog.py index 25e186d..bf1e7d6 100644 --- a/backend/tests/test_widget_catalog.py +++ b/backend/tests/test_widget_catalog.py @@ -1,7 +1,7 @@ """Widget-Katalog: Konsistenz (IDs, Default-Layout, Katalog-Response).""" from dashboard_layout_schema import default_layout_dict -from widget_catalog import ALLOWED_WIDGET_IDS, WIDGET_CATALOG, catalog_response +from widget_catalog import ALLOWED_WIDGET_IDS, DEFAULT_LAB_WIDGET_IDS, WIDGET_CATALOG, catalog_response def test_catalog_ids_unique_and_match_allowed(): @@ -15,7 +15,9 @@ def test_default_layout_follows_catalog_order(): assert d["version"] == 1 got = [w["id"] for w in d["widgets"]] assert got == [e["id"] for e in WIDGET_CATALOG] - assert all(w["enabled"] is True for w in d["widgets"]) + enabled_ids = {w["id"] for w in d["widgets"] if w["enabled"]} + assert enabled_ids == DEFAULT_LAB_WIDGET_IDS + assert any(w["enabled"] for w in d["widgets"]) def test_catalog_response_shape(): diff --git a/backend/version.py b/backend/version.py index 1988fe4..379d111 100644 --- a/backend/version.py +++ b/backend/version.py @@ -30,7 +30,7 @@ MODULE_VERSIONS = { "importdata": "1.0.0", "membership": "2.1.0", "workflow": "0.6.0", # Phase 4: End Node Template Engine - "app_dashboard": "1.5.0", # kpi_board: Kachelwahl tiles statt chart_days + "app_dashboard": "1.6.0", # P1 Produkt-Widgets im Katalog + Default nur Kern-5 aktiv } CHANGELOG = [ diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py index cb16c5a..80fbda6 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -15,7 +15,7 @@ class WidgetCatalogEntry(TypedDict): description: str -# Reihenfolge in der Liste = Standard-Layout (alle default_enabled: True im Default-Layout) +# Reihenfolge = Default-Layout-Reihenfolge. Aktiv-Flags: DEFAULT_LAB_WIDGET_IDS (Rest zunächst aus). WIDGET_CATALOG: list[WidgetCatalogEntry] = [ { "id": "welcome", @@ -42,8 +42,73 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [ "title": "Aktivität", "description": "Training & Konsistenz (optional: config chart_days 7–90)", }, + { + "id": "dashboard_greeting", + "title": "Begrüßung (Produkt)", + "description": "Hallo, Datum & letztes Gewicht-Update", + }, + { + "id": "quick_weight_today", + "title": "Gewicht heute", + "description": "Tagesgewicht erfassen (wie Produkt-Dashboard)", + }, + { + "id": "body_stat_strip", + "title": "Kennzahlen-Kacheln", + "description": "Gewicht, KF, Magermasse, Ø-kcal — Oberreihe", + }, + { + "id": "status_pills", + "title": "Indikatoren (Pills)", + "description": "WHR, WHtR, Protein, KF", + }, + { + "id": "profile_goals_progress", + "title": "Profil-Ziele", + "description": "Fortschritt Gewicht/Körperfett aus Profilfeldern", + }, + { + "id": "trend_kcal_weight", + "title": "Trend Kalorien + Gewicht", + "description": "Linienchart (optional config chart_days 7–90, Default 30)", + }, + { + "id": "nutrition_activity_summary", + "title": "Ernährung & Aktivität Kurz", + "description": "Ø 7T Kacheln", + }, + { + "id": "recovery_sleep_rest", + "title": "Erholung", + "description": "Schlaf-Widget & Ruhetage", + }, + { + "id": "training_type_distribution", + "title": "Training Verteilung", + "description": "Kuchen Trainingstypen (optional config distribution_days 7–120, Default 28)", + }, + { + "id": "goals_focus_teaser", + "title": "Ziele Teaser", + "description": "Kurzlink zur Ziele-Seite", + }, + { + "id": "ai_pipeline_insight", + "title": "KI Pipeline & letzte Analyse", + "description": "Pipeline starten + Gesamt-Insight", + }, ] +DEFAULT_LAB_WIDGET_IDS: frozenset[str] = frozenset( + { + "welcome", + "quick_capture", + "kpi_board", + "body_overview", + "activity_overview", + } +) + ALLOWED_WIDGET_IDS: frozenset[str] = frozenset(e["id"] for e in WIDGET_CATALOG) diff --git a/frontend/src/components/DashboardStatKit.jsx b/frontend/src/components/DashboardStatKit.jsx new file mode 100644 index 0000000..33b7d5d --- /dev/null +++ b/frontend/src/components/DashboardStatKit.jsx @@ -0,0 +1,134 @@ +import { useState } from 'react' +import { + clampTileSpan, + DASHBOARD_TILE_GRID_COLS, +} from '../utils/dashboardLayout' + +export const PILL_TOOLTIPS = { + WHR: 'Waist-Hip-Ratio: Taille ÷ Hüfte. Maß für Bauchfettverteilung. Ziel: <0,90 (M) / <0,85 (F)', + WHtR: 'Waist-to-Height-Ratio: Taille ÷ Körpergröße. Gesündestest Maß: Ziel unter 0,50.', + KF: 'Körperfettanteil in Prozent (aus Caliper-Messung).', + 'Protein Ø7T': + 'Durchschnittliche tägliche Proteinaufnahme der letzten 7 Tage vs. Zielbereich (1,6–2,2g/kg KG).', +} + +export function Pill({ label, value, status, sub }) { + const [tip, setTip] = useState(false) + const color = status === 'good' ? 'var(--accent)' : status === 'warn' ? 'var(--warn)' : '#D85A30' + const bg = + status === 'good' + ? 'var(--accent-light)' + : status === 'warn' + ? 'var(--warn-bg)' + : '#FCEBEB' + const tipText = PILL_TOOLTIPS[label] + return ( +
+
tipText && setTip((s) => !s)} + onKeyDown={(e) => tipText && e.key === 'Enter' && setTip((s) => !s)} + tabIndex={tipText ? 0 : undefined} + style={{ + display: 'flex', + alignItems: 'center', + gap: 5, + padding: '5px 10px', + borderRadius: 20, + background: bg, + border: `1px solid ${color}44`, + cursor: tipText ? 'help' : 'default', + }} + > +
+ {label} + {value} + {sub && {sub}} + {tipText && ( + + ⓘ + + )} +
+ {tip && tipText && ( +
setTip(false)} + style={{ + position: 'absolute', + bottom: '110%', + left: 0, + zIndex: 50, + background: 'var(--surface)', + border: '1px solid var(--border)', + borderRadius: 8, + padding: '8px 10px', + fontSize: 11, + color: 'var(--text2)', + minWidth: 200, + maxWidth: 260, + lineHeight: 1.5, + boxShadow: '0 4px 16px rgba(0,0,0,0.15)', + }} + > + {label} +
+ {tipText} +
+ )} +
+ ) +} + +/** + * KPI-Kachel (Dashboard-Raster). + */ +export function StatCard({ + icon, + label, + value, + unit, + delta, + deltaGoodWhenNeg = false, + sub, + onClick, + color, + spanMobile = 1, + spanDesktop = 1, +}) { + const deltaColor = + delta == null + ? null + : (deltaGoodWhenNeg ? delta < 0 : delta > 0) + ? 'var(--accent)' + : 'var(--warn)' + const sm = clampTileSpan(spanMobile, DASHBOARD_TILE_GRID_COLS.mobile) + const lg = clampTileSpan(spanDesktop, DASHBOARD_TILE_GRID_COLS.desktop) + return ( +
onClick && (e.currentTarget.style.borderColor = 'var(--accent)')} + onMouseLeave={(e) => onClick && (e.currentTarget.style.borderColor = 'var(--border)')} + > +
{icon}
+
{label}
+
+ {value} + {unit} +
+ {delta != null && ( +
+ {delta > 0 ? '+' : ''} + {delta} {unit} +
+ )} + {sub &&
{sub}
} +
+ ) +} diff --git a/frontend/src/components/QuickWeightEntry.jsx b/frontend/src/components/QuickWeightEntry.jsx new file mode 100644 index 0000000..9a05926 --- /dev/null +++ b/frontend/src/components/QuickWeightEntry.jsx @@ -0,0 +1,113 @@ +import { useState, useEffect } from 'react' +import { Check } from 'lucide-react' +import dayjs from 'dayjs' +import { api } from '../utils/api' + +/** + * Tagesgewicht erfassen (wie Dashboard „Gewicht heute“). + */ +export default function QuickWeightEntry({ onSaved }) { + const [input, setInput] = useState('') + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + const [weightUsage, setWeightUsage] = useState(null) + const today = dayjs().format('YYYY-MM-DD') + + const loadUsage = () => { + api + .getFeatureUsage() + .then((features) => { + const weightFeature = features.find((f) => f.feature_id === 'weight_entries') + setWeightUsage(weightFeature) + }) + .catch((err) => console.error('Failed to load usage:', err)) + } + + useEffect(() => { + api.weightStats().then((s) => { + if (s?.latest?.date === today) setInput(String(s.latest.weight)) + }) + loadUsage() + }, [today]) + + const handleSave = async () => { + const w = parseFloat(input) + if (!w || w < 20 || w > 300) return + setSaving(true) + setError(null) + try { + await api.upsertWeight(today, w) + setSaved(true) + await loadUsage() + onSaved?.() + setTimeout(() => setSaved(false), 2000) + } catch (err) { + console.error('Save failed:', err) + setError(err.message || 'Fehler beim Speichern') + setTimeout(() => setError(null), 5000) + } finally { + setSaving(false) + } + } + + const isDisabled = saving || !input || (weightUsage && !weightUsage.allowed) + const tooltipText = + weightUsage && !weightUsage.allowed + ? `Limit erreicht (${weightUsage.used}/${weightUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` + : '' + + return ( +
+ {error && ( +
+ {error} +
+ )} +
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !isDisabled && handleSave()} + /> + kg +
+ +
+
+
+ ) +} diff --git a/frontend/src/components/TrendKcalWeightChart.jsx b/frontend/src/components/TrendKcalWeightChart.jsx new file mode 100644 index 0000000..3637e25 --- /dev/null +++ b/frontend/src/components/TrendKcalWeightChart.jsx @@ -0,0 +1,183 @@ +import { + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, +} from 'recharts' +import dayjs from 'dayjs' +import 'dayjs/locale/de' + +dayjs.locale('de') + +function rollingAvg(arr, key, w = 7) { + return arr.map((d, i) => { + const s = arr + .slice(Math.max(0, i - w + 1), i + 1) + .map((x) => x[key]) + .filter((v) => v != null) + return s.length + ? { + ...d, + [`${key}_avg`]: Math.round((s.reduce((a, b) => a + b) / s.length) * 10) / 10, + } + : d + }) +} + +/** + * Kalorien + Gewicht im Zeitfenster (wie Dashboard-Trends). + * @param {{ weights: any[], nutrition: any[], windowDays?: number }} props + */ +export default function TrendKcalWeightChart({ weights, nutrition, windowDays = 30 }) { + const n = Math.max(7, Math.min(90, Number(windowDays) || 30)) + const days = [] + for (let i = n - 1; i >= 0; i--) days.push(dayjs().subtract(i, 'day').format('YYYY-MM-DD')) + + const wMap = {} + ;(weights || []).forEach((w) => { + wMap[w.date] = w.weight + }) + const nMap = {} + ;(nutrition || []).forEach((x) => { + nMap[x.date] = Math.round(x.kcal || 0) + }) + + let lastW = null + const combined = days + .map((date) => { + if (wMap[date]) lastW = wMap[date] + return { + date: dayjs(date).format('DD.MM'), + kcal: nMap[date] || null, + weight: wMap[date] || null, + weightLine: lastW, + } + }) + .filter((d) => d.kcal || d.weightLine) + + const withAvg = rollingAvg(combined, 'kcal') + const hasKcal = combined.some((d) => d.kcal) + const hasW = combined.some((d) => d.weightLine) + + if (!hasKcal && !hasW) { + return ( +
+ Mehr Ernährungs- und Gewichtsdaten für den Chart nötig +
+ ) + } + + return ( + + + + + {hasKcal && ( + + )} + {hasW && ( + + )} + [ + v == null ? '–' : `${Math.round(v)} ${name === 'weightLine' || name === 'weight' ? 'kg' : 'kcal'}`, + name === 'kcal_avg' + ? 'Ø Kalorien (7T)' + : name === 'kcal' + ? 'Kalorien' + : name === 'weightLine' + ? 'Gewicht (interpoliert)' + : 'Gewicht Messung', + ]} + /> + {hasKcal && ( + + )} + {hasKcal && ( + + )} + {hasW && ( + + )} + {hasW && ( + { + const { cx, cy, value } = props + return value != null ? ( + + ) : ( + + ) + }} + connectNulls={false} + name="weight" + /> + )} + + + ) +} diff --git a/frontend/src/components/dashboard-widgets/AiPipelineInsightWidget.jsx b/frontend/src/components/dashboard-widgets/AiPipelineInsightWidget.jsx new file mode 100644 index 0000000..7619cfa --- /dev/null +++ b/frontend/src/components/dashboard-widgets/AiPipelineInsightWidget.jsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Brain } from 'lucide-react' +import dayjs from 'dayjs' +import 'dayjs/locale/de' +import { api } from '../../utils/api' +import Markdown from '../../utils/Markdown' + +dayjs.locale('de') + +export default function AiPipelineInsightWidget({ refreshTick = 0 }) { + const nav = useNavigate() + const [insights, setInsights] = useState([]) + const [showInsight, setShowInsight] = useState(false) + const [pipelineLoading, setPipelineLoading] = useState(false) + const [pipelineError, setPipelineError] = useState(null) + + const load = () => + api.latestInsights().then((ins) => setInsights(Array.isArray(ins) ? ins : [])).catch(() => setInsights([])) + + useEffect(() => { + load() + }, [refreshTick]) + + const runPipeline = async () => { + setPipelineLoading(true) + setPipelineError(null) + try { + await api.insightPipeline() + await load() + } catch (e) { + setPipelineError(`Fehler: ${e.message}`) + } finally { + setPipelineLoading(false) + } + } + + const latestInsight = insights.find((i) => i.scope === 'gesamt') || insights[0] + + return ( +
+
+
KI-Auswertung
+ +
+ + {pipelineError &&
{pipelineError}
} + + {latestInsight ? ( + <> +
+ Letzte Analyse: {dayjs(latestInsight.created).format('DD. MMMM YYYY, HH:mm')} +
+
+ + {!showInsight && ( +
+ )} +
+ + + ) : ( +
+ Noch keine KI-Auswertung vorhanden. + +
+ )} +
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/BodyStatStripWidget.jsx b/frontend/src/components/dashboard-widgets/BodyStatStripWidget.jsx new file mode 100644 index 0000000..f41e933 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/BodyStatStripWidget.jsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import dayjs from 'dayjs' +import { api } from '../../utils/api' +import { useProfile } from '../../context/ProfileContext' +import { getBfCategory } from '../../utils/calc' +import { StatCard } from '../DashboardStatKit' +import { dashboardStatGridClassName, DASHBOARD_TILE_GRID_COLS } from '../../utils/dashboardLayout' + +export default function BodyStatStripWidget({ refreshTick = 0 }) { + const nav = useNavigate() + const { activeProfile } = useProfile() + const sex = activeProfile?.sex || 'm' + const [weights, setWeights] = useState([]) + const [calipers, setCalipers] = useState([]) + const [nutrition, setNutrition] = useState([]) + + useEffect(() => { + Promise.all([api.listWeight(60), api.listCaliper(3), api.listNutrition(30)]) + .then(([w, ca, n]) => { + setWeights(w) + setCalipers(ca) + setNutrition(n) + }) + .catch(() => { + setWeights([]) + setCalipers([]) + setNutrition([]) + }) + }, [refreshTick]) + + const latestW = weights[0] + const prevW = weights[1] + const latestCal = calipers[0] + const wDelta = latestW && prevW ? Math.round((latestW.weight - prevW.weight) * 10) / 10 : null + const bfCat = latestCal?.body_fat_pct ? getBfCategory(latestCal.body_fat_pct, sex) : null + const bfPrev = calipers[1]?.body_fat_pct + const bfDelta = + latestCal?.body_fat_pct && bfPrev ? Math.round((latestCal.body_fat_pct - bfPrev) * 10) / 10 : null + + const recentNutr = nutrition.filter((n) => n.date >= dayjs().subtract(7, 'day').format('YYYY-MM-DD')) + const avgKcal = recentNutr.length + ? Math.round(recentNutr.reduce((s, n) => s + (n.kcal || 0), 0) / recentNutr.length) + : null + + if (!latestW && !latestCal?.body_fat_pct && !avgKcal) { + return ( +
+ Noch keine Kennzahlen – erfasse Gewicht oder Körperdaten. +
+ ) + } + + return ( +
+
Kennzahlen
+
+ {latestW && ( + nav('/history')} + color="#378ADD" + /> + )} + {latestCal?.body_fat_pct != null && ( + nav('/history', { state: { tab: 'body' } })} + color={bfCat?.color} + /> + )} + {latestCal?.lean_mass != null && ( + nav('/history', { state: { tab: 'body' } })} + /> + )} + {avgKcal != null && ( + nav('/history', { state: { tab: 'nutrition' } })} + color="#EF9F27" + /> + )} +
+
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/DashboardGreetingWidget.jsx b/frontend/src/components/dashboard-widgets/DashboardGreetingWidget.jsx new file mode 100644 index 0000000..6d2bdb4 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/DashboardGreetingWidget.jsx @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react' +import dayjs from 'dayjs' +import 'dayjs/locale/de' +import { useProfile } from '../../context/ProfileContext' +import { api } from '../../utils/api' + +dayjs.locale('de') + +/** Produkt-Dashboard: Begrüßung + Datum + letztes Gewicht-Datum */ +export default function DashboardGreetingWidget({ refreshTick = 0 }) { + const { activeProfile } = useProfile() + const [latestWeightDate, setLatestWeightDate] = useState(null) + + useEffect(() => { + api + .listWeight(1) + .then((rows) => { + setLatestWeightDate(rows?.[0]?.date || null) + }) + .catch(() => setLatestWeightDate(null)) + }, [refreshTick]) + + return ( +
+

+ Hallo, {activeProfile?.name || 'Nutzer'} 👋 +

+
+ {dayjs().format('dddd, DD. MMMM YYYY')} + {latestWeightDate && ` · Letztes Update ${dayjs(latestWeightDate).format('DD.MM.')}`} +
+
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/GoalsFocusTeaserWidget.jsx b/frontend/src/components/dashboard-widgets/GoalsFocusTeaserWidget.jsx new file mode 100644 index 0000000..ae27db4 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/GoalsFocusTeaserWidget.jsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useProfile } from '../../context/ProfileContext' +import { api } from '../../utils/api' + +export default function GoalsFocusTeaserWidget({ refreshTick = 0 }) { + const nav = useNavigate() + const { activeProfile } = useProfile() + const [goalsCount, setGoalsCount] = useState(null) + + useEffect(() => { + if (!activeProfile?.id) return + api + .listGoals() + .then((list) => setGoalsCount(Array.isArray(list) ? list.length : 0)) + .catch(() => setGoalsCount(null)) + }, [activeProfile?.id, refreshTick]) + + return ( +
+
+
Ziele & Fokus
+ +
+
e.key === 'Enter' && nav('/goals')} + onClick={() => nav('/goals')} + > + {goalsCount != null && ( +
+ {goalsCount === 0 + ? 'Noch keine Ziele angelegt.' + : `${goalsCount} ${goalsCount === 1 ? 'Ziel' : 'Ziele'} im System.`} +
+ )} +
+ Focus Areas und Fortschritt – tippen zum Öffnen der Ziele-Seite. +
+
+
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/NutritionActivitySummaryWidget.jsx b/frontend/src/components/dashboard-widgets/NutritionActivitySummaryWidget.jsx new file mode 100644 index 0000000..94d12fd --- /dev/null +++ b/frontend/src/components/dashboard-widgets/NutritionActivitySummaryWidget.jsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import dayjs from 'dayjs' +import { api } from '../../utils/api' +import { + dashboardTileGridClassName, + DASHBOARD_TILE_GRID_COLS, +} from '../../utils/dashboardLayout' +import DashboardTile from '../DashboardTile' + +export default function NutritionActivitySummaryWidget({ refreshTick = 0 }) { + const nav = useNavigate() + const [nutrition, setNutrition] = useState([]) + const [activities, setActivities] = useState([]) + const [latestWeight, setLatestWeight] = useState(null) + + useEffect(() => { + Promise.all([api.listNutrition(30), api.listActivity(800, 30), api.listWeight(1)]) + .then(([n, a, w]) => { + setNutrition(n) + setActivities(a) + setLatestWeight(w?.[0]?.weight ?? null) + }) + .catch(() => { + setNutrition([]) + setActivities([]) + setLatestWeight(null) + }) + }, [refreshTick]) + + const recentNutr = nutrition.filter((n) => n.date >= dayjs().subtract(7, 'day').format('YYYY-MM-DD')) + const avgKcal = recentNutr.length + ? Math.round(recentNutr.reduce((s, n) => s + (n.kcal || 0), 0) / recentNutr.length) + : null + const avgProtein = recentNutr.length + ? Math.round(recentNutr.reduce((s, n) => s + (n.protein_g || 0), 0) / recentNutr.length * 10) / 10 + : null + const ptLow = Math.round((latestWeight || 80) * 1.6) + const proteinOk = avgProtein && avgProtein >= ptLow + + const recentAct = activities.filter((a) => a.date >= dayjs().subtract(7, 'day').format('YYYY-MM-DD')) + const actKcal = recentAct.length ? Math.round(recentAct.reduce((s, a) => s + (a.kcal_active || 0), 0)) : null + + const showNutr = !!(avgKcal || avgProtein) + const showAct = actKcal != null + if (!showNutr && !showAct) { + return ( +
+ Noch keine Ernährungs- oder Aktivitätsdaten (7 Tage). +
+ ) + } + + const summaryBoth = showNutr && showAct + const summarySpanM = summaryBoth ? 1 : 2 + const summarySpanD = summaryBoth ? 2 : 4 + + return ( +
+
+ Ernährung & Aktivität +
+
+ {showNutr && ( + +
e.key === 'Enter' && nav('/history', { state: { tab: 'nutrition' } })} + onClick={() => nav('/history', { state: { tab: 'nutrition' } })} + > +
+ 🍽️ ERNÄHRUNG (Ø 7T) +
+ {avgKcal != null &&
{avgKcal} kcal
} + {avgProtein != null && ( +
+ {avgProtein}g Protein {proteinOk ? '✓' : '⚠️'} +
+ )} +
→ Verlauf Ernährung
+
+
+ )} + {showAct && ( + +
e.key === 'Enter' && nav('/history', { state: { tab: 'activity' } })} + onClick={() => nav('/history', { state: { tab: 'activity' } })} + > +
+ 🏋️ AKTIVITÄT (7T) +
+
{actKcal} kcal
+
{recentAct.length} Trainings
+
→ Verlauf Aktivität
+
+
+ )} +
+
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/ProfileGoalsProgressWidget.jsx b/frontend/src/components/dashboard-widgets/ProfileGoalsProgressWidget.jsx new file mode 100644 index 0000000..d8fa644 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/ProfileGoalsProgressWidget.jsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from 'react' +import { useProfile } from '../../context/ProfileContext' +import { getBfCategory } from '../../utils/calc' +import { api } from '../../utils/api' + +/** Profil-Ziele Gewicht / Körperfett (Balken wie Dashboard) */ +export default function ProfileGoalsProgressWidget({ refreshTick = 0 }) { + const { activeProfile } = useProfile() + const sex = activeProfile?.sex || 'm' + const [weights, setWeights] = useState([]) + const [calipers, setCalipers] = useState([]) + + useEffect(() => { + Promise.all([api.listWeight(120), api.listCaliper(3)]) + .then(([w, ca]) => { + setWeights(w) + setCalipers(ca) + }) + .catch(() => { + setWeights([]) + setCalipers([]) + }) + }, [refreshTick]) + + const latestW = weights[0] + const latestCal = calipers[0] + const bfCat = latestCal?.body_fat_pct ? getBfCategory(latestCal.body_fat_pct, sex) : null + + const gw = activeProfile?.goal_weight + const gbf = activeProfile?.goal_bf_pct + if ((!gw || !latestW) && (!gbf || latestCal?.body_fat_pct == null)) return null + + return ( +
+
Profil-Ziele
+ {gw && latestW && ( +
+ {(() => { + const start = Math.max(...weights.map((w) => w.weight)) + const curr = latestW.weight + const goal = gw + const total = start - goal + const done = start - curr + const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 100 + const remain = Math.round((curr - goal) * 10) / 10 + return ( + <> +
+ + Gewicht: {curr} → {goal} kg + + + {remain > 0 ? `noch ${remain}kg` : 'Ziel erreicht! 🎉'} + +
+
+
+
+
{pct}% des Weges
+ + ) + })()} +
+ )} + {gbf && latestCal?.body_fat_pct != null && ( +
+ {(() => { + const curr = latestCal.body_fat_pct + const goal = gbf + const remain = Math.round((curr - goal) * 10) / 10 + const pct = + curr <= goal ? 100 : Math.min(100, Math.round((1 - (curr - goal) / Math.max(curr - goal, 5)) * 100)) + return ( + <> +
+ + Körperfett: {curr}% → {goal}% + + + {remain > 0 ? `noch ${remain}%` : 'Ziel erreicht! 🎉'} + +
+
+
+
+
Aktuell: {bfCat?.label}
+ + ) + })()} +
+ )} +
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/QuickWeightTodayWidget.jsx b/frontend/src/components/dashboard-widgets/QuickWeightTodayWidget.jsx new file mode 100644 index 0000000..c46c9ff --- /dev/null +++ b/frontend/src/components/dashboard-widgets/QuickWeightTodayWidget.jsx @@ -0,0 +1,20 @@ +import { useNavigate } from 'react-router-dom' +import QuickWeightEntry from '../QuickWeightEntry' + +export default function QuickWeightTodayWidget({ onSaved }) { + const nav = useNavigate() + return ( +
+
+
+
Gewicht heute
+
Tageswert erfassen
+
+ +
+ +
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/RecoverySleepRestWidget.jsx b/frontend/src/components/dashboard-widgets/RecoverySleepRestWidget.jsx new file mode 100644 index 0000000..17500b4 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/RecoverySleepRestWidget.jsx @@ -0,0 +1,20 @@ +import DashboardTile from '../DashboardTile' +import SleepWidget from '../SleepWidget' +import RestDaysWidget from '../RestDaysWidget' +import { dashboardTileGridClassName, DASHBOARD_TILE_GRID_COLS } from '../../utils/dashboardLayout' + +export default function RecoverySleepRestWidget() { + return ( +
+
Erholung
+
+ + + + + + +
+
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/StatusPillsWidget.jsx b/frontend/src/components/dashboard-widgets/StatusPillsWidget.jsx new file mode 100644 index 0000000..4db5fe2 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/StatusPillsWidget.jsx @@ -0,0 +1,89 @@ +import { useEffect, useState } from 'react' +import dayjs from 'dayjs' +import { api } from '../../utils/api' +import { useProfile } from '../../context/ProfileContext' +import { getBfCategory } from '../../utils/calc' +import { Pill } from '../DashboardStatKit' + +/** WHR, WHtR, Protein Ø7T, KF – wie Dashboard-Pill-Leiste */ +export default function StatusPillsWidget({ refreshTick = 0 }) { + const { activeProfile } = useProfile() + const sex = activeProfile?.sex || 'm' + const height = activeProfile?.height || 178 + const [weights, setWeights] = useState([]) + const [calipers, setCalipers] = useState([]) + const [circs, setCircs] = useState([]) + const [nutrition, setNutrition] = useState([]) + + useEffect(() => { + Promise.all([api.listWeight(2), api.listCaliper(3), api.listCirc(2), api.listNutrition(30)]) + .then(([w, ca, ci, n]) => { + setWeights(w) + setCalipers(ca) + setCircs(ci) + setNutrition(n) + }) + .catch(() => { + setWeights([]) + setCalipers([]) + setCircs([]) + setNutrition([]) + }) + }, [refreshTick]) + + const latestCal = calipers[0] + const latestCir = circs[0] + const latestW = weights[0] + + const recentNutr = nutrition.filter((n) => n.date >= dayjs().subtract(7, 'day').format('YYYY-MM-DD')) + const avgProtein = recentNutr.length + ? Math.round(recentNutr.reduce((s, n) => s + (n.protein_g || 0), 0) / recentNutr.length * 10) / 10 + : null + const ptLow = Math.round((latestW?.weight || 80) * 1.6) + const proteinOk = avgProtein && avgProtein >= ptLow + + const whr = + latestCir?.c_waist && latestCir?.c_hip + ? Math.round((latestCir.c_waist / latestCir.c_hip) * 100) / 100 + : null + const whtr = + latestCir?.c_waist && height ? Math.round((latestCir.c_waist / height) * 100) / 100 : null + const bfCat = latestCal?.body_fat_pct ? getBfCategory(latestCal.body_fat_pct, sex) : null + + const pills = [] + if (whr) + pills.push({ + label: 'WHR', + value: whr, + status: whr < (sex === 'm' ? 0.9 : 0.85) ? 'good' : 'warn', + sub: `<${sex === 'm' ? '0,90' : '0,85'}`, + }) + if (whtr) pills.push({ label: 'WHtR', value: whtr, status: whtr < 0.5 ? 'good' : 'warn', sub: '<0,50' }) + if (avgProtein) + pills.push({ + label: 'Protein Ø7T', + value: `${avgProtein}g`, + status: proteinOk ? 'good' : 'warn', + sub: `Ziel ${ptLow}g`, + }) + if (bfCat && latestCal?.body_fat_pct != null) + pills.push({ + label: 'KF', + value: `${latestCal.body_fat_pct}%`, + status: latestCal.body_fat_pct < (sex === 'm' ? 18 : 25) ? 'good' : 'warn', + sub: bfCat.label, + }) + + if (pills.length === 0) return null + + return ( +
+
Indikatoren
+
+ {pills.map((p, i) => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/TrainingTypeDistributionWidget.jsx b/frontend/src/components/dashboard-widgets/TrainingTypeDistributionWidget.jsx new file mode 100644 index 0000000..2ab05ac --- /dev/null +++ b/frontend/src/components/dashboard-widgets/TrainingTypeDistributionWidget.jsx @@ -0,0 +1,25 @@ +import { useNavigate } from 'react-router-dom' +import TrainingTypeDistribution from '../TrainingTypeDistribution' + +/** + * @param {{ refreshTick?: number, distributionDays?: number }} props + */ +export default function TrainingTypeDistributionWidget({ refreshTick = 0, distributionDays = 28 }) { + const nav = useNavigate() + const days = Math.max(7, Math.min(120, Number(distributionDays) || 28)) + + return ( +
+
+
+
Training
+
Verteilung der Trainingstypen ({days} Tage)
+
+ +
+ +
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/TrendKcalWeightWidget.jsx b/frontend/src/components/dashboard-widgets/TrendKcalWeightWidget.jsx new file mode 100644 index 0000000..754aefd --- /dev/null +++ b/frontend/src/components/dashboard-widgets/TrendKcalWeightWidget.jsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { api } from '../../utils/api' +import TrendKcalWeightChart from '../TrendKcalWeightChart' +import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays' + +/** + * @param {{ refreshTick?: number, chartDays?: number }} props + */ +export default function TrendKcalWeightWidget({ refreshTick = 0, chartDays }) { + const nav = useNavigate() + const windowDays = chartDays != null ? normalizeBodyChartDays(chartDays) : 30 + const fetchNutritionDays = Math.max(windowDays, 30) + const [weights, setWeights] = useState([]) + const [nutrition, setNutrition] = useState([]) + + useEffect(() => { + Promise.all([api.listWeight(Math.max(60, windowDays + 30)), api.listNutrition(fetchNutritionDays)]) + .then(([w, n]) => { + setWeights(w) + setNutrition(n) + }) + .catch(() => { + setWeights([]) + setNutrition([]) + }) + }, [refreshTick, windowDays, fetchNutritionDays]) + + if (weights.length <= 2 && nutrition.length <= 2) { + return ( +
+ Mehr Gewichts- und Ernährungsdaten für den Trend nötig. +
+ ) + } + + return ( +
+
+
+
Trends
+
+ Kalorien und Gewicht ({windowDays} Tage) +
+
+ +
+ +
+ + + Ø Kalorien + + + + Gewicht + +
+
+ ) +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 0996931..9a7987c 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -1,10 +1,6 @@ import { useState, useEffect } from 'react' import { useNavigate, useLocation } from 'react-router-dom' -import { Check, Brain } from 'lucide-react' -import { - LineChart, Line, XAxis, YAxis, Tooltip, - ResponsiveContainer, CartesianGrid -} from 'recharts' +import { Brain } from 'lucide-react' import { api } from '../utils/api' import { useProfile } from '../context/ProfileContext' import { getBfCategory } from '../utils/calc' @@ -14,241 +10,20 @@ import TrainingTypeDistribution from '../components/TrainingTypeDistribution' import SleepWidget from '../components/SleepWidget' import RestDaysWidget from '../components/RestDaysWidget' import Markdown from '../utils/Markdown' +import QuickWeightEntry from '../components/QuickWeightEntry' +import TrendKcalWeightChart from '../components/TrendKcalWeightChart' +import { Pill, StatCard } from '../components/DashboardStatKit' import dayjs from 'dayjs' import 'dayjs/locale/de' import DashboardSection from '../components/DashboardSection' import DashboardTile from '../components/DashboardTile' import { - clampTileSpan, DASHBOARD_TILE_GRID_COLS, dashboardStatGridClassName, dashboardTileGridClassName } from '../utils/dashboardLayout' dayjs.locale('de') -// ── Helpers ─────────────────────────────────────────────────────────────────── -function rollingAvg(arr, key, w=7) { - return arr.map((d,i)=>{ - const s=arr.slice(Math.max(0,i-w+1),i+1).map(x=>x[key]).filter(v=>v!=null) - return s.length?{...d,[`${key}_avg`]:Math.round(s.reduce((a,b)=>a+b)/s.length*10)/10}:d - }) -} - -// ── Quick Weight Entry ──────────────────────────────────────────────────────── -function QuickWeight({ onSaved }) { - const [input, setInput] = useState('') - const [saving, setSaving] = useState(false) - const [saved, setSaved] = useState(false) - const [error, setError] = useState(null) - const [weightUsage, setWeightUsage] = useState(null) - const today = dayjs().format('YYYY-MM-DD') - - const loadUsage = () => { - api.getFeatureUsage().then(features => { - const weightFeature = features.find(f => f.feature_id === 'weight_entries') - setWeightUsage(weightFeature) - }).catch(err => console.error('Failed to load usage:', err)) - } - - useEffect(()=>{ - api.weightStats().then(s=>{ - if(s?.latest?.date===today) setInput(String(s.latest.weight)) - }) - loadUsage() - },[]) - - const handleSave = async () => { - const w=parseFloat(input); if(!w||w<20||w>300) return - setSaving(true) - setError(null) - try{ - await api.upsertWeight(today,w) - setSaved(true) - await loadUsage() // Reload usage after save - onSaved?.() - setTimeout(()=>setSaved(false),2000) - } catch(err) { - console.error('Save failed:', err) - setError(err.message || 'Fehler beim Speichern') - setTimeout(()=>setError(null), 5000) - } finally { - setSaving(false) - } - } - - const isDisabled = saving || !input || (weightUsage && !weightUsage.allowed) - const tooltipText = weightUsage && !weightUsage.allowed - ? `Limit erreicht (${weightUsage.used}/${weightUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` - : '' - - return ( -
- {error && ( -
- {error} -
- )} -
- setInput(e.target.value)} - onKeyDown={e=>e.key==='Enter'&&!isDisabled&&handleSave()}/> - kg -
- -
-
-
- ) -} - -// ── Status Pill ─────────────────────────────────────────────────────────────── -const PILL_TOOLTIPS = { - 'WHR': 'Waist-Hip-Ratio: Taille ÷ Hüfte. Maß für Bauchfettverteilung. Ziel: <0,90 (M) / <0,85 (F)', - 'WHtR': 'Waist-to-Height-Ratio: Taille ÷ Körpergröße. Gesündestest Maß: Ziel unter 0,50.', - 'KF': 'Körperfettanteil in Prozent (aus Caliper-Messung).', - 'Protein Ø7T': 'Durchschnittliche tägliche Proteinaufnahme der letzten 7 Tage vs. Zielbereich (1,6–2,2g/kg KG).', -} -function Pill({ label, value, status, sub }) { - const [tip, setTip] = useState(false) - const color = status==='good'?'var(--accent)':status==='warn'?'var(--warn)':'#D85A30' - const bg = status==='good'?'var(--accent-light)':status==='warn'?'var(--warn-bg)':'#FCEBEB' - const tipText = PILL_TOOLTIPS[label] - return ( -
-
tipText&&setTip(s=>!s)} - style={{display:'flex',alignItems:'center',gap:5,padding:'5px 10px', - borderRadius:20,background:bg,border:`1px solid ${color}44`, - cursor:tipText?'help':'default'}}> -
- {label} - {value} - {sub && {sub}} - {tipText && } -
- {tip && tipText && ( -
setTip(false)} style={{ - position:'absolute',bottom:'110%',left:0,zIndex:50, - background:'var(--surface)',border:'1px solid var(--border)', - borderRadius:8,padding:'8px 10px',fontSize:11,color:'var(--text2)', - minWidth:200,maxWidth:260,lineHeight:1.5, - boxShadow:'0 4px 16px rgba(0,0,0,0.15)'}}> - {label}
{tipText} -
- )} -
- ) -} - -// ── Stat Card ───────────────────────────────────────────────────────────────── -/** - * KPI-Kachel im Dashboard-Raster (`dashboard-stat-grid` / `dashboard-tile-grid`). - * @param {number} [spanMobile=1] Spaltenbreite unter 1024px (max. = Raster-Spalten mobile) - * @param {number} [spanDesktop=1] Spaltenbreite ≥1024px (max. 4) - */ -function StatCard({ - icon, - label, - value, - unit, - delta, - deltaGoodWhenNeg = false, - sub, - onClick, - color, - spanMobile = 1, - spanDesktop = 1 -}) { - const deltaColor = delta==null ? null - : (deltaGoodWhenNeg ? delta<0 : delta>0) ? 'var(--accent)' : 'var(--warn)' - const sm = clampTileSpan(spanMobile, DASHBOARD_TILE_GRID_COLS.mobile) - const lg = clampTileSpan(spanDesktop, DASHBOARD_TILE_GRID_COLS.desktop) - return ( -
onClick&&(e.currentTarget.style.borderColor='var(--accent)')} - onMouseLeave={e=>onClick&&(e.currentTarget.style.borderColor='var(--border)')}> -
{icon}
-
{label}
-
- {value}{unit} -
- {delta!=null &&
- {delta>0?'+':''}{delta} {unit} -
} - {sub &&
{sub}
} -
- ) -} - -// ── Combined Chart: Kcal + Weight ───────────────────────────────────────────── -function ComboChart({ weights, nutrition }) { - // Build unified date axis from last 30 days - const days = [] - for (let i=29; i>=0; i--) days.push(dayjs().subtract(i,'day').format('YYYY-MM-DD')) - - const wMap = {}; (weights||[]).forEach(w=>{ wMap[w.date]=w.weight }) - const nMap = {}; (nutrition||[]).forEach(n=>{ nMap[n.date]=Math.round(n.kcal||0) }) - - // Forward-fill weight: carry last known weight to fill gaps - let lastW = null - const combined = days.map(date=>{ - if (wMap[date]) lastW = wMap[date] - return { - date: dayjs(date).format('DD.MM'), - kcal: nMap[date]||null, - weight: wMap[date]||null, // actual measurement dots - weightLine:lastW, // interpolated line - } - }).filter(d=>d.kcal||d.weightLine) - - const withAvg = rollingAvg(combined,'kcal') - const hasKcal = combined.some(d=>d.kcal) - const hasW = combined.some(d=>d.weightLine) - - if (!hasKcal && !hasW) return ( -
- Mehr Ernährungs- und Gewichtsdaten für den Chart nötig -
- ) - - return ( - - - - - {hasKcal && } - {hasW && } - [v==null?'–':`${Math.round(v)} ${n==='weightLine'||n==='weight'?'kg':'kcal'}`, - n==='kcal_avg'?'Ø Kalorien (7T)':n==='kcal'?'Kalorien':n==='weightLine'?'Gewicht (interpoliert)':'Gewicht Messung']}/> - {hasKcal && } - {hasKcal && } - {hasW && } - {hasW && { const {cx,cy,value}=props; return value!=null?:}} connectNulls={false} name="weight"/>} - - - ) -} - // ── Main Dashboard ──────────────────────────────────────────────────────────── export default function Dashboard() { const nav = useNavigate() @@ -426,7 +201,7 @@ export default function Dashboard() { } >
- +
@@ -518,7 +293,7 @@ export default function Dashboard() { >
- +
Ø Kalorien Gewicht diff --git a/frontend/src/widgetSystem/defaultLabLayout.js b/frontend/src/widgetSystem/defaultLabLayout.js index 5da9c5c..d778b08 100644 --- a/frontend/src/widgetSystem/defaultLabLayout.js +++ b/frontend/src/widgetSystem/defaultLabLayout.js @@ -1,7 +1,7 @@ /** - * Standard-Layout v1 (nur Pilot-Fallback ohne API). - * Reihenfolge: gleich backend/widget_catalog.WIDGET_CATALOG bei Änderung dort mitpflegen - * (oder später nur noch aus GET /api/app/dashboard-layout default_layout beziehen). + * Standard-Layout v1 (nur Pilot `/pilot/viz` ohne API). + * API-Nutzer: default_layout aus Backend (alle Katalog-IDs; aktiv = DEFAULT_LAB_WIDGET_IDS). + * Diese Datei: kompakte feste 5 Widgets für den Pilot – nicht automatisch alle P1-Widgets. */ export const DEFAULT_LAB_LAYOUT = { version: 1, diff --git a/frontend/src/widgetSystem/registerPilotLabWidgets.js b/frontend/src/widgetSystem/registerPilotLabWidgets.js index caf0da5..c72546e 100644 --- a/frontend/src/widgetSystem/registerPilotLabWidgets.js +++ b/frontend/src/widgetSystem/registerPilotLabWidgets.js @@ -6,6 +6,17 @@ import PilotQuickCapture from '../components/pilot/PilotQuickCapture' import PilotKpiBoard from '../components/pilot/PilotKpiBoard' import PilotBodySection from '../components/pilot/PilotBodySection' import PilotActivitySection from '../components/pilot/PilotActivitySection' +import DashboardGreetingWidget from '../components/dashboard-widgets/DashboardGreetingWidget' +import QuickWeightTodayWidget from '../components/dashboard-widgets/QuickWeightTodayWidget' +import BodyStatStripWidget from '../components/dashboard-widgets/BodyStatStripWidget' +import StatusPillsWidget from '../components/dashboard-widgets/StatusPillsWidget' +import ProfileGoalsProgressWidget from '../components/dashboard-widgets/ProfileGoalsProgressWidget' +import TrendKcalWeightWidget from '../components/dashboard-widgets/TrendKcalWeightWidget' +import NutritionActivitySummaryWidget from '../components/dashboard-widgets/NutritionActivitySummaryWidget' +import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget' +import TrainingTypeDistributionWidget from '../components/dashboard-widgets/TrainingTypeDistributionWidget' +import GoalsFocusTeaserWidget from '../components/dashboard-widgets/GoalsFocusTeaserWidget' +import AiPipelineInsightWidget from '../components/dashboard-widgets/AiPipelineInsightWidget' import { normalizeBodyChartDays } from './bodyChartDays' import { registerDashboardWidget } from './dashboardWidgetRegistry' @@ -49,6 +60,71 @@ export function ensurePilotLabWidgetsRegistered() { chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days), }), }) + + registerDashboardWidget({ + id: 'dashboard_greeting', + Component: DashboardGreetingWidget, + mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }), + }) + registerDashboardWidget({ + id: 'quick_weight_today', + Component: QuickWeightTodayWidget, + mapProps: (ctx) => ({ onSaved: ctx.requestRefresh }), + }) + registerDashboardWidget({ + id: 'body_stat_strip', + Component: BodyStatStripWidget, + mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }), + }) + registerDashboardWidget({ + id: 'status_pills', + Component: StatusPillsWidget, + mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }), + }) + registerDashboardWidget({ + id: 'profile_goals_progress', + Component: ProfileGoalsProgressWidget, + mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }), + }) + registerDashboardWidget({ + id: 'trend_kcal_weight', + Component: TrendKcalWeightWidget, + mapProps: (ctx) => ({ + refreshTick: ctx.refreshTick, + chartDays: ctx.layoutEntry?.config?.chart_days, + }), + }) + registerDashboardWidget({ + id: 'nutrition_activity_summary', + Component: NutritionActivitySummaryWidget, + mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }), + }) + registerDashboardWidget({ + id: 'recovery_sleep_rest', + Component: RecoverySleepRestWidget, + mapProps: () => ({}), + }) + registerDashboardWidget({ + id: 'training_type_distribution', + Component: TrainingTypeDistributionWidget, + mapProps: (ctx) => ({ + refreshTick: ctx.refreshTick, + distributionDays: + ctx.layoutEntry?.config?.distribution_days != null + ? Number(ctx.layoutEntry.config.distribution_days) + : 28, + }), + }) + registerDashboardWidget({ + id: 'goals_focus_teaser', + Component: GoalsFocusTeaserWidget, + mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }), + }) + registerDashboardWidget({ + id: 'ai_pipeline_insight', + Component: AiPipelineInsightWidget, + mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }), + }) } /** @internal Nur für Tests */ diff --git a/scripts/gitea/MCP_SETUP.md b/scripts/gitea/MCP_SETUP.md index dda4927..07868de 100644 --- a/scripts/gitea/MCP_SETUP.md +++ b/scripts/gitea/MCP_SETUP.md @@ -53,10 +53,13 @@ Die Gitea-URL muss von deinem Rechner erreichbar sein (z. B. `192.168.2.144:3000 | `gitea_list_issues` | Issues listen, optional alle Seiten | | `gitea_get_issue` | Ein Issue mit Body | | `gitea_comment_issue` | Kommentar | +| `gitea_patch_issue` | Titel und/oder **Beschreibung** des Issues ändern (PATCH) | | `gitea_create_issue` | Neu anlegen | | `gitea_close_issue` / `gitea_reopen_issue` | Status | | `gitea_get_repo_file` | Datei remote via API | +**MCP vs. CLI:** Sehr lange Issue-Bodies oder Vorlagen aus Datei → `python scripts/gitea/gitea_api.py issues edit … --body-file` (siehe README). Kurze Updates direkt im Agent → `gitea_patch_issue`. + ## Issue-Triage durch den Agent Sinnvoller Ablauf: Issues listen → je Issue **Code/Commits prüfen** → bei eindeutig erledigt: kurzer Kommentar + **close**; bei teilweise: Kommentar mit Checkboxen; bei unklar: nur Kommentar, **nicht** schließen. diff --git a/scripts/gitea/README.md b/scripts/gitea/README.md index e5aae90..6b00994 100644 --- a/scripts/gitea/README.md +++ b/scripts/gitea/README.md @@ -15,6 +15,17 @@ Dient dazu, **Issues** auf deiner Gitea-Instanz zu lesen und anzulegen – mit d Python 3.10+ (nur Standardbibliothek). +## MCP vs. CLI (wann was) + +| Aufgabe | Empfehlung | +|--------|------------| +| Issues listen, ein Issue lesen, kurzer Kommentar, schließen/öffnen | **MCP** (`gitea_*` in Cursor), weniger Kontext im Chat | +| **Issue-Beschreibung oder Titel ändern** (PATCH) | Kurz im Chat: **MCP** `gitea_patch_issue`. Groß / aus Datei / Automation: **CLI** `issues edit --body-file` | +| Neues Issue mit langem Markdown aus Vorlage | **CLI** `issues create --body-file` | +| Remote-Datei aus Gitea lesen (nicht im Workspace) | **MCP** `gitea_get_repo_file` oder CLI `repo file` | + +Beides spricht dieselbe REST-API (`gitea_lib`); Token und `GITEA_*` wie oben. + ## Aufruf (im Repo-Root) ```powershell @@ -36,6 +47,11 @@ python scripts/gitea/gitea_api.py issues create --title "Fix: …" --body-file p python scripts/gitea/gitea_api.py issues comment 42 --body "…" python scripts/gitea/gitea_api.py issues comment 42 --body-file path/to/comment.md +# Beschreibung und/oder Titel ändern (PATCH) +python scripts/gitea/gitea_api.py issues edit 42 --title "Neuer Titel" +python scripts/gitea/gitea_api.py issues edit 42 --body "Neuer **Markdown**-Body" +python scripts/gitea/gitea_api.py issues edit 42 --body-file path/to/body.md + # Schließen / wieder öffnen python scripts/gitea/gitea_api.py issues close 42 python scripts/gitea/gitea_api.py issues reopen 42 @@ -66,4 +82,5 @@ python scripts/gitea/gitea_api.py repo file backend/main.py --ref develop ## MCP (Tools direkt im Agent) -Siehe [`MCP_SETUP.md`](./MCP_SETUP.md) und [`../.cursor/mcp.json.example`](../../.cursor/mcp.json.example). +Siehe [`MCP_SETUP.md`](./MCP_SETUP.md) und [`../.cursor/mcp.json.example`](../../.cursor/mcp.json.example). +Nach dem Hinzufügen neuer MCP-Tools Cursor einmal **neu starten**, damit die Tool-Liste aktualisiert wird. diff --git a/scripts/gitea/gitea_api.py b/scripts/gitea/gitea_api.py index 5f4367f..032c483 100644 --- a/scripts/gitea/gitea_api.py +++ b/scripts/gitea/gitea_api.py @@ -103,6 +103,31 @@ def cmd_issues_reopen(args: argparse.Namespace, base: str, token: str, owner: st sys.exit(1) +def cmd_issues_edit(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None: + fields: dict = {} + if args.title is not None: + fields["title"] = args.title.strip() + if not fields["title"]: + sys.stderr.write("issues edit: --title darf nicht leer sein\n") + sys.exit(2) + body: str | None = None + if args.body_file: + body = Path(args.body_file).read_text(encoding="utf-8") + elif args.body is not None: + body = args.body + if body is not None: + fields["body"] = body + if not fields: + sys.stderr.write( + "issues edit: mindestens eines von --title, --body oder --body-file setzen\n" + ) + sys.exit(2) + status, payload = issues_patch(base, token, owner, repo, args.number, fields) + print(json.dumps(payload, indent=2, ensure_ascii=False)) + if status >= 400: + sys.exit(1) + + def cmd_repo_contents(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None: status, payload = repo_file_content( base, token, owner, repo, args.path, ref=args.ref or "" @@ -167,6 +192,20 @@ def main() -> None: p_ro.add_argument("number", type=int) p_ro.set_defaults(_handler=cmd_issues_reopen) + p_ed = i_sub.add_parser( + "edit", + help="Issue per PATCH ändern (Titel und/oder Beschreibung; für große Texte --body-file)", + ) + p_ed.add_argument("number", type=int) + p_ed.add_argument("--title", default=None, help="Neuer Titel") + p_ed.add_argument("--body", default=None, help="Neue Beschreibung (Markdown)") + p_ed.add_argument( + "--body-file", + default=None, + help="Beschreibung aus Datei (UTF-8); überschreibt --body wenn beides gesetzt", + ) + p_ed.set_defaults(_handler=cmd_issues_edit) + p_repo = sub.add_parser("repo", help="Repository (API)") r_sub = p_repo.add_subparsers(dest="repo_cmd", required=True) diff --git a/scripts/gitea/mcp_server_gitea.py b/scripts/gitea/mcp_server_gitea.py index 3d43785..f7a5c21 100644 --- a/scripts/gitea/mcp_server_gitea.py +++ b/scripts/gitea/mcp_server_gitea.py @@ -31,7 +31,9 @@ mcp = FastMCP( "mitai-gitea", instructions=( "Gitea-Tools für das Repo aus GITEA_OWNER/GITEA_REPO. " - "Schließe Issues nur nach klarer Code-Verifikation; sonst Kommentar mit offenen Punkten." + "Schließe Issues nur nach klarer Code-Verifikation; sonst Kommentar mit offenen Punkten. " + "Kurze Titel-/Body-Änderungen: gitea_patch_issue. " + "Sehr lange Bodies oder Skripte: Terminal scripts/gitea/gitea_api.py issues edit … --body-file." ), ) @@ -95,6 +97,28 @@ def gitea_comment_issue(issue_number: int, body: str) -> str: return _json({"http_status": st, "result": payload}) +@mcp.tool() +def gitea_patch_issue( + issue_number: int, + title: str | None = None, + body: str | None = None, +) -> str: + """Issue-Titel und/oder Beschreibung (PATCH). Mindestens eines von title/body setzen. Für sehr lange Markdown-Texte besser: CLI issues edit --body-file.""" + fields: dict[str, str] = {} + if title is not None: + t = title.strip() + if not t: + return _json({"error": "title darf nicht leer sein"}) + fields["title"] = t + if body is not None: + fields["body"] = body + if not fields: + return _json({"error": "Mindestens title oder body angeben"}) + base, token, owner, repo = _cfg() + st, payload = issues_patch(base, token, owner, repo, issue_number, fields) + return _json({"http_status": st, "result": payload}) + + @mcp.tool() def gitea_close_issue(issue_number: int) -> str: """Issue schließen (state=closed)."""