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)."""