Bug Fixes #63

Merged
Lars merged 5 commits from develop into main 2026-04-05 17:54:00 +02:00
16 changed files with 648 additions and 129 deletions

View File

@ -13,7 +13,8 @@ jobs:
set -e
echo "=== Deploying to DEVELOPMENT ==="
cd /home/lars/docker/bodytrack-dev
git pull origin develop
git fetch origin develop
git reset --hard origin/develop
docker compose -f docker-compose.dev-env.yml build --no-cache
docker compose -f docker-compose.dev-env.yml up -d
sleep 5

View File

@ -13,7 +13,9 @@ jobs:
set -e
echo "=== Deploying to PRODUCTION ==="
cd /home/lars/docker/bodytrack
git pull origin main
# Arbeitskopie = exakt origin/main (vermeidet Abbruch bei lokalem package-lock o. ä.)
git fetch origin main
git reset --hard origin/main
docker compose -f docker-compose.yml build --no-cache
docker compose -f docker-compose.yml up -d
sleep 5

3
.gitignore vendored
View File

@ -64,4 +64,5 @@ tmp/
# Cursor MCP mit Secrets (Example: .cursor/mcp.json.example)
.cursor/mcp.json
.claude/settings.local.jsonfrontend/package-lock.json
.claude/settings.local.json
frontend/package-lock.json

View File

