Bug Fixes #63
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
95
docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md
Normal file
95
docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md
Normal 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`).
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.*
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user