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.
This commit is contained in:
parent
c0c512e942
commit
3d498d03c1
|
|
@ -17,6 +17,7 @@
|
||||||
- ✅ Bestehende Issues aktualisieren (Status, Beschreibung)
|
- ✅ Bestehende Issues aktualisieren (Status, Beschreibung)
|
||||||
- ✅ Issues bei Fertigstellung schließen
|
- ✅ Issues bei Fertigstellung schließen
|
||||||
- 🎯 Gitea: http://192.168.2.144:3000/Lars/mitai-jinkendo/issues
|
- 🎯 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:**
|
**Dokumentation:**
|
||||||
- Code-Änderungen in CLAUDE.md dokumentieren
|
- Code-Änderungen in CLAUDE.md dokumentieren
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from typing import Any, Literal
|
||||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
||||||
from dashboard_widget_config import validate_widget_entry_config
|
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)
|
# Abwärtskompatibel (Tests importieren weiterhin aus diesem Modul)
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
@ -23,9 +23,10 @@ __all__ = [
|
||||||
|
|
||||||
|
|
||||||
def default_layout_dict() -> dict[str, Any]:
|
def default_layout_dict() -> dict[str, Any]:
|
||||||
|
on = DEFAULT_LAB_WIDGET_IDS
|
||||||
return {
|
return {
|
||||||
"version": 1,
|
"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],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,13 @@ from typing import Any
|
||||||
|
|
||||||
MAX_WIDGET_CONFIG_JSON_BYTES = 3072
|
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_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"})
|
||||||
_KPI_REF_TILE_RE = re.compile(r"^ref:[a-z0-9_]{1,64}$")
|
_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")
|
return _validate_chart_days_only(raw, label="activity_overview")
|
||||||
if widget_id == "kpi_board":
|
if widget_id == "kpi_board":
|
||||||
return _validate_kpi_board_config(raw)
|
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")
|
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:
|
if v < 7 or v > 90:
|
||||||
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
||||||
return {"chart_days": v}
|
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}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,24 @@ def test_kpi_board_tiles():
|
||||||
validate_widget_entry_config("kpi_board", {"extra": 1})
|
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():
|
def test_kpi_board_legacy_chart_days_dropped():
|
||||||
"""Nur chart_days (Alt-Layouts) → automatische Kachelwahl, kein Ø-Kal-Fenster mehr."""
|
"""Nur chart_days (Alt-Layouts) → automatische Kachelwahl, kein Ø-Kal-Fenster mehr."""
|
||||||
assert validate_widget_entry_config("kpi_board", {"chart_days": 14}) == {}
|
assert validate_widget_entry_config("kpi_board", {"chart_days": 14}) == {}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Widget-Katalog: Konsistenz (IDs, Default-Layout, Katalog-Response)."""
|
"""Widget-Katalog: Konsistenz (IDs, Default-Layout, Katalog-Response)."""
|
||||||
|
|
||||||
from dashboard_layout_schema import default_layout_dict
|
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():
|
def test_catalog_ids_unique_and_match_allowed():
|
||||||
|
|
@ -15,7 +15,9 @@ def test_default_layout_follows_catalog_order():
|
||||||
assert d["version"] == 1
|
assert d["version"] == 1
|
||||||
got = [w["id"] for w in d["widgets"]]
|
got = [w["id"] for w in d["widgets"]]
|
||||||
assert got == [e["id"] for e in WIDGET_CATALOG]
|
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():
|
def test_catalog_response_shape():
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ MODULE_VERSIONS = {
|
||||||
"importdata": "1.0.0",
|
"importdata": "1.0.0",
|
||||||
"membership": "2.1.0",
|
"membership": "2.1.0",
|
||||||
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
"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 = [
|
CHANGELOG = [
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ class WidgetCatalogEntry(TypedDict):
|
||||||
description: str
|
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] = [
|
WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
||||||
{
|
{
|
||||||
"id": "welcome",
|
"id": "welcome",
|
||||||
|
|
@ -42,8 +42,73 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
||||||
"title": "Aktivität",
|
"title": "Aktivität",
|
||||||
"description": "Training & Konsistenz (optional: config chart_days 7–90)",
|
"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)
|
ALLOWED_WIDGET_IDS: frozenset[str] = frozenset(e["id"] for e in WIDGET_CATALOG)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
134
frontend/src/components/DashboardStatKit.jsx
Normal file
134
frontend/src/components/DashboardStatKit.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<div
|
||||||
|
role={tipText ? 'button' : undefined}
|
||||||
|
onClick={() => 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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: 7, height: 7, borderRadius: '50%', background: color, flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text2)' }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color }}>{value}</span>
|
||||||
|
{sub && <span style={{ fontSize: 10, color: 'var(--text3)' }}>{sub}</span>}
|
||||||
|
{tipText && (
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--text3)', opacity: 0.7 }} aria-hidden>
|
||||||
|
ⓘ
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{tip && tipText && (
|
||||||
|
<div
|
||||||
|
role="tooltip"
|
||||||
|
onClick={() => 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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<br />
|
||||||
|
{tipText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div
|
||||||
|
className="dashboard-stat-card"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
cursor: onClick ? 'pointer' : 'default',
|
||||||
|
'--tile-sm': String(sm),
|
||||||
|
'--tile-lg': String(lg),
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => onClick && (e.currentTarget.style.borderColor = 'var(--accent)')}
|
||||||
|
onMouseLeave={(e) => onClick && (e.currentTarget.style.borderColor = 'var(--border)')}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 18, marginBottom: 4 }}>{icon}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 2 }}>{label}</div>
|
||||||
|
<div style={{ fontSize: 19, fontWeight: 700, color: color || 'var(--text1)', lineHeight: 1.1 }}>
|
||||||
|
{value}
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 400, color: 'var(--text3)', marginLeft: 2 }}>{unit}</span>
|
||||||
|
</div>
|
||||||
|
{delta != null && (
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: deltaColor, marginTop: 2 }}>
|
||||||
|
{delta > 0 ? '+' : ''}
|
||||||
|
{delta} {unit}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sub && <div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 2 }}>{sub}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
113
frontend/src/components/QuickWeightEntry.jsx
Normal file
113
frontend/src/components/QuickWeightEntry.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div>
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 10px',
|
||||||
|
background: 'var(--danger-bg)',
|
||||||
|
border: '1px solid var(--danger)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--danger)',
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={20}
|
||||||
|
max={300}
|
||||||
|
step={0.1}
|
||||||
|
className="form-input"
|
||||||
|
style={{ flex: 1, fontSize: 17, fontWeight: 600, textAlign: 'center' }}
|
||||||
|
placeholder="kg eingeben"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && !isDisabled && handleSave()}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text3)' }}>kg</span>
|
||||||
|
<div title={tooltipText} style={{ display: 'inline-block' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ padding: '8px 14px', cursor: isDisabled ? 'not-allowed' : 'pointer' }}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
{saved ? (
|
||||||
|
<Check size={15} />
|
||||||
|
) : saving ? (
|
||||||
|
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||||
|
) : weightUsage && !weightUsage.allowed ? (
|
||||||
|
'🔒 Limit'
|
||||||
|
) : (
|
||||||
|
'Speichern'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
183
frontend/src/components/TrendKcalWeightChart.jsx
Normal file
183
frontend/src/components/TrendKcalWeightChart.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div style={{ padding: 20, textAlign: 'center', fontSize: 12, color: 'var(--text3)' }}>
|
||||||
|
Mehr Ernährungs- und Gewichtsdaten für den Chart nötig
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
|
<LineChart data={withAvg} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fontSize: 9, fill: 'var(--text3)' }}
|
||||||
|
tickLine={false}
|
||||||
|
interval={Math.max(0, Math.floor(withAvg.length / 6) - 1)}
|
||||||
|
/>
|
||||||
|
{hasKcal && (
|
||||||
|
<YAxis
|
||||||
|
yAxisId="kcal"
|
||||||
|
tick={{ fontSize: 9, fill: 'var(--text3)' }}
|
||||||
|
tickLine={false}
|
||||||
|
domain={['auto', 'auto']}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasW && (
|
||||||
|
<YAxis
|
||||||
|
yAxisId="weight"
|
||||||
|
orientation="right"
|
||||||
|
tick={{ fontSize: 9, fill: 'var(--text3)' }}
|
||||||
|
tickLine={false}
|
||||||
|
domain={['auto', 'auto']}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
formatter={(v, name) => [
|
||||||
|
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 && (
|
||||||
|
<Line
|
||||||
|
yAxisId="kcal"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="kcal"
|
||||||
|
stroke="#EF9F2744"
|
||||||
|
strokeWidth={1}
|
||||||
|
dot={false}
|
||||||
|
connectNulls={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasKcal && (
|
||||||
|
<Line
|
||||||
|
yAxisId="kcal"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="kcal_avg"
|
||||||
|
stroke="#EF9F27"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
connectNulls
|
||||||
|
name="kcal_avg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasW && (
|
||||||
|
<Line
|
||||||
|
yAxisId="weight"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="weightLine"
|
||||||
|
stroke="#378ADD88"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dot={false}
|
||||||
|
connectNulls
|
||||||
|
name="weightLine"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasW && (
|
||||||
|
<Line
|
||||||
|
yAxisId="weight"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="weight"
|
||||||
|
stroke="#378ADD"
|
||||||
|
strokeWidth={0}
|
||||||
|
dot={(props) => {
|
||||||
|
const { cx, cy, value } = props
|
||||||
|
return value != null ? (
|
||||||
|
<circle
|
||||||
|
key={cx}
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={4}
|
||||||
|
fill="#378ADD"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<g key={cx} />
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
connectNulls={false}
|
||||||
|
name="weight"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>KI-Auswertung</div>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 10px' }} onClick={() => nav('/analysis')}>
|
||||||
|
<Brain size={11} /> Analysen →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary btn-full" style={{ marginBottom: 10 }} onClick={runPipeline} disabled={pipelineLoading}>
|
||||||
|
{pipelineLoading ? (
|
||||||
|
<>
|
||||||
|
<div className="spinner" style={{ width: 13, height: 13 }} /> Analyse läuft… (3 Stufen)
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Brain size={13} /> 🔬 Mehrstufige Analyse starten
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{pipelineError && <div style={{ fontSize: 12, color: '#D85A30', marginBottom: 8 }}>{pipelineError}</div>}
|
||||||
|
|
||||||
|
{latestInsight ? (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
|
Letzte Analyse: {dayjs(latestInsight.created).format('DD. MMMM YYYY, HH:mm')}
|
||||||
|
</div>
|
||||||
|
<div style={{ maxHeight: showInsight ? 'none' : 120, overflow: 'hidden', position: 'relative' }}>
|
||||||
|
<Markdown text={latestInsight.content} />
|
||||||
|
{!showInsight && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 40,
|
||||||
|
background: 'linear-gradient(transparent,var(--surface))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--accent)',
|
||||||
|
marginTop: 6,
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
onClick={() => setShowInsight((s) => !s)}
|
||||||
|
>
|
||||||
|
{showInsight ? '▲ Weniger anzeigen' : '▼ Vollständig anzeigen'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text3)', padding: '8px 0' }}>
|
||||||
|
Noch keine KI-Auswertung vorhanden.
|
||||||
|
<button type="button" className="btn btn-primary" style={{ marginTop: 8, display: 'block', fontSize: 12 }} onClick={() => nav('/analysis')}>
|
||||||
|
Erste Analyse erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16, fontSize: 13, color: 'var(--text3)' }}>
|
||||||
|
Noch keine Kennzahlen – erfasse Gewicht oder Körperdaten.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 10, color: 'var(--text1)' }}>Kennzahlen</div>
|
||||||
|
<div className={dashboardStatGridClassName(DASHBOARD_TILE_GRID_COLS.mobile)}>
|
||||||
|
{latestW && (
|
||||||
|
<StatCard
|
||||||
|
icon="⚖️"
|
||||||
|
label="Gewicht"
|
||||||
|
value={latestW.weight}
|
||||||
|
unit="kg"
|
||||||
|
delta={wDelta}
|
||||||
|
deltaGoodWhenNeg
|
||||||
|
sub={dayjs(latestW.date).format('DD.MM.')}
|
||||||
|
onClick={() => nav('/history')}
|
||||||
|
color="#378ADD"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{latestCal?.body_fat_pct != null && (
|
||||||
|
<StatCard
|
||||||
|
icon="🫧"
|
||||||
|
label="Körperfett"
|
||||||
|
value={latestCal.body_fat_pct}
|
||||||
|
unit="%"
|
||||||
|
delta={bfDelta}
|
||||||
|
deltaGoodWhenNeg
|
||||||
|
sub={bfCat?.label}
|
||||||
|
onClick={() => nav('/history', { state: { tab: 'body' } })}
|
||||||
|
color={bfCat?.color}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{latestCal?.lean_mass != null && (
|
||||||
|
<StatCard
|
||||||
|
icon="💪"
|
||||||
|
label="Magermasse"
|
||||||
|
value={latestCal.lean_mass}
|
||||||
|
unit="kg"
|
||||||
|
sub={latestCal.date ? dayjs(latestCal.date).format('DD.MM.') : '–'}
|
||||||
|
onClick={() => nav('/history', { state: { tab: 'body' } })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{avgKcal != null && (
|
||||||
|
<StatCard
|
||||||
|
icon="🍽️"
|
||||||
|
label="Ø Kalorien"
|
||||||
|
value={avgKcal}
|
||||||
|
unit="kcal"
|
||||||
|
sub="letzte 7 Tage"
|
||||||
|
onClick={() => nav('/history', { state: { tab: 'nutrition' } })}
|
||||||
|
color="#EF9F27"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<h2 style={{ fontSize: 22, fontWeight: 800, margin: 0, color: 'var(--text1)' }}>
|
||||||
|
Hallo, {activeProfile?.name || 'Nutzer'} 👋
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 2 }}>
|
||||||
|
{dayjs().format('dddd, DD. MMMM YYYY')}
|
||||||
|
{latestWeightDate && ` · Letztes Update ${dayjs(latestWeightDate).format('DD.MM.')}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Ziele & Fokus</div>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => nav('/goals')}>
|
||||||
|
Bearbeiten →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && nav('/goals')}
|
||||||
|
onClick={() => nav('/goals')}
|
||||||
|
>
|
||||||
|
{goalsCount != null && (
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text1)', marginBottom: 8 }}>
|
||||||
|
{goalsCount === 0
|
||||||
|
? 'Noch keine Ziele angelegt.'
|
||||||
|
: `${goalsCount} ${goalsCount === 1 ? 'Ziel' : 'Ziele'} im System.`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text2)' }}>
|
||||||
|
Focus Areas und Fortschritt – tippen zum Öffnen der Ziele-Seite.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16, fontSize: 13, color: 'var(--text3)' }}>
|
||||||
|
Noch keine Ernährungs- oder Aktivitätsdaten (7 Tage).
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryBoth = showNutr && showAct
|
||||||
|
const summarySpanM = summaryBoth ? 1 : 2
|
||||||
|
const summarySpanD = summaryBoth ? 2 : 4
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 10, color: 'var(--text1)' }}>
|
||||||
|
Ernährung & Aktivität
|
||||||
|
</div>
|
||||||
|
<div className={`dashboard-summary-row ${dashboardTileGridClassName(DASHBOARD_TILE_GRID_COLS.mobile)}`}>
|
||||||
|
{showNutr && (
|
||||||
|
<DashboardTile spanMobile={summarySpanM} spanDesktop={summarySpanD}>
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{ cursor: 'pointer', height: '100%' }}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && nav('/history', { state: { tab: 'nutrition' } })}
|
||||||
|
onClick={() => nav('/history', { state: { tab: 'nutrition' } })}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 12, marginBottom: 8, color: 'var(--text3)' }}>
|
||||||
|
🍽️ ERNÄHRUNG (Ø 7T)
|
||||||
|
</div>
|
||||||
|
{avgKcal != null && <div style={{ fontSize: 16, fontWeight: 700, color: '#EF9F27' }}>{avgKcal} kcal</div>}
|
||||||
|
{avgProtein != null && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: proteinOk ? 'var(--accent)' : 'var(--warn)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{avgProtein}g Protein {proteinOk ? '✓' : '⚠️'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>→ Verlauf Ernährung</div>
|
||||||
|
</div>
|
||||||
|
</DashboardTile>
|
||||||
|
)}
|
||||||
|
{showAct && (
|
||||||
|
<DashboardTile spanMobile={summarySpanM} spanDesktop={summarySpanD}>
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{ cursor: 'pointer', height: '100%' }}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && nav('/history', { state: { tab: 'activity' } })}
|
||||||
|
onClick={() => nav('/history', { state: { tab: 'activity' } })}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 12, marginBottom: 8, color: 'var(--text3)' }}>
|
||||||
|
🏋️ AKTIVITÄT (7T)
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700, color: '#EF9F27' }}>{actKcal} kcal</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text2)' }}>{recentAct.length} Trainings</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>→ Verlauf Aktivität</div>
|
||||||
|
</div>
|
||||||
|
</DashboardTile>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 10, color: 'var(--text1)' }}>Profil-Ziele</div>
|
||||||
|
{gw && latestW && (
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
{(() => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
||||||
|
<span>
|
||||||
|
Gewicht: {curr} → {goal} kg
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>
|
||||||
|
{remain > 0 ? `noch ${remain}kg` : 'Ziel erreicht! 🎉'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 8, background: 'var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${pct}%`,
|
||||||
|
background: 'var(--accent)',
|
||||||
|
borderRadius: 4,
|
||||||
|
transition: 'width 0.5s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 2 }}>{pct}% des Weges</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{gbf && latestCal?.body_fat_pct != null && (
|
||||||
|
<div>
|
||||||
|
{(() => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
||||||
|
<span>
|
||||||
|
Körperfett: {curr}% → {goal}%
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>
|
||||||
|
{remain > 0 ? `noch ${remain}%` : 'Ziel erreicht! 🎉'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 8, background: 'var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${pct}%`,
|
||||||
|
background: bfCat?.color || 'var(--accent)',
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 2 }}>Aktuell: {bfCat?.label}</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import QuickWeightEntry from '../QuickWeightEntry'
|
||||||
|
|
||||||
|
export default function QuickWeightTodayWidget({ onSaved }) {
|
||||||
|
const nav = useNavigate()
|
||||||
|
return (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Gewicht heute</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Tageswert erfassen</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => nav('/weight')}>
|
||||||
|
Alle Einträge →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<QuickWeightEntry onSaved={onSaved} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 10, color: 'var(--text1)' }}>Erholung</div>
|
||||||
|
<div className={`dashboard-erholung-grid ${dashboardTileGridClassName(DASHBOARD_TILE_GRID_COLS.mobile)}`}>
|
||||||
|
<DashboardTile spanMobile={1} spanDesktop={2}>
|
||||||
|
<SleepWidget />
|
||||||
|
</DashboardTile>
|
||||||
|
<DashboardTile spanMobile={1} spanDesktop={2}>
|
||||||
|
<RestDaysWidget />
|
||||||
|
</DashboardTile>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Indikatoren</div>
|
||||||
|
<div className="dashboard-pill-row">
|
||||||
|
{pills.map((p, i) => (
|
||||||
|
<Pill key={i} {...p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Training</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Verteilung der Trainingstypen ({days} Tage)</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => nav('/activity')}>
|
||||||
|
Details →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<TrainingTypeDistribution key={`${refreshTick}-${days}`} days={days} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16, fontSize: 13, color: 'var(--text3)' }}>
|
||||||
|
Mehr Gewichts- und Ernährungsdaten für den Trend nötig.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Trends</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||||
|
Kalorien und Gewicht ({windowDays} Tage)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: 12, padding: '6px 12px' }}
|
||||||
|
onClick={() => nav('/history', { state: { tab: 'body' } })}
|
||||||
|
>
|
||||||
|
Details →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<TrendKcalWeightChart weights={weights} nutrition={nutrition} windowDays={windowDays} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: 6,
|
||||||
|
fontSize: 10,
|
||||||
|
color: 'var(--text3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 12,
|
||||||
|
height: 2,
|
||||||
|
background: '#EF9F27',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
marginRight: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Ø Kalorien
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 12,
|
||||||
|
height: 2,
|
||||||
|
background: '#378ADD',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
marginRight: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Gewicht
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { Check, Brain } from 'lucide-react'
|
import { Brain } from 'lucide-react'
|
||||||
import {
|
|
||||||
LineChart, Line, XAxis, YAxis, Tooltip,
|
|
||||||
ResponsiveContainer, CartesianGrid
|
|
||||||
} from 'recharts'
|
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { useProfile } from '../context/ProfileContext'
|
import { useProfile } from '../context/ProfileContext'
|
||||||
import { getBfCategory } from '../utils/calc'
|
import { getBfCategory } from '../utils/calc'
|
||||||
|
|
@ -14,241 +10,20 @@ import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
||||||
import SleepWidget from '../components/SleepWidget'
|
import SleepWidget from '../components/SleepWidget'
|
||||||
import RestDaysWidget from '../components/RestDaysWidget'
|
import RestDaysWidget from '../components/RestDaysWidget'
|
||||||
import Markdown from '../utils/Markdown'
|
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 from 'dayjs'
|
||||||
import 'dayjs/locale/de'
|
import 'dayjs/locale/de'
|
||||||
import DashboardSection from '../components/DashboardSection'
|
import DashboardSection from '../components/DashboardSection'
|
||||||
import DashboardTile from '../components/DashboardTile'
|
import DashboardTile from '../components/DashboardTile'
|
||||||
import {
|
import {
|
||||||
clampTileSpan,
|
|
||||||
DASHBOARD_TILE_GRID_COLS,
|
DASHBOARD_TILE_GRID_COLS,
|
||||||
dashboardStatGridClassName,
|
dashboardStatGridClassName,
|
||||||
dashboardTileGridClassName
|
dashboardTileGridClassName
|
||||||
} from '../utils/dashboardLayout'
|
} from '../utils/dashboardLayout'
|
||||||
dayjs.locale('de')
|
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 (
|
|
||||||
<div>
|
|
||||||
{error && (
|
|
||||||
<div style={{padding:'8px 10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:12,color:'var(--danger)',marginBottom:8}}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{display:'flex',gap:8,alignItems:'center'}}>
|
|
||||||
<input type="number" min={20} max={300} step={0.1} className="form-input"
|
|
||||||
style={{flex:1,fontSize:17,fontWeight:600,textAlign:'center'}}
|
|
||||||
placeholder="kg eingeben" value={input} onChange={e=>setInput(e.target.value)}
|
|
||||||
onKeyDown={e=>e.key==='Enter'&&!isDisabled&&handleSave()}/>
|
|
||||||
<span style={{fontSize:13,color:'var(--text3)'}}>kg</span>
|
|
||||||
<div title={tooltipText} style={{display:'inline-block'}}>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
style={{padding:'8px 14px', cursor: isDisabled ? 'not-allowed' : 'pointer'}}
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isDisabled}
|
|
||||||
>
|
|
||||||
{saved ? <Check size={15}/>
|
|
||||||
: saving ? <div className="spinner" style={{width:14,height:14}}/>
|
|
||||||
: (weightUsage && !weightUsage.allowed) ? '🔒 Limit'
|
|
||||||
: 'Speichern'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 (
|
|
||||||
<div style={{position:'relative'}}>
|
|
||||||
<div onClick={()=>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'}}>
|
|
||||||
<div style={{width:7,height:7,borderRadius:'50%',background:color,flexShrink:0}}/>
|
|
||||||
<span style={{fontSize:12,fontWeight:500,color:'var(--text2)'}}>{label}</span>
|
|
||||||
<span style={{fontSize:12,fontWeight:700,color}}>{value}</span>
|
|
||||||
{sub && <span style={{fontSize:10,color:'var(--text3)'}}>{sub}</span>}
|
|
||||||
{tipText && <span style={{fontSize:10,color:'var(--text3)',opacity:0.7}}>ⓘ</span>}
|
|
||||||
</div>
|
|
||||||
{tip && tipText && (
|
|
||||||
<div onClick={()=>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)'}}>
|
|
||||||
<strong>{label}</strong><br/>{tipText}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 (
|
|
||||||
<div
|
|
||||||
className="dashboard-stat-card"
|
|
||||||
onClick={onClick}
|
|
||||||
style={{
|
|
||||||
cursor: onClick ? 'pointer' : 'default',
|
|
||||||
'--tile-sm': String(sm),
|
|
||||||
'--tile-lg': String(lg)
|
|
||||||
}}
|
|
||||||
onMouseEnter={e=>onClick&&(e.currentTarget.style.borderColor='var(--accent)')}
|
|
||||||
onMouseLeave={e=>onClick&&(e.currentTarget.style.borderColor='var(--border)')}>
|
|
||||||
<div style={{fontSize:18,marginBottom:4}}>{icon}</div>
|
|
||||||
<div style={{fontSize:11,color:'var(--text3)',marginBottom:2}}>{label}</div>
|
|
||||||
<div style={{fontSize:19,fontWeight:700,color:color||'var(--text1)',lineHeight:1.1}}>
|
|
||||||
{value}<span style={{fontSize:12,fontWeight:400,color:'var(--text3)',marginLeft:2}}>{unit}</span>
|
|
||||||
</div>
|
|
||||||
{delta!=null && <div style={{fontSize:11,fontWeight:600,color:deltaColor,marginTop:2}}>
|
|
||||||
{delta>0?'+':''}{delta} {unit}
|
|
||||||
</div>}
|
|
||||||
{sub && <div style={{fontSize:10,color:'var(--text3)',marginTop:2}}>{sub}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 (
|
|
||||||
<div style={{padding:20,textAlign:'center',fontSize:12,color:'var(--text3)'}}>
|
|
||||||
Mehr Ernährungs- und Gewichtsdaten für den Chart nötig
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponsiveContainer width="100%" height={160}>
|
|
||||||
<LineChart data={withAvg} margin={{top:4,right:8,bottom:0,left:-20}}>
|
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
|
||||||
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
|
||||||
interval={Math.max(0,Math.floor(withAvg.length/6)-1)}/>
|
|
||||||
{hasKcal && <YAxis yAxisId="kcal" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>}
|
|
||||||
{hasW && <YAxis yAxisId="weight" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>}
|
|
||||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
|
||||||
formatter={(v,n)=>[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 && <Line yAxisId="kcal" type="monotone" dataKey="kcal" stroke="#EF9F2744" strokeWidth={1} dot={false} connectNulls={false}/>}
|
|
||||||
{hasKcal && <Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EF9F27" strokeWidth={2} dot={false} connectNulls={true} name="kcal_avg"/>}
|
|
||||||
{hasW && <Line yAxisId="weight" type="monotone" dataKey="weightLine" stroke="#378ADD88" strokeWidth={1.5} dot={false} connectNulls={true} name="weightLine"/>}
|
|
||||||
{hasW && <Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#378ADD" strokeWidth={0}
|
|
||||||
dot={(props)=>{ const {cx,cy,value}=props; return value!=null?<circle key={cx} cx={cx} cy={cy} r={4} fill="#378ADD" stroke="white" strokeWidth={1.5}/>:<g key={cx}/>}} connectNulls={false} name="weight"/>}
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main Dashboard ────────────────────────────────────────────────────────────
|
// ── Main Dashboard ────────────────────────────────────────────────────────────
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const nav = useNavigate()
|
const nav = useNavigate()
|
||||||
|
|
@ -426,7 +201,7 @@ export default function Dashboard() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<QuickWeight onSaved={load}/>
|
<QuickWeightEntry onSaved={load} />
|
||||||
</div>
|
</div>
|
||||||
</DashboardSection>
|
</DashboardSection>
|
||||||
|
|
||||||
|
|
@ -518,7 +293,7 @@ export default function Dashboard() {
|
||||||
>
|
>
|
||||||
<DashboardTile>
|
<DashboardTile>
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<ComboChart weights={weights} nutrition={nutrition}/>
|
<TrendKcalWeightChart weights={weights} nutrition={nutrition} windowDays={30} />
|
||||||
<div style={{display:'flex',gap:16,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
|
<div style={{display:'flex',gap:16,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
|
||||||
<span><span style={{display:'inline-block',width:12,height:2,background:'#EF9F27',verticalAlign:'middle',marginRight:3}}/>Ø Kalorien</span>
|
<span><span style={{display:'inline-block',width:12,height:2,background:'#EF9F27',verticalAlign:'middle',marginRight:3}}/>Ø Kalorien</span>
|
||||||
<span><span style={{display:'inline-block',width:12,height:2,background:'#378ADD',verticalAlign:'middle',marginRight:3}}/>Gewicht</span>
|
<span><span style={{display:'inline-block',width:12,height:2,background:'#378ADD',verticalAlign:'middle',marginRight:3}}/>Gewicht</span>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* Standard-Layout v1 (nur Pilot-Fallback ohne API).
|
* Standard-Layout v1 (nur Pilot `/pilot/viz` ohne API).
|
||||||
* Reihenfolge: gleich backend/widget_catalog.WIDGET_CATALOG bei Änderung dort mitpflegen
|
* API-Nutzer: default_layout aus Backend (alle Katalog-IDs; aktiv = DEFAULT_LAB_WIDGET_IDS).
|
||||||
* (oder später nur noch aus GET /api/app/dashboard-layout default_layout beziehen).
|
* Diese Datei: kompakte feste 5 Widgets für den Pilot – nicht automatisch alle P1-Widgets.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_LAB_LAYOUT = {
|
export const DEFAULT_LAB_LAYOUT = {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,17 @@ import PilotQuickCapture from '../components/pilot/PilotQuickCapture'
|
||||||
import PilotKpiBoard from '../components/pilot/PilotKpiBoard'
|
import PilotKpiBoard from '../components/pilot/PilotKpiBoard'
|
||||||
import PilotBodySection from '../components/pilot/PilotBodySection'
|
import PilotBodySection from '../components/pilot/PilotBodySection'
|
||||||
import PilotActivitySection from '../components/pilot/PilotActivitySection'
|
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 { normalizeBodyChartDays } from './bodyChartDays'
|
||||||
import { registerDashboardWidget } from './dashboardWidgetRegistry'
|
import { registerDashboardWidget } from './dashboardWidgetRegistry'
|
||||||
|
|
||||||
|
|
@ -49,6 +60,71 @@ export function ensurePilotLabWidgetsRegistered() {
|
||||||
chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days),
|
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 */
|
/** @internal Nur für Tests */
|
||||||
|
|
|
||||||
|
|
@ -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_list_issues` | Issues listen, optional alle Seiten |
|
||||||
| `gitea_get_issue` | Ein Issue mit Body |
|
| `gitea_get_issue` | Ein Issue mit Body |
|
||||||
| `gitea_comment_issue` | Kommentar |
|
| `gitea_comment_issue` | Kommentar |
|
||||||
|
| `gitea_patch_issue` | Titel und/oder **Beschreibung** des Issues ändern (PATCH) |
|
||||||
| `gitea_create_issue` | Neu anlegen |
|
| `gitea_create_issue` | Neu anlegen |
|
||||||
| `gitea_close_issue` / `gitea_reopen_issue` | Status |
|
| `gitea_close_issue` / `gitea_reopen_issue` | Status |
|
||||||
| `gitea_get_repo_file` | Datei remote via API |
|
| `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
|
## 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,17 @@ Dient dazu, **Issues** auf deiner Gitea-Instanz zu lesen und anzulegen – mit d
|
||||||
|
|
||||||
Python 3.10+ (nur Standardbibliothek).
|
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)
|
## Aufruf (im Repo-Root)
|
||||||
|
|
||||||
```powershell
|
```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 "…"
|
||||||
python scripts/gitea/gitea_api.py issues comment 42 --body-file path/to/comment.md
|
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
|
# Schließen / wieder öffnen
|
||||||
python scripts/gitea/gitea_api.py issues close 42
|
python scripts/gitea/gitea_api.py issues close 42
|
||||||
python scripts/gitea/gitea_api.py issues reopen 42
|
python scripts/gitea/gitea_api.py issues reopen 42
|
||||||
|
|
@ -67,3 +83,4 @@ python scripts/gitea/gitea_api.py repo file backend/main.py --ref develop
|
||||||
## MCP (Tools direkt im Agent)
|
## 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.
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,31 @@ def cmd_issues_reopen(args: argparse.Namespace, base: str, token: str, owner: st
|
||||||
sys.exit(1)
|
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:
|
def cmd_repo_contents(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None:
|
||||||
status, payload = repo_file_content(
|
status, payload = repo_file_content(
|
||||||
base, token, owner, repo, args.path, ref=args.ref or ""
|
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.add_argument("number", type=int)
|
||||||
p_ro.set_defaults(_handler=cmd_issues_reopen)
|
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)")
|
p_repo = sub.add_parser("repo", help="Repository (API)")
|
||||||
r_sub = p_repo.add_subparsers(dest="repo_cmd", required=True)
|
r_sub = p_repo.add_subparsers(dest="repo_cmd", required=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ mcp = FastMCP(
|
||||||
instructions=(
|
instructions=(
|
||||||
"Gitea-Tools für das Repo aus GITEA_OWNER/GITEA_REPO. "
|
"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})
|
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()
|
@mcp.tool()
|
||||||
def gitea_close_issue(issue_number: int) -> str:
|
def gitea_close_issue(issue_number: int) -> str:
|
||||||
"""Issue schließen (state=closed)."""
|
"""Issue schließen (state=closed)."""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user