diff --git a/.gitea/workflows/deploy-dev.yml b/.gitea/workflows/deploy-dev.yml index 8e9d3d1..7425a8d 100644 --- a/.gitea/workflows/deploy-dev.yml +++ b/.gitea/workflows/deploy-dev.yml @@ -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 diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml index ca356fd..e0b25a0 100644 --- a/.gitea/workflows/deploy-prod.yml +++ b/.gitea/workflows/deploy-prod.yml @@ -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 diff --git a/.gitignore b/.gitignore index 97d2d19..e105a20 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 4945c22..6f9408c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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** diff --git a/backend/routers/sleep.py b/backend/routers/sleep.py index dfdf8e6..da5b559 100644 --- a/backend/routers/sleep.py +++ b/backend/routers/sleep.py @@ -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: Kern→light, REM→rem, Tief→deep, Wach→awake - - 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 diff --git a/docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md b/docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md new file mode 100644 index 0000000..c96fccf --- /dev/null +++ b/docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md @@ -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/` 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`). diff --git a/docs/issues/PHASE_PLAN_RESPONSIVE_UI.md b/docs/issues/PHASE_PLAN_RESPONSIVE_UI.md index 42542f3..4a121ef 100644 --- a/docs/issues/PHASE_PLAN_RESPONSIVE_UI.md +++ b/docs/issues/PHASE_PLAN_RESPONSIVE_UI.md @@ -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. diff --git a/docs/issues/REVIEW_OPEN_ISSUES_2026-04-04.md b/docs/issues/REVIEW_OPEN_ISSUES_2026-04-04.md index 30d23d5..4168188 100644 --- a/docs/issues/REVIEW_OPEN_ISSUES_2026-04-04.md +++ b/docs/issues/REVIEW_OPEN_ISSUES_2026-04-04.md @@ -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** | P1–P6 + **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 P0–P8, Abnahmekriterien & Tests, Fortschrittstabelle). +**Umsetzungsplan:** `docs/issues/PHASE_PLAN_RESPONSIVE_UI.md` (Phasen P0–P8). **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. --- diff --git a/docs/issues/UMSETZUNGSPLAN_TRAININGSPROFILE_SPORTSPEZIFISCH_2026-04-05.md b/docs/issues/UMSETZUNGSPLAN_TRAININGSPROFILE_SPORTSPEZIFISCH_2026-04-05.md new file mode 100644 index 0000000..4392549 --- /dev/null +++ b/docs/issues/UMSETZUNGSPLAN_TRAININGSPROFILE_SPORTSPEZIFISCH_2026-04-05.md @@ -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.* diff --git a/frontend/src/app.css b/frontend/src/app.css index dc81b06..2ec9efb 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; } diff --git a/frontend/src/config/appNav.js b/frontend/src/config/appNav.js index e3cd55c..23d36d8 100644 --- a/frontend/src/config/appNav.js +++ b/frontend/src/config/appNav.js @@ -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 ] diff --git a/frontend/src/pages/Analysis.jsx b/frontend/src/pages/Analysis.jsx index 1cc6ae1..25cea80 100644 --- a/frontend/src/pages/Analysis.jsx +++ b/frontend/src/pages/Analysis.jsx @@ -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 (
-

KI-Analyse

- +
+

KI-Analyse

+

+ Ziele und Fokusbereiche steuern den Kontext der Auswertungen –{' '} + unter „Ziele“ konfigurieren + {' '}(auch über die untere Navigation). +

+
@@ -508,8 +506,8 @@ export default function Analysis() { {/* ── Analysen starten ── */} {tab==='run' && (
- {/* Fresh result shown immediately */} - {newResult && ( + {/* Fallback: Ergebnis oben nur wenn keine Pipeline-Split-Ansicht (z. B. keine Prompts) */} + {newResult && !(canUseAI && pipelinePrompts.length > 0) && (
✅ Neue Analyse erstellt: @@ -565,6 +563,19 @@ export default function Analysis() {
+ {newResult && ( +
+
+ ✅ Neue Analyse erstellt: +
+ +
+ )} {activeCategoryKey && (() => { const group = pipelineGroups.find(g => g.categoryKey === activeCategoryKey) if (!group?.prompts?.length) return null diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 7cff98c..f8b4dd8 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -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
const latestCal = calipers[0] @@ -588,18 +596,26 @@ export default function Dashboard() { { e.stopPropagation(); nav('/goals') }}> - Verwalten → + Ziele bearbeiten → } >
nav('/goals')}> -
- Definiere deine Trainingsmodus und konkrete Ziele für bessere KI-Analysen + {goalsCount != null && ( +
+ {goalsCount === 0 + ? 'Noch keine Ziele angelegt.' + : `${goalsCount} ${goalsCount === 1 ? 'Ziel' : 'Ziele'} im System.`} +
+ )} +
+ Hier pflegst du Focus Areas, Meilensteine und Fortschritt – unabhängig von der KI-Analyse-Seite. + Tippen zum Öffnen oder unten in der Navigation Ziele wählen.
diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index d014779..a3d881c 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -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() {
+
+
+ Strategische Ziele +
+

+ Konkrete Ziele, Focus Areas und Fortschritt – eigener Bereich{' '} + Ziele in der Navigation (nicht in der KI-Analyse). +

+ + Zu den Zielen + +
+ {/* Auth actions */}
🔐 Konto
diff --git a/scripts/gitea/README.md b/scripts/gitea/README.md index bfa8853..e5aae90 100644 --- a/scripts/gitea/README.md +++ b/scripts/gitea/README.md @@ -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 diff --git a/scripts/gitea/gitea_api.py b/scripts/gitea/gitea_api.py index b85d21b..5f4367f 100644 --- a/scripts/gitea/gitea_api.py +++ b/scripts/gitea/gitea_api.py @@ -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")