feat: Enhance dashboard widget configuration and introduce new widgets
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- 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:
Lars 2026-04-07 14:19:45 +02:00
parent c0c512e942
commit 3d498d03c1
28 changed files with 1487 additions and 243 deletions

View File

@ -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

View File

@ -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],
} }

View File

@ -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}

View File

@ -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}) == {}

View File

@ -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():

View File

@ -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 = [

View File

@ -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 790)", "description": "Training & Konsistenz (optional: config chart_days 790)",
}, },
{
"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 790, 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 7120, 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)

View 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,62,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>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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 &amp; 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>
)
}

View File

@ -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 &amp; 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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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,62,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>

View File

@ -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,

View File

@ -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 */

View File

@ -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.

View File

@ -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
@ -66,4 +82,5 @@ 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.

View File

@ -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)

View File

@ -31,7 +31,9 @@ mcp = FastMCP(
"mitai-gitea", "mitai-gitea",
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)."""