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)
|
||||
- ✅ 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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}) == {}
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
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 { 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 (
|
||||
<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 ────────────────────────────────────────────────────────────
|
||||
export default function Dashboard() {
|
||||
const nav = useNavigate()
|
||||
|
|
@ -426,7 +201,7 @@ export default function Dashboard() {
|
|||
}
|
||||
>
|
||||
<div className="card section-gap">
|
||||
<QuickWeight onSaved={load}/>
|
||||
<QuickWeightEntry onSaved={load} />
|
||||
</div>
|
||||
</DashboardSection>
|
||||
|
||||
|
|
@ -518,7 +293,7 @@ export default function Dashboard() {
|
|||
>
|
||||
<DashboardTile>
|
||||
<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)'}}>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -67,3 +83,4 @@ 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).
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ mcp = FastMCP(
|
|||
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. "
|
||||
"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)."""
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user