@ -6,6 +6,7 @@
> | Architektur-Regeln | `.claude/rules/ARCHITECTURE.md` |
> | Coding-Regeln | `.claude/rules/CODING_RULES.md` |
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
## Claude Code Verantwortlichkeiten
@ -69,6 +70,8 @@ backend/
frontend/src/
├── App.jsx # Root, Auth-Gates, Navigation
├── app.css # CSS-Variablen + globale Styles
├── config/ # appNav.js · adminNav.js (Single Source für Main/Admin-Nav)
├── layouts/ # AdminShell · RequireAdmin
├── context/ # AuthContext · ProfileContext
├── pages/ # Alle Screens
└── utils/
@ -90,6 +93,10 @@ frontend/src/
**Branch:** develop
**Nächster Schritt:** Frontend Chart Integration → Testing → Prod Deploy v0.9i
### GUI / Informationsarchitektur (Abnahme dieser Iteration, 2026-04-05)
Admin-Bereich (`AdminShell`, Hub-Routen), Hauptnavigation inkl. **Ziele** (`config/appNav.js`), Einstellungen nur aktives Profil + E-Mail, KI-Analyse Ergebnis in rechter Spalte, **PWA** Bottom-Nav inkl. iOS Safe Area. Zentrale Agent-Doku: **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`**. Responsive-Epic **Gitea #30:** Phasenplan `docs/issues/PHASE_PLAN_RESPONSIVE_UI.md`**P7 Kern erledigt**, **P8** (Regression/Abnahme) ausstehend; Issue bewusst **nicht** geschlossen.
### Updates (28.03.2026 - Phase 0c Multi-Layer Architecture Complete) 🆕
#### Phase 0c: Multi-Layer Data Architecture ✅ **COMPLETED**

View File

@ -9,7 +9,8 @@ Endpoints:
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import Literal
from datetime import datetime, timedelta
from datetime import datetime, timedelta, date
from decimal import Decimal, InvalidOperation
import csv
import io
import json
@ -19,6 +20,174 @@ from db import get_db, get_cursor
router = APIRouter(prefix="/api/sleep", tags=["sleep"])
def _strip_row_keys(row: dict) -> dict:
return {(k or "").strip(): (v.strip() if isinstance(v, str) else v) for k, v in row.items()}
def _safe_float(value) -> float | None:
if value is None or value == "":
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, Decimal):
return float(value)
try:
s = str(value).strip().replace(",", ".")
return float(s)
except (ValueError, InvalidOperation):
return None
def _parse_apple_sleep_datetime(value: str) -> datetime:
raw = (value or "").strip()
if not raw:
raise ValueError("empty datetime")
fmts = (
"%Y-%m-%d %H:%M:%S %z",
"%d.%m.%y %H:%M:%S",
"%d.%m.%Y %H:%M:%S",
"%Y-%m-%d %H:%M:%S",
)
last_err = None
for fmt in fmts:
try:
return datetime.strptime(raw, fmt)
except ValueError as e:
last_err = e
raise ValueError(f"Unbekanntes Datumsformat: {raw!r}") from last_err
def _hr_to_minutes(hours: float | None) -> int:
if hours is None:
return 0
return int(round(float(hours) * 60))
def _detect_apple_sleep_csv_format(fieldnames: list[str] | None) -> Literal["segments", "summary"]:
if not fieldnames:
raise HTTPException(
status_code=400,
detail="CSV enthält keine Spaltenüberschriften.",
)
fn = {(f or "").strip() for f in fieldnames}
if {"Start", "End", "Duration (hr)", "Value"}.issubset(fn):
return "segments"
if "Start" in fn and "End" in fn and "Total Sleep (hr)" in fn:
return "summary"
raise HTTPException(
status_code=400,
detail=(
"Unbekanntes Apple-Health-Schlaf-CSV. "
"Erwartet wird entweder der Segment-Export (Spalten Start, End, Duration (hr), Value) "
"oder die Schlafanalyse mit Nacht-Zusammenfassung (u. a. Total Sleep (hr), Core/Light, Deep, REM)."
),
)
def _build_nights_from_apple_summary(reader: csv.DictReader) -> dict[date, dict]:
nights_dict: dict[date, dict] = {}
for raw in reader:
row = _strip_row_keys(raw)
start_s = row.get("Start") or ""
end_s = row.get("End") or ""
if not start_s or not end_s:
continue
try:
start_dt = _parse_apple_sleep_datetime(start_s)
end_dt = _parse_apple_sleep_datetime(end_s)
except ValueError:
continue
dt_key = (row.get("Date/Time") or row.get("Datum/Uhrzeit") or "").strip()
if dt_key:
try:
wake_d = datetime.strptime(dt_key[:10], "%Y-%m-%d").date()
except ValueError:
wake_d = end_dt.date()
else:
wake_d = end_dt.date()
core_hr = _safe_float(row.get("Core (hr)"))
if core_hr is None:
core_hr = _safe_float(row.get("Light (hr)")) or 0.0
deep_min = _hr_to_minutes(_safe_float(row.get("Deep (hr)")))
rem_min = _hr_to_minutes(_safe_float(row.get("REM (hr)")))
light_min = _hr_to_minutes(core_hr)
awake_min = _hr_to_minutes(_safe_float(row.get("Awake (hr)")))
nights_dict[wake_d] = {
"bedtime": start_dt,
"wake_time": end_dt,
"segments": [],
"deep_minutes": deep_min,
"rem_minutes": rem_min,
"light_minutes": light_min,
"awake_minutes": awake_min,
}
return nights_dict
def _build_nights_from_apple_segments(reader: csv.DictReader, phase_map: dict) -> dict[date, dict]:
segments = []
for raw in reader:
row = _strip_row_keys(raw)
phase_key = (row.get("Value") or "").strip()
phase_en = phase_map.get(phase_key)
if phase_en is None:
continue
try:
start_dt = _parse_apple_sleep_datetime(row.get("Start") or "")
end_dt = _parse_apple_sleep_datetime(row.get("End") or "")
duration_hr = _safe_float(row.get("Duration (hr)"))
if duration_hr is None:
continue
except (ValueError, TypeError):
continue
duration_min = int(duration_hr * 60)
segments.append({
"start": start_dt,
"end": end_dt,
"duration_min": duration_min,
"phase": phase_en,
})
segments.sort(key=lambda s: s["start"])
nights = []
current_night = None
for seg in segments:
if current_night is None or (seg["start"] - current_night["wake_time"]).total_seconds() > 7200:
current_night = {
"bedtime": seg["start"],
"wake_time": seg["end"],
"segments": [],
"deep_minutes": 0,
"rem_minutes": 0,
"light_minutes": 0,
"awake_minutes": 0,
}
nights.append(current_night)
current_night["segments"].append(seg)
current_night["wake_time"] = max(current_night["wake_time"], seg["end"])
current_night["bedtime"] = min(current_night["bedtime"], seg["start"])
if seg["phase"] == "deep":
current_night["deep_minutes"] += seg["duration_min"]
elif seg["phase"] == "rem":
current_night["rem_minutes"] += seg["duration_min"]
elif seg["phase"] == "light":
current_night["light_minutes"] += seg["duration_min"]
elif seg["phase"] == "awake":
current_night["awake_minutes"] += seg["duration_min"]
nights_dict: dict[date, dict] = {}
for night in nights:
wake_date = night["wake_time"].date()
nights_dict[wake_date] = night
return nights_dict
# ── Models ────────────────────────────────────────────────────────────────────
class SleepCreate(BaseModel):
@ -460,96 +629,46 @@ async def import_apple_health_sleep(
"""
Import sleep data from Apple Health CSV export.
Expected CSV format:
Start,End,Duration (hr),Value,Source
2026-03-14 22:44:23,2026-03-14 23:00:19,0.266,Kern,Apple Watch
Supports:
- Segment-Export: Start, End, Duration (hr), Value (Kern/Tief/REM/Wach oder Core/Deep/)
- Schlafanalyse (Nacht-Zusammenfassung): Date/Time, Start, End, Total Sleep (hr),
Core/Light (hr), Deep (hr), REM (hr), Awake (hr),
- Aggregates segments by night (wake date)
- Maps German phase names: Kernlight, REMrem, Tiefdeep, Wachawake
- Stores raw segments in JSONB
- Does NOT overwrite manual entries (source='manual')
- Aggregates segments by night (wake date) where applicable
- Stores Roh-Segmente in JSONB (bei Zusammenfassung oft leer)
- Überschreibt keine manuellen Einträge (source='manual')
"""
pid = session['profile_id']
# Read CSV
content = await file.read()
csv_text = content.decode('utf-8-sig') # Handle BOM
csv_text = content.decode('utf-8-sig')
reader = csv.DictReader(io.StringIO(csv_text))
fmt = _detect_apple_sleep_csv_format(reader.fieldnames)
# Phase mapping (German → English)
phase_map = {
'Kern': 'light',
'REM': 'rem',
'Tief': 'deep',
'Wach': 'awake',
'Schlafend': None # Ignore initial sleep entry
"Kern": "light",
"Core": "light",
"Light": "light",
"REM": "rem",
"Tief": "deep",
"Deep": "deep",
"Wach": "awake",
"Awake": "awake",
"Schlafend": None,
"Asleep": None,
"In Bed": None,
}
# Parse segments
segments = []
for row in reader:
phase_de = row['Value'].strip()
phase_en = phase_map.get(phase_de)
if fmt == "summary":
nights_dict = _build_nights_from_apple_summary(reader)
else:
nights_dict = _build_nights_from_apple_segments(reader, phase_map)
if phase_en is None: # Skip "Schlafend"
continue
start_dt = datetime.strptime(row['Start'], '%Y-%m-%d %H:%M:%S')
end_dt = datetime.strptime(row['End'], '%Y-%m-%d %H:%M:%S')
duration_hr = float(row['Duration (hr)'])
duration_min = int(duration_hr * 60)
segments.append({
'start': start_dt,
'end': end_dt,
'duration_min': duration_min,
'phase': phase_en
})
# Sort segments chronologically
segments.sort(key=lambda s: s['start'])
# Group segments into nights (gap-based)
# If gap between segments > 2 hours → new night
nights = []
current_night = None
for seg in segments:
# Start new night if:
# 1. First segment
# 2. Gap > 2 hours since last segment
if current_night is None or (seg['start'] - current_night['wake_time']).total_seconds() > 7200:
current_night = {
'bedtime': seg['start'],
'wake_time': seg['end'],
'segments': [],
'deep_minutes': 0,
'rem_minutes': 0,
'light_minutes': 0,
'awake_minutes': 0
}
nights.append(current_night)
# Add segment to current night
current_night['segments'].append(seg)
current_night['wake_time'] = max(current_night['wake_time'], seg['end'])
current_night['bedtime'] = min(current_night['bedtime'], seg['start'])
# Sum phases
if seg['phase'] == 'deep':
current_night['deep_minutes'] += seg['duration_min']
elif seg['phase'] == 'rem':
current_night['rem_minutes'] += seg['duration_min']
elif seg['phase'] == 'light':
current_night['light_minutes'] += seg['duration_min']
elif seg['phase'] == 'awake':
current_night['awake_minutes'] += seg['duration_min']
# Convert nights list to dict with wake_date as key
nights_dict = {}
for night in nights:
wake_date = night['wake_time'].date() # Date when you woke up
nights_dict[wake_date] = night
if not nights_dict:
raise HTTPException(
status_code=400,
detail="Keine importierbaren Schlafzeilen gefunden (prüfe Start/Ende und Format).",
)
# Insert nights
imported = 0

View File

@ -0,0 +1,95 @@
# GUI, Informationsarchitektur, Admin & Navigation (Abnahme 2026-04-05)
> **Zweck:** Einheitliche Referenz für Menschen und Code-Agents (Cursor, Claude Code, …).
> **Gitea:** Teilaspekte zu [#30 Responsive UI](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/30); Admin-Layout kein separates Issue.
> **Branch-Realität:** Umsetzung auf `develop`, Deploy nach `main` wie üblich.
---
## Kurzüberblick
| Thema | Kern |
|-------|------|
| **Admin** | Eigener Bereich mit Shell wie KI-Analyse: Gruppen-Chips (mobil) / Sidebar (Desktop), Hub `/admin/g/:groupId`, Route-Guard `RequireAdmin` |
| **Ziele** | Eigener Hauptnav-Punkt `/goals`; nicht mehr primär unter Analyse; Dashboard + Einstellungen als Sekundär-Einstiege |
| **Einstellungen / Profil** | Nur **aktives** Profil inline; E-Mail-Self-Service; neue Profile nur **Admin → Benutzerverwaltung** |
| **KI-Analyse** | Neue Ergebnis-Karte im **rechten** Spalt der `analysis-split`, damit Kategorie-Nav nicht verdrängt wird |
| **PWA / iPhone** | Bottom-Nav: Safe Area auf **`.bottom-nav`**, kein Clipping durch `overflow-y` + `safe-area` auf Items |
| **Deploy Prod** | Workflow: `git fetch` + `git reset --hard origin/<branch>` statt `pull` bei schmutziger `package-lock` |
| **#14 Icon Picker** | Erledigt (`EmojiIconPicker`); Gitea geschlossen |
---
## Dateien (Single Source of Truth im Code)
| Bereich | Pfade |
|---------|--------|
| Hauptnavigation | `frontend/src/config/appNav.js` (`getMainNavItems`) |
| Active-State Bottom + Sidebar | `frontend/src/App.jsx` (`navItemActive`), `frontend/src/components/DesktopSidebar.jsx` |
| Admin Shell & Routing | `frontend/src/layouts/AdminShell.jsx`, `frontend/src/layouts/RequireAdmin.jsx`, `frontend/src/config/adminNav.js` (`ADMIN_GROUPS`, Hubs) |
| Admin-Seiten | `frontend/src/pages/AdminHomePage.jsx`, `AdminGroupHubPage.jsx`, `AdminUsersPage.jsx`, `AdminSystemPage.jsx`, … |
| Einstellungen Profil | `frontend/src/pages/SettingsPage.jsx`, `.settings-page__field` in `app.css` |
| KI-Analyse Layout | `frontend/src/pages/Analysis.jsx` + `.analysis-split*` in `app.css` |
| Admin-Zugriff verweigert | `frontend/src/pages/Dashboard.jsx` (`adminDenied` State aus `location.state`) |
| Bottom-Nav / Safe Area | `frontend/src/app.css` (`--nav-h`, `--nav-pad-top`, `.bottom-nav`, `.app-main` padding-bottom) |
| Deploy | `.gitea/workflows/deploy-prod.yml`, `deploy-dev.yml` |
| Backend Profil E-Mail | `backend/models.py` (`ProfileUpdate.email`), `backend/routers/profiles.py` (`update_profile` + Eindeutigkeit / Verifikation) |
| .gitignore Fix | `frontend/package-lock.json` eigene Zeile (war mit `settings.local` verklebt) |
---
## Navigation: Reihenfolge (Mobile & Desktop)
Übersicht → Erfassen → Verlauf → **Ziele** → Analyse → Einstellungen → **[Admin]**
- **Ziele:** `NavLink` mit `end: true` (exakt `/goals`).
- **Admin:** nur wenn `session.role === 'admin'`; Highlight bei `pathname.startsWith('/admin')`.
---
## Admin-Bereich (IA)
1. **Shell** nutzt dieselben UI-Klassen wie die Analyse: `.analysis-split`, `.analysis-split__nav-wrap`, `.analysis-split__main`.
2. **Nur Gruppen** in der Shell-Nav (inkl. Zähler pro Gruppe).
3. Konkrete Seiten: Karten auf **`/admin/g/:groupId`** (z. B. `features`, `prompts`, `system`).
4. **KI-Prompts** eigene Gruppe; **Basiseinstellungen** = SMTP + Placeholder-Export (`/admin/system`).
5. Kein Admin-Block mehr in den **Einstellungen** (nur Admin-Hinweis-Link zu Benutzerverwaltung).
---
## Backend-Hinweis Profil
`PUT /api/profile` akzeptiert `email`; Änderung setzt `email_verified` u. a. zurück (siehe `profiles.py`).
---
## Gitea / Issues (manuell oder `scripts/gitea/gitea_api.py`)
| Issue | Empfehlung |
|-------|------------|
| **#30** | Offen lassen oder TEILWEISE: Desktop-Sidebar existiert; ergänzt um Admin-Shell, scrollable Bottom-Nav, iOS Safe-Area. Kommentar mit Link **auf dieses Dokument**. |
| **#14** | Bereits geschlossen (Icon-Picker). |
| **Kein eigenes Admin-IA-Issue** | Optional: Sub-Issue „Admin Shell P7“ schließen oder mit #30 verknüpfen. |
Schließe **kein** großes #30 ohne Abnahme „volle Responsive-Spec“ diese Session liefert nur einen **abgegrenzten** GUI-Stand.
---
## Checks für Folge-Agents
1. `frontend`: `npm run build`
2. Admin-Route als Nicht-Admin: Redirect `/`, Dashboard-Hinweis
3. iPhone PWA: Bottom-Nav nicht beschnitten; Content nicht hinter Home-Indicator
4. `develop` → nach Abnahme `main` + Deploy (siehe aktualisierte Workflows)
---
## Changelog-Kurz
- Admin: Shell, Hub-Routen, `RequireAdmin`, entfernt aus Settings.
- Ziele: `appNav`, Dashboard, Settings, Analyse nur noch Hinweis-Link.
- Settings: Mein Profil, E-Mail, keine Profilliste.
- Analyse: `newResult` in `analysis-split__main`.
- CSS: Bottom-Nav scroll + Safe Area + `--nav-pad-top`.
- Deploy: `reset --hard` auf Runner-Arbeitskopie.
- Review-Liste: #14 erledigt (siehe `REVIEW_OPEN_ISSUES_2026-04-04.md`).

View File

@ -3,7 +3,7 @@
> **Gitea:** [#30 Responsive UI](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/30)
> **Spec:** `.claude/docs/functional/RESPONSIVE_UI.md`
> **Breakpoint:** `<1024px` = Mobile (Bottom-Nav, bestehendes Verhalten), `≥1024px` = Desktop (Sidebar 220px)
> **Letzte Plan-Aktualisierung:** 2026-04-04 (P6; P7 Admin bewusst offen)
> **Letzte Plan-Aktualisierung:** 2026-04-05 (P7 Admin-Shell umgesetzt; Doku `GUI_IA_ADMIN_NAV_2026-04-05.md`; P8 ausstehend)
---
@ -18,7 +18,7 @@
| P4 | Verlauf (Tabs links / Content rechts) | ☑ erledigt | `History.jsx` + `.history-*` in `app.css`; Tab-State bei `location.state.tab` |
| P5 | Analyse (Prompts links / Ergebnis rechts) | ☑ erledigt | `Analysis.jsx` + `.analysis-split*` in `app.css` |
| P6 | Erfassung / Capture & Formularseiten | ☑ erledigt | `.capture-page` + `--capture-content-max` (eine Desktop-Breite); CaptureShell-Navigation |
| P7 | Admin & restliche Vollbreiten-Seiten | ⏸ Konzeption | Layout nach Abstimmung; nicht mit P6 mitgezogen |
| P7 | Admin & restliche Vollbreiten-Seiten | ☑ erledigt (Kern) | **2026-04-05:** `AdminShell` + Hub-Routen + `adminNav`; Tabellen/„volle Breite“ weiter iterativ laut Spec §5.5 |
| P8 | Abschluss, Regression, Spec-Pflege | ☐ pending | |
**Status-Legende:** `☐ pending` · `◐ in Arbeit` · `☑ erledigt` · `⏸ blockiert`
@ -202,6 +202,8 @@ Spec **§5.4**: Desktop Formulare **zentriert**, **max-width ~600px** im Content
## Phase P7 Admin & übrige Seiten
**Stand 2026-04-05:** Admin nutzt **dedizierte Shell** mit Gruppen-Navigation und Hubs (`AdminShell`, `adminNav.js`, `RequireAdmin`) — IA- und Routing-Grundlage erledigt. Tabellen „mehr Breite“ / feinere Layout-Tuning gemäß §5.5 können weiterlaufen ohne Shell-Refactor. Doku: `GUI_IA_ADMIN_NAV_2026-04-05.md`.
### Ziel
Spec **§5.5**: Admin-Tabellen nutzen auf Desktop **mehr Breite**; Mobile weiterhin horizontales Scrollen wo nötig.

View File

@ -24,7 +24,7 @@
| 26 | Charts erweitern | TEILWEISE | Phase-0c API + History/NutritionCharts |
| 27 | Korrelationen & Insights | TEILWEISE | C-Charts + offene Data-Layer-TODOs |
| 29 | Abilities-Matrix UI | TEILWEISE | Admin/ProfileBuilder, UX offen |
| 30 | Responsive UI Sidebar | OFFEN | Weiterhin Bottom-Nav-fokussiert |
| 30 | Responsive UI Sidebar | **TEILWEISE** | P1P6 + **P7 Admin-Shell** + iOS Bottom-Nav Safe-Area; SSoT-Doku: **`GUI_IA_ADMIN_NAV_2026-04-05.md`**; #30 bleibt offen bis P8/Abnahme |
| 32 | Version-System (inkl. ehem. #33) | OFFEN | Gitea: Body/Titel 2026-04-04 aktualisiert; Runner/Build-Git bewusst später |
| 33 | — | GESCHLOSSEN | In #32 konsolidiert (superseded) |
| 34 | External Volumes Doku | PRÜFEN | Gegen Compose abgleichen |
@ -112,11 +112,11 @@
### #30 [FEAT] Responsive UI Desktop Sidebar + 2-spaltig
**Code-Stand:** Weiterhin stark **Mobile-first** (z. B. `bottom-nav` in `App.jsx`); keine ausgebaute Desktop-Sidebar wie im klassischen Admin-Dashboard.
**Code-Stand (2026-04-05):** **Desktop-Sidebar** (`DesktopSidebar`, `config/appNav.js`) und **Mobile Bottom-Nav** mit horizontalem Scroll + **Safe Area** (`app.css`); **Admin** eigener Bereich **`AdminShell`** mit Gruppen-Hub wie Analyse (`adminNav.js`, `/admin/g/:groupId`, `RequireAdmin`). Siehe **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** (Dateipfade, IA, Deploy-Hinweis).
**Umsetzungsplan:** `docs/issues/PHASE_PLAN_RESPONSIVE_UI.md` (Phasen P0P8, Abnahmekriterien & Tests, Fortschrittstabelle).
**Umsetzungsplan:** `docs/issues/PHASE_PLAN_RESPONSIVE_UI.md` (Phasen P0P8). **P7** Kern umgesetzt; **P8** (Regression, finale Abnahme) stehen noch aus Issue **nicht** schließen ohne P8-Checkliste.
**Vorschlag:** `OFFEN`.
**Vorschlag:** `TEILWEISE` Gitea-Kommentar mit Link auf die GUI-Doku; Titel/Beschreibung optional um „Admin-Shell / Ziele-Nav / iOS PWA-Leiste“ ergänzen.
---

View File

@ -0,0 +1,202 @@
# Umsetzungsplan: Sportspezifisch auswertbare Trainingsprofile
**Status:** Konzept für fachliche Abstimmung / Konzeptagent
**Datum:** 2026-04-05
**Bezug:** Fachliches Teilkonzept „Sportspezifisch auswertbare Trainingsprofile“ (User-Entwurf)
**Hinweis:** Keine Implementierung ohne Freigabe der unter [Offene Fragen](#offene-fragen-für-den-fachlichen-konzeptagenten) gekläerten Punkte.
---
## 1. Kurzfazit: Machbarkeit
**Fachlich und technisch grundsätzlich machbar** als gestaffelte Erweiterung auf dem bestehenden System:
- `training_types.profile` (JSONB)
- `TrainingProfileEvaluator` (`backend/profile_evaluator.py`)
- Persistenz: `activity_log.evaluation`, `quality_label`, `overall_score`
**Engpass:** Viele im Teilkonzept genannten Kriterien (z. B. Muskelgruppen, Nähe zum muskulären Versagen, detaillierte Zonenzeiten) **lassen sich aus der aktuellen `activity_log`-Datenbasis nicht zuverlässig ableiten**. Dafür braucht es zusätzliche Erfassung, Importfelder oder explizite „niedrige Evidenz / unbekannt“-Markierung in Metriken und KI-Kontext (Placeholder-Registry, Erklärbarkeit).
---
## 2. Ist-Analyse (Abhängigkeiten)
### 2.1 Daten & Konfiguration
| Baustein | Rolle |
|---------|--------|
| `training_types` | `category`, `subcategory`, `name_*`, optional `abilities` (JSONB), `profile` (JSONB) |
| `activity_type_mappings` | Import/manuelles Mapping `activity_type``training_type_id` (unverändert lassen) |
| `training_parameters` | Registry messbarer Parameter für Regeln (`source_field` → `activity_log`) |
| `activity_log` | u. a. `training_type_id`, `evaluation`, `quality_label`, `overall_score` |
### 2.2 Bewertung heute
- Der Evaluator läuft über mehrere **rule_sets** (Minimumanforderungen, Zonen, Effekte, Periodisierung, KPI, Safety).
- **Ein** zusammengefasster `overall_score` und **ein** `quality_label` (`excellent` | `good` | `acceptable` | `poor`).
- **Relevanz, intrinsische Qualität, Zielbezug, Platzierung** sind im Ergebnis **nicht formal getrennt**; Periodisierung/Performance sind teils **MVP/vereinfacht**.
### 2.3 Downstream (nicht brechen)
- **Issue #31 / Quality Filter:** `profiles.quality_filter_level`, `quality_filter.py` (SQL-Fragmente nach `quality_label`)
- **Frontend:** Badges in Aktivitäts-UI, History-Hinweise
- **Data Layer / Charts:** u. a. `calculate_quality_sessions_pct`, `activity_score`, Korrelationen
- **Placeholder Registry:** u. a. `quality_sessions_pct` (bekannte Semantik-/Label-Diskrepanz, siehe unten)
### 2.4 Bekannte technische Inkonsistenz (Bestand)
`calculate_quality_sessions_pct` filtert u. a. auf `quality_label IN (..., 'very_good', ...)`, der `TrainingProfileEvaluator` setzt **`very_good` nicht**. Das sollte **vor oder zusammen mit** größeren Profil-Erweiterungen bereinigt werden, damit neue Dimensionen nicht auf wackeliger Basis validiert werden.
---
## 3. Umsetzungsprinzipien (Kompatibilität)
1. **Kein Big-Bang:** Consumer erwarten weiterhin sinnvolle `quality_label` / `overall_score`, es sei denn, es gibt eine **versionierte Cutover-Strategie**.
2. **Additive JSON-Struktur** in `evaluation` (z. B. mehrdimensionale Teilergebnisse), ohne bestehende Schlüssel willkürlich zu entfernen.
3. **Legacy-Vertrag festlegen:** z. B. `overall_score` = **primär intrinsische** Qualität (oder explizit „Legacy-Komposit“ mit `schema_version`).
4. **`training_types` bleibt Single Source** für Typ + Profil; Mapping-Import-Logik nicht „mitziehen“, außer es ist fachlich nötig.
5. **Neue Coach-Metriken:** über Placeholder Registry, mit Evidence-Tags; keine doppelte Wahrheit neben der Registry.
6. **KI:** `ai_context` / `interpretation_boundary` im Profil ausbaubar machen (was erlaubt/verboten ist, bei Datenlücken).
---
## 4. Phasenplan
### Phase 0 Begriffe, Verträge, Bestandsaufnahme
- Glossar: Relevanz, intrinsische Qualität, Zielbeitrag, Platzierung, Belastung, sportspezifische Qualität → **Mapping auf bestehende `rule_sets`**.
- Vollständige Liste: wer liest `quality_label` / `evaluation` / `overall_score` (APIs, SQL, Frontend).
- Align: `very_good` vs. Evaluator-Labels; optional: Rolle von `acceptable` in „Qualitätssessions“ vs. globalem Filter **fachlich** klären.
### Phase 1 Metamodell im Profil-JSON
- Erweiterung `training_types.profile`: z. B. `sport_logic`, `dimensions_config`, `goal_linkage`, `load_character`, `recovery_character`, `interference`, `ai_interpretation`.
- Evaluator liefert **Teilscores + Begründungen** pro Dimension; **Legacy-Felder** ableitbar und dokumentiert.
### Phase 2 Zielbezug
- Input: aktive Ziele, `goal_mode`, Focus-Gewichte (`user_focus_area_weights`).
- Ausgabe: eigene Dimension **`goal_fit`**, ohne intrinsische Bewertung zu überschreiben.
### Phase 3 Wochenkontext / Platzierung / Interferenz
- Kontext in `load_evaluation_context` erweitern (Reihenfolge, Vortag, Ruhetage, optional Vitals/Schlaf).
- Regeln schrittweise; False-Positive-Risiko beachten.
### Phase 4 Sportspezifische Templates
- Profilvorlagen pro `sport_logic` (analog `profile_templates.py`), unterschiedliche Parameter und Schwellen.
- `confidence` / `data_sufficiency` pro Dimension.
### Phase 5 Coach-Metriken & Placeholder
- Neue Platzhalter nur bei klarer Semantik; ggf. `quality_sessions_pct` **umschreiben** oder durch neuen Key ersetzen + Deprecation-Plan.
### Phase 6 Frontend / Admin
- Dimensionen sichtbar machen; Badges können vorerst Legacy-Score anzeigen.
---
## 5. Risiken (Kurz)
| Risiko | Maßnahme |
|--------|----------|
| Bedeutung von `quality_label` wechselt still | `evaluation.schema_version`, dokumentierter Vertrag, ggf. separates `intrinsic_quality_label` nach Cutover |
| Charts/Scores springen | Shadow-Vergleich, gestuftes Rollout |
| KI übertreibt bei Datenlücken | `ai_interpretation` + Metadata-Evidence |
| Registry-Drift | Alle neuen Kennzahlen registrieren |
---
## Offene Fragen für den fachlichen Konzeptagenten
Die folgenden Blöcke sind bewusst als Markdown-Codeblöcke formatiert, damit sie **unverändert** in Prompts oder Tickets übernommen werden können.
### Block A Priorität & Konflikte zwischen Dimensionen
```markdown
Wenn sich Dimensionen widersprechen (z. B. intrinsische Qualität „hoch“, Zielpassung „niedrig“, Platzierung „problematisch“):
1. Welche Dimension soll für die **primäre Nutzer-/Coach-Aussage** in der UI führend sein?
2. Gibt es eine **feste Prioritätsreihenfolge** (z. B. Sicherheit > Platzierung > Zielpassung > intrinsische Qualität) oder ist sie kontextabhängig?
3. Soll es eine **explizite „Gesamt-Coach-Empfehlung“** geben, die aus den Dimensionen kombiniert wird, oder nur parallele Teilurteile ohne Gesamtnote?
```
### Block B Minimaldatensatz & Sportlogiken
```markdown
Pro Trainingsfamilie / „sport_logic“ (Kraft, Cardio, Schnellkraft, Kampfsport, Mobility, Erholung aktiv, Geist & Meditation):
1. Welche **messbaren Felder** sind minimal nötig, damit eine sportspezifische Bewertung **fachlich vertretbar** ist (nicht nur „nice to have“)?
2. Welche Aussagen dürfen **ohne** diese Felder **nicht** getroffen werden (nur „unbekannt“ / Confidence niedrig)?
3. Sollen fehlende Daten die **intrinsische** Bewertung hart begrenzen (Cap), oder nur **Zusatzdimensionen** betreffen?
```
### Block C Einzeltraining vs. Wochenkontext
```markdown
Platzierung / Kontextqualität:
1. Darf schlechte Platzierung die **sichtbare Einheit-Qualität** numerisch senken, oder nur **Flags/Hinweise** erzeugen?
2. Welche zeitliche Auflösung ist Pflicht: nur **Datum** oder auch **start_time** / Reihenfolge am Tag?
3. Wie gehen wir mit **unvollständiger Historie** um (Importlücken, manuell gelöschte Einträge)?
```
### Block D Ziele & Multi-Ziel
```markdown
Zielbezug (goal_fit):
1. Bewertung strikt gegen **ein Primärziel**, oder gewichtet gegen **alle aktiven Ziele**?
2. Wie werden **Focus Areas** vs. **konkrete Goals** priorisiert, wenn sie sich widersprechen?
3. Soll „behindert das Ziel“ (Teilkonzept) ein **eigener Score** oder nur eine **kategorische** Aussage sein?
```
### Block E KI & Haftung / Grenzen
```markdown
KI-Interpretationsrahmen (pro Profil oder global):
1. Welche Formulierungen sind **verboten** (medizinische Diagnosen, absolute Leistungsversprechen, …)?
2. Muss jede KI-gestützte Aussage einen **Evidenz-Hinweis** tragen („basierend auf Dauer/HF“, „Muskelgruppen nicht erfasst“)?
3. Soll der Nutzer **opt-in** für interpretative Texte jenseits der Regeln haben?
```
### Block F Legacy-Metriken & Filter
```markdown
Bestehende Kennzahlen:
1. Soll `quality_label` / `overall_score` dauerhaft **nur intrinsisch** bedeuten, oder ein **Komposit** aus mehreren Dimensionen?
2. Wie sollen `quality_sessions_pct` und der globale **Quality Filter** künftig mit mehrdimensionalen Urteilen umgehen (nur intrinsisch filtern vs. Kombination)?
3. Ist `acceptable` fachlich eine „Qualitätssession“ oder nur „formal ok“?
```
### Block G Abnahme / Akzeptanz
```markdown
Abnahme:
1. Welche **messbaren** Abnahmekriterien gelten pro Phase (z. B. % konsistent bewerteter Sessions, keine Regression bei Filter-Charts)?
2. Wer **fachlich** sign-off: Rolle „Domain Owner“ vs. Entwicklung?
```
---
## Anhang: Referenz im Repository
| Thema | Pfad (orientierend) |
|-------|---------------------|
| Evaluator | `backend/profile_evaluator.py` |
| Regeln | `backend/rule_engine.py` |
| Auswertung + Kontext | `backend/evaluation_helper.py` |
| Profile JSON / Migration | `backend/migrations/014_training_profiles.sql`, Templates `backend/profile_templates.py` |
| Quality Filter | `backend/quality_filter.py` |
| Aktivitäts-API inkl. Eval | `backend/routers/activity.py` |
| Quality-Sessions-Metrik | `backend/data_layer/activity_metrics.py` (`calculate_quality_sessions_pct`) |
| Placeholder Registry | `backend/placeholder_registrations/activity_metrics.py` |
---
*Ende des Dokuments.*

View File

@ -12,7 +12,9 @@
--accent-dark: #0a5c43;
--danger: #D85A30;
--warn: #EF9F27;
--nav-h: 64px;
/* Höhe der eigentlichen Tab-Zeile (ohne Abstand/Home-Indicator) */
--nav-h: 56px;
--nav-pad-top: 8px;
--header-h: 52px;
--font: system-ui, -apple-system, 'Segoe UI', sans-serif;
--capture-content-max: 800px;
@ -36,20 +38,61 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
position: sticky; top: 0; z-index: 10;
}
.app-logo { font-size: 18px; font-weight: 700; color: var(--accent); letter-spacing: -0.02em; }
.app-main { flex: 1; overflow-y: auto; padding: 16px 16px calc(var(--nav-h) + 16px); }
/* unten: Tab-Leiste + Abstand nach oben zur Leiste + Home-Indicator (iPhone) */
.app-main {
flex: 1;
overflow-y: auto;
padding: 16px 16px calc(var(--nav-h) + var(--nav-pad-top) + env(safe-area-inset-bottom, 0px) + 20px);
}
.bottom-nav {
position: fixed; bottom: 0; left: 50%; transform: translateX(-50%);
width: 100%; max-width: 600px;
height: var(--nav-h); display: flex; align-items: stretch;
background: var(--surface); border-top: 1px solid var(--border);
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 600px;
display: flex;
align-items: center;
background: var(--surface);
border-top: 1px solid var(--border);
z-index: 20;
overflow-x: auto;
overflow-y: visible;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
justify-content: flex-start;
gap: 2px;
padding: var(--nav-pad-top) 6px env(safe-area-inset-bottom, 0px);
min-height: calc(var(--nav-h) + var(--nav-pad-top) + env(safe-area-inset-bottom, 0px));
height: auto;
box-sizing: border-box;
}
.bottom-nav::-webkit-scrollbar {
display: none;
}
.nav-item {
flex: 1; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 3px; color: var(--text3);
text-decoration: none; font-size: 10px; font-weight: 500;
transition: color 0.15s; padding-bottom: env(safe-area-inset-bottom, 0);
flex: 0 0 auto;
min-width: 56px;
max-width: 96px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
color: var(--text3);
text-decoration: none;
font-weight: 500;
transition: color 0.15s;
padding: 2px 4px 4px;
box-sizing: border-box;
}
.nav-item span {
font-size: 10px;
line-height: 1.15;
text-align: center;
max-width: 100%;
}
.nav-item.active { color: var(--accent); }
.nav-item svg { flex-shrink: 0; }
@ -256,11 +299,15 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
.analysis-page__header {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
margin-bottom: 16px;
gap: 12px;
flex-wrap: wrap;
}
.analysis-page__header > div:first-child {
flex: 1;
min-width: 0;
}
.analysis-split {
display: flex;
@ -544,14 +591,6 @@ a.analysis-split__nav-item {
}
}
/* 6-item nav - smaller labels */
.nav-item span { font-size: 11px; }
/* 7-item nav scrollable */
.bottom-nav { overflow-x: auto; }
.nav-item span { font-size: 11px; }
.nav-item { min-width: 60px; }
/* Header with profile avatar */
.app-header { display:flex; align-items:center; justify-content:space-between; }
.app-header a { display:flex; }

View File

@ -2,6 +2,7 @@ import {
LayoutDashboard,
PlusSquare,
TrendingUp,
Target,
BarChart2,
Settings,
Shield
@ -18,6 +19,7 @@ function baseItems() {
{ to: '/', label: 'Übersicht', end: true },
{ to: '/capture', label: 'Erfassen' },
{ to: '/history', label: 'Verlauf' },
{ to: '/goals', label: 'Ziele', shortLabel: 'Ziele', end: true },
{ to: '/analysis', label: 'Analyse' },
{ to: '/settings', label: 'Einstellungen', shortLabel: 'Einst.' }
]
@ -29,6 +31,7 @@ export function getMainNavItems(isAdmin) {
LayoutDashboard,
PlusSquare,
TrendingUp,
Target,
BarChart2,
Settings
]

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react'
import { Brain, Trash2, ChevronDown, ChevronUp, Target } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { Brain, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
import { Link } from 'react-router-dom'
import { api } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import Markdown from '../utils/Markdown'
@ -330,7 +330,6 @@ function InsightCard({ ins, onDelete, defaultOpen=false, prompts=[] }) {
export default function Analysis() {
const { canUseAI } = useAuth()
const navigate = useNavigate()
const [prompts, setPrompts] = useState([])
const [allInsights, setAllInsights] = useState([])
const [loading, setLoading] = useState(null)
@ -474,15 +473,14 @@ export default function Analysis() {
return (
<div className="analysis-page">
<div className="analysis-page__header">
<h1 className="page-title" style={{ margin: 0 }}>KI-Analyse</h1>
<button
type="button"
className="btn btn-secondary"
onClick={() => navigate('/goals')}
style={{ fontSize: 13, padding: '6px 12px' }}
>
<Target size={14} /> Ziele
</button>
<div>
<h1 className="page-title" style={{ margin: 0 }}>KI-Analyse</h1>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0', lineHeight: 1.5, maxWidth: 520 }}>
Ziele und Fokusbereiche steuern den Kontext der Auswertungen {' '}
<Link to="/goals" style={{ color: 'var(--accent)', fontWeight: 600 }}>unter Ziele konfigurieren</Link>
{' '}(auch über die untere Navigation).
</p>
</div>
</div>
<div className="tabs">
@ -508,8 +506,8 @@ export default function Analysis() {
{/* ── Analysen starten ── */}
{tab==='run' && (
<div>
{/* Fresh result shown immediately */}
{newResult && (
{/* Fallback: Ergebnis oben nur wenn keine Pipeline-Split-Ansicht (z. B. keine Prompts) */}
{newResult && !(canUseAI && pipelinePrompts.length > 0) && (
<div style={{marginBottom:16}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--accent)',marginBottom:8}}>
Neue Analyse erstellt:
@ -565,6 +563,19 @@ export default function Analysis() {
</nav>
</div>
<div className="analysis-split__main">
{newResult && (
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--accent)', marginBottom: 8 }}>
Neue Analyse erstellt:
</div>
<InsightCard
ins={{ ...newResult, created: new Date().toISOString() }}
onDelete={deleteInsight}
defaultOpen={true}
prompts={prompts}
/>
</div>
)}
{activeCategoryKey && (() => {
const group = pipelineGroups.find(g => g.categoryKey === activeCategoryKey)
if (!group?.prompts?.length) return null

View File

@ -256,6 +256,7 @@ export default function Dashboard() {
const { activeProfile } = useProfile()
const [adminDeniedHint, setAdminDeniedHint] = useState(false)
const [goalsCount, setGoalsCount] = useState(null)
const [stats, setStats] = useState(null)
const [weights, setWeights] = useState([])
@ -310,6 +311,13 @@ export default function Dashboard() {
return () => window.clearTimeout(clear)
}, [location.state, nav])
useEffect(() => {
if (!activeProfile?.id) return
api.listGoals()
.then((list) => setGoalsCount(Array.isArray(list) ? list.length : 0))
.catch(() => setGoalsCount(null))
}, [activeProfile?.id])
if (loading) return <div className="empty-state"><div className="spinner"/></div>
const latestCal = calipers[0]
@ -588,18 +596,26 @@ export default function Dashboard() {
<DashboardSection
title="Ziele & Fokus"
description="Trainingsmodus, Schwerpunkte und konkrete Ziele für die KI."
description="Strategische Ziele und Schwerpunkte eigener Menüpunkt „Ziele“, Kontext für KI und Dashboard."
headerRight={
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }}
onClick={(e)=>{ e.stopPropagation(); nav('/goals') }}>
Verwalten
Ziele bearbeiten
</button>
}
>
<DashboardTile>
<div className="card section-gap" style={{ cursor: 'pointer' }} onClick={()=>nav('/goals')}>
<div style={{fontSize:12,color:'var(--text2)',padding:'8px 0'}}>
Definiere deine Trainingsmodus und konkrete Ziele für bessere KI-Analysen
{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)', padding: goalsCount != null ? '0 0 8px' : '8px 0' }}>
Hier pflegst du Focus Areas, Meilensteine und Fortschritt unabhängig von der KI-Analyse-Seite.
Tippen zum Öffnen oder unten in der Navigation <strong>Ziele</strong> wählen.
</div>
</div>
</DashboardTile>

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Save, Download, Upload, Check, LogOut, Key, BarChart3 } from 'lucide-react'
import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target } from 'lucide-react'
import { Link } from 'react-router-dom'
import { useProfile } from '../context/ProfileContext'
import { useAuth } from '../context/AuthContext'
@ -428,6 +428,19 @@ export default function SettingsPage() {
</button>
</div>
<div className="card section-gap">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Target size={15} color="var(--accent)" /> Strategische Ziele
</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.6 }}>
Konkrete Ziele, Focus Areas und Fortschritt eigener Bereich{' '}
<strong>Ziele</strong> in der Navigation (nicht in der KI-Analyse).
</p>
<Link to="/goals" className="btn btn-secondary btn-full" style={{ textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box' }}>
Zu den Zielen
</Link>
</div>
{/* Auth actions */}
<div className="card section-gap">
<div className="card-title">🔐 Konto</div>

View File

@ -34,6 +34,7 @@ python scripts/gitea/gitea_api.py issues create --title "Fix: …" --body-file p
# Kommentar
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
# Schließen / wieder öffnen
python scripts/gitea/gitea_api.py issues close 42

View File

@ -71,8 +71,14 @@ def cmd_issues_create(args: argparse.Namespace, base: str, token: str, owner: st
def cmd_issues_comment(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None:
body = args.body or ""
if getattr(args, "body_file", None):
body = Path(args.body_file).read_text(encoding="utf-8")
if not body.strip():
sys.stderr.write("issues comment: --body oder --body-file mit Inhalt erforderlich\n")
sys.exit(2)
status, payload = issues_comment(
base, token, owner, repo, args.number, args.body
base, token, owner, repo, args.number, body
)
print(json.dumps(payload, indent=2, ensure_ascii=False))
if status >= 400:
@ -149,7 +155,8 @@ def main() -> None:
p_co = i_sub.add_parser("comment", help="Add comment")
p_co.add_argument("number", type=int)
p_co.add_argument("--body", required=True)
p_co.add_argument("--body", default="")
p_co.add_argument("--body-file", help="Kommentar aus Datei (UTF-8); überschreibt --body wenn gesetzt")
p_co.set_defaults(_handler=cmd_issues_comment)
p_cl = i_sub.add_parser("close", help="Close issue")