Merge pull request 'Bug Fixing Kombi-Übungen - Performance Update 1 (Phase 0-2)' (#33) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 32s
Test Suite / playwright-tests (push) Successful in 58s

Reviewed-on: #33
This commit is contained in:
Lars 2026-05-14 09:09:55 +02:00
commit d153a22545
41 changed files with 2221 additions and 610 deletions

View File

@ -15,6 +15,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) |
| exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar |
| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id |
| dashboard | `GET /api/dashboard/kpis` | ja | `get_tenant_context` | wie `GET /api/exercises` + `GET /api/training-units` | Aggregat für Dashboard-Kurzüberblick (ein Roundtrip) |
| training_modules | `/api/training-modules*` | ja | `get_tenant_context` | ja | Bibliotheks-Module wie Vorlagen/Rahmen; POST Default `club_id` bei `visibility=club` |
| training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id |
| admin_users | `GET /api/admin/users` | Plattform | `require_auth` | Admin-Rolle | EXEMPT `check_access_layer_hints.py` |
@ -37,13 +38,13 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
Letzte Änderung: 2026-05-12 — Trainingsmodule (`/api/training-modules*`); Governance wie Planungsbibliothek.
Letzte Änderung: 2026-05-13 — `GET /api/dashboard/kpis` (Kurzüberblick-Aggregat).
---
### Changelog (Fortführung)
- **2026-05-12:** `training_modules` Router dokumentiert.
- **2026-05-13:** Dashboard-KPI-Endpunkt dokumentiert.
- **2026-05-07:** Legacy `GET/PUT /api/profile` auf Session-Profil gehärtet; OpenAPI/Health-Ready Produktionsdefaults; Security-Release-Tests + CI-Schritt `security_release_checks.py` — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
- **2026-05-07 (Phase 3):** CSP SPA (nginx); API `nosniff`-Middleware — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.

View File

@ -88,6 +88,90 @@ jobs:
npm run build
echo "✓ Frontend build OK"
# Phase-0 Lastsmoke: nur k6 — eigener Job (kein Node/Playwright), klare CI-Zuordnung.
k6-health-baseline:
name: k6 /health Baseline
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
env:
E2E_TARGET_URL: https://dev.shinkan.jinkendo.de
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: E2E-Ziel wählen (Dev über Proxy vs. Production)
id: e2e
run: |
EVENT="${{ github.event_name }}"
WF_NAME="${{ github.event.workflow_run.name }}"
DEV_BASE="${{ env.E2E_TARGET_URL }}"
if [ "$EVENT" = "workflow_run" ] && [ "$WF_NAME" = "Deploy Production" ]; then
echo "mode=prod" >> $GITHUB_OUTPUT
echo "base_url=https://shinkan.jinkendo.de" >> $GITHUB_OUTPUT
echo "→ k6 gegen Prod-Basis."
else
echo "mode=dev" >> $GITHUB_OUTPUT
echo "base_url=${DEV_BASE}" >> $GITHUB_OUTPUT
echo "→ k6 gegen Dev (${DEV_BASE})."
fi
- name: Dev /health abwarten
if: ${{ steps.e2e.outputs.mode == 'dev' }}
run: |
BASE="${{ steps.e2e.outputs.base_url }}"
echo "Warte auf $BASE/health …"
for i in $(seq 1 90); do
if curl -sf "$BASE/health" >/dev/null 2>&1; then
echo "Health OK (Versuch $i)"
exit 0
fi
sleep 2
done
echo "Timeout: Dev /health nicht erreichbar — Deploy / DNS / Firewall prüfen."
curl -v "$BASE/health" || true
exit 1
- name: Prod /health abwarten
if: ${{ steps.e2e.outputs.mode == 'prod' }}
run: |
BASE="${{ steps.e2e.outputs.base_url }}"
echo "Warte auf $BASE/health …"
for i in $(seq 1 60); do
if curl -sf "$BASE/health" >/dev/null 2>&1; then
echo "Health OK (Versuch $i)"
exit 0
fi
sleep 5
done
echo "Timeout: Prod /health nicht erreichbar"
curl -v "$BASE/health" || true
exit 1
- name: Install k6
run: |
set -e
K6_VER="v0.55.0"
ARCH=$(uname -m)
case "$ARCH" in
x86_64) K6_ARCH=amd64 ;;
aarch64|arm64) K6_ARCH=arm64 ;;
*) echo "k6: unbekannte Architektur: $ARCH"; exit 1 ;;
esac
echo "Installing k6 ${K6_VER} linux-${K6_ARCH}"
curl -sSL "https://github.com/grafana/k6/releases/download/${K6_VER}/k6-${K6_VER}-linux-${K6_ARCH}.tar.gz" -o /tmp/k6.tgz
tar -xzf /tmp/k6.tgz -C /tmp
sudo mv "/tmp/k6-${K6_VER}-linux-${K6_ARCH}/k6" /usr/local/bin/k6
k6 version
- name: k6 Health-Baseline (parallele /health)
env:
BASE_URL: ${{ steps.e2e.outputs.base_url }}
run: |
set -e
echo "k6 gegen BASE_URL=$BASE_URL"
k6 run scripts/load/k6-health-baseline.js
echo "✓ k6 Health-Baseline passed"
playwright-tests:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest

View File

@ -14,6 +14,8 @@
> | Medien-Archiv, Lifecycle, Inline (Plan §11) | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
> | Handover / nächste Session | **`docs/HANDOVER.md`** |
> | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
> | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** |
> | Performance-Baseline (Phase 0) | **`docs/architecture/BASELINE_SNAPSHOT.md`** |
## Projekt-Übersicht

View File

@ -193,7 +193,7 @@ def read_root():
return out
# Register routers
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
app.include_router(auth.router)
app.include_router(profiles.router)
@ -209,6 +209,7 @@ app.include_router(media_assets.admin_rights_router)
app.include_router(media_assets.admin_legal_hold_router)
app.include_router(skills.router)
app.include_router(training_planning.router)
app.include_router(dashboard.router)
app.include_router(training_modules.router)
app.include_router(training_framework_programs.router)
app.include_router(catalogs.router)

View File

@ -0,0 +1,7 @@
-- Unterstützung für GET /api/exercises: ORDER BY e.updated_at DESC
-- und häufiger Pfad created_by_me (= e.created_by = Profil) mit derselben Sortierung.
-- Hinweis: idx_exercises_created_at (014) betrifft created_at, nicht updated_at.
CREATE INDEX IF NOT EXISTS idx_exercises_updated_at_desc ON exercises (updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_exercises_created_by_updated_at_desc ON exercises (created_by, updated_at DESC);

View File

@ -0,0 +1,7 @@
-- GET /api/training-units: Liste nutzt immer tu.framework_slot_id IS NULL (keine Rahmen-Blueprints)
-- und sortiert nach planned_date, planned_time_start (ASC/DESC mit NULLS LAST).
-- Teilindex verkleinert die Menge und unterstützt die Sortierung.
CREATE INDEX IF NOT EXISTS idx_training_units_scheduled_order
ON training_units (planned_date DESC, planned_time_start DESC NULLS LAST)
WHERE framework_slot_id IS NULL;

View File

@ -0,0 +1,33 @@
-- Migration 060: Übungslisten bei großem Bestand (Ziel: Tausende Übungen, viele Filterkombinationen).
-- Ergänzt 058 (globale Sortierung / created_by): kleinere Partial-Indizes für häufige
-- Sichtbarkeits-Pfade der Bibliothek sowie Junction-Indizes für die List-Subqueries
-- (primary_focus_name / JSON-Aggregate mit is_primary).
--
-- Bereits vorhanden und sinnvoll: UNIQUE(exercise_id, …) auf den M:N-Tabellen für EXISTS-Joins;
-- GIN auf exercises.search_vector (014); idx_exercises_exercise_kind (056).
-- Official: OR-Zweig der Bibliothek — kompakter als Full-Table-Scan bei BitmapOr mit anderen Partial-Indizes
CREATE INDEX IF NOT EXISTS idx_exercises_list_official_updated
ON exercises (updated_at DESC)
WHERE visibility = 'official'
AND COALESCE(status, '') <> 'archived';
-- Club: häufig club_id + Sortierung nach updated_at (Mandanten-Bibliothek)
CREATE INDEX IF NOT EXISTS idx_exercises_list_club_updated
ON exercises (club_id, updated_at DESC)
WHERE visibility = 'club'
AND club_id IS NOT NULL
AND COALESCE(status, '') <> 'archived';
-- List-SELECT: Subqueries / json_agg sortieren zuerst nach is_primary (siehe exercises.py)
CREATE INDEX IF NOT EXISTS idx_exercise_focus_areas_exercise_primary
ON exercise_focus_areas (exercise_id, is_primary DESC NULLS LAST, focus_area_id);
CREATE INDEX IF NOT EXISTS idx_exercise_style_directions_exercise_primary
ON exercise_style_directions (exercise_id, is_primary DESC NULLS LAST, style_direction_id);
CREATE INDEX IF NOT EXISTS idx_exercise_training_types_exercise_primary
ON exercise_training_types (exercise_id, is_primary DESC NULLS LAST, training_type_id);
CREATE INDEX IF NOT EXISTS idx_exercise_target_groups_exercise_primary
ON exercise_target_groups (exercise_id, is_primary DESC NULLS LAST, target_group_id);

View File

@ -0,0 +1,22 @@
-- GET /api/training-units: Keyset über (planned_date, planned_time_start NULLS LAST per Sort, id)
-- Ersetzt den reinen Datum/Uhrzeit-Teilindex 059 durch zwei Richtungen mit Tie-Break id.
DROP INDEX IF EXISTS idx_training_units_scheduled_order;
CREATE INDEX IF NOT EXISTS idx_training_units_list_keyset_desc
ON training_units (
planned_date DESC,
(planned_time_start IS NULL) ASC,
planned_time_start DESC NULLS LAST,
id DESC
)
WHERE framework_slot_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_training_units_list_keyset_asc
ON training_units (
planned_date ASC,
(planned_time_start IS NULL) ASC,
planned_time_start ASC NULLS LAST,
id ASC
)
WHERE framework_slot_id IS NULL;

View File

@ -0,0 +1,41 @@
-- list_exercises mit skill_min_level / skill_max_level: EXISTS auf exercise_skills mit numerischem Stufen-Rang.
-- Ausdruck muss mit backend/routers/exercises.py _EXERCISE_SKILL_LEVEL_RANK_SQL (Alias „es“) übereinstimmen.
CREATE INDEX IF NOT EXISTS idx_exercise_skills_exercise_level_rank
ON exercise_skills (
exercise_id,
(CASE COALESCE(
NULLIF(TRIM(LOWER(target_level::text)), ''),
NULLIF(TRIM(LOWER(required_level::text)), '')
)
WHEN 'basis' THEN 1
WHEN 'grundlagen' THEN 2
WHEN 'aufbau' THEN 3
WHEN 'fortgeschritten' THEN 4
WHEN 'optimierung' THEN 5
WHEN 'einsteiger' THEN 1
WHEN 'experte' THEN 5
WHEN '1' THEN 1
WHEN '2' THEN 2
WHEN '3' THEN 3
WHEN '4' THEN 4
WHEN '5' THEN 5
ELSE NULL END)
)
WHERE (CASE COALESCE(
NULLIF(TRIM(LOWER(target_level::text)), ''),
NULLIF(TRIM(LOWER(required_level::text)), '')
)
WHEN 'basis' THEN 1
WHEN 'grundlagen' THEN 2
WHEN 'aufbau' THEN 3
WHEN 'fortgeschritten' THEN 4
WHEN 'optimierung' THEN 5
WHEN 'einsteiger' THEN 1
WHEN 'experte' THEN 5
WHEN '1' THEN 1
WHEN '2' THEN 2
WHEN '3' THEN 3
WHEN '4' THEN 4
WHEN '5' THEN 5
ELSE NULL END) IS NOT NULL;

View File

@ -0,0 +1,103 @@
"""
Dashboard: zusammengefasste Kennzahlen (ein Roundtrip statt mehrerer Listen).
"""
from __future__ import annotations
from datetime import date
from typing import Any, Dict, List
from fastapi import APIRouter, Depends
from tenant_context import TenantContext, get_tenant_context
from routers.exercises import list_exercises_like_get
from routers.training_planning import list_training_units
router = APIRouter(prefix="/api", tags=["dashboard"])
def _slice_training_home_notes(planned_pool: List[Dict[str, Any]], max_notes: int = 5) -> List[Dict[str, Any]]:
out = []
for u in planned_pool:
tn = (u.get("trainer_notes") or "").strip()
n = (u.get("notes") or "").strip()
if tn or n:
out.append(u)
if len(out) >= max_notes:
break
return out
@router.get("/dashboard/kpis")
def get_dashboard_kpis(tenant: TenantContext = Depends(get_tenant_context)):
"""
Kurzüberblick: Übungs-KPIs + YTD-Einheiten + Trainings-Home (nächste Termine, Vermerke, offene Rückschau)
in einem Roundtrip gleiche Filter wie zuvor im Dashboard (mehrere Client-Calls).
"""
year = date.today().year
year_start = f"{year}-01-01"
year_end = f"{year}-12-31"
today = date.today().isoformat()
draft_list = list_exercises_like_get(
tenant, created_by_me=True, status="draft", limit=100
)
mine_list = list_exercises_like_get(
tenant, created_by_me=True, status=None, limit=100
)
ytd_completed = list_training_units(
group_id=None,
club_id=None,
start_date=year_start,
end_date=year_end,
status="completed",
assigned_to_me=True,
debrief_pending=False,
sort="desc",
limit=250,
tenant=tenant,
)
planned_pool = list_training_units(
group_id=None,
club_id=None,
start_date=today,
end_date=None,
status="planned",
assigned_to_me=True,
debrief_pending=False,
sort="asc",
limit=40,
tenant=tenant,
)
review_pending = list_training_units(
group_id=None,
club_id=None,
start_date=None,
end_date=None,
status=None,
assigned_to_me=True,
debrief_pending=True,
sort="desc",
limit=8,
tenant=tenant,
)
draft_preview = [
{"id": int(ex["id"]), "title": ex.get("title") or f"Übung #{ex['id']}"}
for ex in draft_list[:8]
]
return {
"year": year,
"draft_count": len(draft_list),
"draft_capped": len(draft_list) >= 100,
"draft_preview": draft_preview,
"mine_count": len(mine_list),
"mine_capped": len(mine_list) >= 100,
"ytd_completed_count": len(ytd_completed),
"ytd_capped": len(ytd_completed) >= 250,
"training_home": {
"upcoming": planned_pool[:8],
"planned_with_notes": _slice_training_home_notes(planned_pool),
"review_pending": review_pending,
},
}

View File

@ -9,6 +9,7 @@ import json
import logging
import os
import re
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple
from urllib.parse import quote
@ -96,6 +97,7 @@ CASE COALESCE(
WHEN '5' THEN 5
ELSE NULL END
""".strip()
# Bei Änderung: Migration 062 idx_exercise_skills_exercise_level_rank (SQL-Ausdruck) synchron halten.
def normalize_exercise_skill_level(value) -> Optional[str]:
@ -1653,6 +1655,20 @@ def bulk_patch_exercises_metadata(
}
def _parse_cursor_updated_at_list(raw: Optional[str]) -> datetime:
s = (raw or "").strip()
if not s:
raise HTTPException(status_code=400, detail="cursor_updated_at leer")
if s.endswith("Z"):
s = s[:-1] + "+00:00"
try:
return datetime.fromisoformat(s)
except ValueError:
raise HTTPException(
status_code=400, detail="cursor_updated_at ungültig (ISO-8601 erwartet)"
)
@router.get("/exercises")
def list_exercises(
focus_area_ids: list[int] = Query(default=[], description="ODER: mind. einer dieser Fokusbereiche"),
@ -1678,6 +1694,15 @@ def list_exercises(
),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
cursor_updated_at: Optional[str] = Query(
default=None,
description="Keyset: ISO-8601 von updated_at der letzten Zeile; zusammen mit cursor_id (offset dann 0)",
),
cursor_id: Optional[int] = Query(
default=None,
ge=1,
description="Keyset: id der letzten Zeile (Tiebreak bei gleichem updated_at); mit cursor_updated_at",
),
include_variants: bool = Query(
default=False,
description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI",
@ -1746,9 +1771,26 @@ def list_exercises(
Liste aller Übungen mit Filtern.
Lightweight Response (ohne M:N Details, nur IDs und Namen).
Optional include_variants für Variantenauswahl in der Trainingsplanung.
Keyset: cursor_updated_at + cursor_id ersetzt große OFFSET-Werte (Sortierung: updated_at DESC, id DESC).
"""
profile_id = tenant.profile_id
c_ts_raw = (cursor_updated_at or "").strip() or None
use_keyset = c_ts_raw is not None and cursor_id is not None
if (c_ts_raw is not None) != (cursor_id is not None):
raise HTTPException(
status_code=400,
detail="cursor_updated_at und cursor_id müssen zusammen gesetzt werden",
)
if use_keyset and offset != 0:
raise HTTPException(
status_code=400,
detail="Keyset-Pagination: offset nicht kombinieren (nur cursor_* oder nur offset)",
)
cursor_ts_val: Optional[datetime] = None
if use_keyset:
cursor_ts_val = _parse_cursor_updated_at_list(c_ts_raw)
with get_db() as conn:
cur = get_cursor(conn)
@ -1981,6 +2023,12 @@ def list_exercises(
where.append("e.search_vector @@ plainto_tsquery('german', %s)")
params.append(qtext)
if cursor_ts_val is not None and cursor_id is not None:
where.append(
"(e.updated_at < %s OR (e.updated_at = %s AND e.id < %s))"
)
params.extend([cursor_ts_val, cursor_ts_val, cursor_id])
variants_sql = ""
if include_variants:
variants_sql = """,
@ -2046,10 +2094,10 @@ def list_exercises(
LEFT JOIN profiles p ON e.created_by = p.id
LEFT JOIN clubs c ON e.club_id = c.id
WHERE {' AND '.join(where)}
ORDER BY e.updated_at DESC
ORDER BY e.updated_at DESC, e.id DESC
LIMIT %s OFFSET %s
"""
params.extend([limit, offset])
params.extend([limit, 0 if use_keyset else offset])
cur.execute(query, params)
rows = cur.fetchall()
@ -2076,6 +2124,58 @@ def list_exercises(
return out
def list_exercises_like_get(
tenant: TenantContext,
*,
created_by_me: bool,
status: Optional[str],
limit: int,
) -> List[Dict[str, Any]]:
"""
Programmatischer Aufruf mit gleicher Semantik wie GET /api/exercises
(ohne FastAPI-Query-Default-Objekte an list_exercises zu übergeben).
"""
return list_exercises(
focus_area_ids=[],
focus_area=None,
visibility_any=[],
visibility=None,
status_any=[],
status=status,
skill_ids=[],
skill_id=None,
style_direction_ids=[],
style_direction_id=None,
training_type_ids=[],
training_type_id=None,
target_group_ids=[],
target_group_id=None,
skill_min_level=None,
skill_max_level=None,
search=None,
ai_search=None,
limit=limit,
offset=0,
include_variants=False,
visibility_exclude_any=[],
status_exclude_any=[],
exclude_without_focus=False,
focus_only_without_focus_areas=False,
focus_area_must_include_ids=[],
focus_area_must_exclude_ids=[],
style_direction_must_include_ids=[],
style_direction_must_exclude_ids=[],
training_type_must_include_ids=[],
training_type_must_exclude_ids=[],
target_group_must_include_ids=[],
target_group_must_exclude_ids=[],
include_archived=False,
created_by_me=created_by_me,
exercise_kind_any=[],
tenant=tenant,
)
@router.get("/exercises/{exercise_id}")
def get_exercise(
exercise_id: int,

View File

@ -4,8 +4,8 @@ und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung).
Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin.
"""
from datetime import date, timedelta
from typing import Any, Dict, List, Optional
from datetime import date, datetime, time as dt_time, timedelta
from typing import Any, Dict, List, Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException, Query
from psycopg2.extras import Json as PsycopgJson
@ -42,6 +42,78 @@ def _optional_positive_int(val, field_name: str) -> Optional[int]:
return i
def _parse_cursor_planned_date(raw: Optional[str]) -> date:
s = (raw or "").strip()
if not s:
raise HTTPException(status_code=400, detail="cursor_planned_date ungültig (YYYY-MM-DD)")
try:
return date.fromisoformat(s[:10])
except ValueError:
raise HTTPException(status_code=400, detail="cursor_planned_date ungültig (YYYY-MM-DD)")
def _parse_cursor_planned_time_optional(raw: Optional[str]) -> Optional[dt_time]:
s = (raw or "").strip()
if not s:
return None
for fmt in ("%H:%M:%S", "%H:%M"):
try:
return datetime.strptime(s, fmt).time()
except ValueError:
continue
raise HTTPException(
status_code=400,
detail="cursor_planned_time ungültig (HH:MM oder HH:MM:SS)",
)
def _training_units_keyset_sql(
order_dir: str,
cursor_date: date,
cursor_time_null: bool,
cursor_time: Optional[dt_time],
cursor_id: int,
) -> Tuple[str, List[Any]]:
"""WHERE-Zusatz für Keyset; sort=asc|desc muss zu order_dir passen."""
d = cursor_date
cid = cursor_id
if order_dir == "ASC":
if cursor_time_null:
frag = (
"(tu.planned_date > %s OR (tu.planned_date = %s AND "
"tu.planned_time_start IS NULL AND tu.id > %s))"
)
return frag, [d, d, cid]
assert cursor_time is not None
ct = cursor_time
frag = (
"(tu.planned_date > %s OR (tu.planned_date = %s AND ("
"(tu.planned_time_start IS NOT NULL AND (tu.planned_time_start > %s OR "
"(tu.planned_time_start = %s AND tu.id > %s))) OR "
"(tu.planned_time_start IS NULL)"
")))"
)
return frag, [d, d, ct, ct, cid]
if order_dir == "DESC":
if cursor_time_null:
frag = (
"(tu.planned_date < %s OR (tu.planned_date = %s AND "
"tu.planned_time_start IS NULL AND tu.id < %s))"
)
return frag, [d, d, cid]
assert cursor_time is not None
ct = cursor_time
frag = (
"(tu.planned_date < %s OR (tu.planned_date = %s AND ("
"(tu.planned_time_start IS NOT NULL AND (tu.planned_time_start < %s OR "
"(tu.planned_time_start = %s AND tu.id < %s))) OR "
"(tu.planned_time_start IS NULL)"
")))"
)
return frag, [d, d, ct, ct, cid]
raise HTTPException(status_code=400, detail="sort: nur asc oder desc")
def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id: Optional[int]):
if not exercise_id:
if variant_id:
@ -1254,6 +1326,19 @@ def list_training_units(
),
sort: str = Query(default="desc"),
limit: Optional[int] = Query(default=None),
cursor_planned_date: Optional[str] = Query(
default=None,
description="Keyset: YYYY-MM-DD der letzten Zeile (mit cursor_id)",
),
cursor_planned_time: Optional[str] = Query(
default=None,
description="Keyset: HH:MM oder HH:MM:SS; weglassen/leer wenn planned_time_start NULL",
),
cursor_id: Optional[int] = Query(
default=None,
ge=1,
description="Keyset: id der letzten Zeile (mit cursor_planned_date)",
),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = tenant.profile_id
@ -1264,6 +1349,40 @@ def list_training_units(
if gid and cid:
raise HTTPException(status_code=400, detail="Nur eines der Parameter group_id oder club_id angeben")
order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC"
lim: Optional[int] = None
if limit is not None:
try:
lim = int(limit)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="limit ungültig")
if lim < 1:
raise HTTPException(status_code=400, detail="limit ungültig")
lim = min(lim, 250)
c_id_q = cursor_id
c_date_raw = (cursor_planned_date or "").strip() or None
time_nonempty = (cursor_planned_time or "").strip() != ""
has_cursor_partial = (
(c_id_q is not None) != (c_date_raw is not None) or (time_nonempty and c_id_q is None)
)
if has_cursor_partial:
raise HTTPException(
status_code=400,
detail="cursor_planned_date und cursor_id müssen zusammen gesetzt werden",
)
use_keyset = c_id_q is not None
if use_keyset and lim is None:
raise HTTPException(status_code=400, detail="Keyset: Parameter limit ist erforderlich")
cursor_d: Optional[date] = None
cursor_t: Optional[dt_time] = None
cursor_t_null = False
if use_keyset:
assert c_id_q is not None and c_date_raw is not None
cursor_d = _parse_cursor_planned_date(c_date_raw)
cursor_t = _parse_cursor_planned_time_optional(cursor_planned_time)
cursor_t_null = cursor_t is None
with get_db() as conn:
cur = get_cursor(conn)
@ -1286,17 +1405,6 @@ def list_training_units(
if not (ok_staff or ok_org or ok_member):
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe")
order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC"
lim: Optional[int] = None
if limit is not None:
try:
lim = int(limit)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="limit ungültig")
if lim < 1:
raise HTTPException(status_code=400, detail="limit ungültig")
lim = min(lim, 250)
query = """
SELECT tu.*,
tg.name as group_name,
@ -1379,10 +1487,25 @@ def list_training_units(
where.append("tu.status = %s")
params.append(status)
if use_keyset:
assert cursor_d is not None and c_id_q is not None
ks_sql, ks_params = _training_units_keyset_sql(
order_dir,
cursor_d,
cursor_t_null,
cursor_t,
int(c_id_q),
)
where.append(ks_sql)
params.extend(ks_params)
if where:
query += " WHERE " + " AND ".join(where)
query += f" ORDER BY tu.planned_date {order_dir}, tu.planned_time_start {order_dir} NULLS LAST"
query += (
f" ORDER BY tu.planned_date {order_dir}, (tu.planned_time_start IS NULL) ASC, "
f"tu.planned_time_start {order_dir} NULLS LAST, tu.id {order_dir}"
)
if lim is not None:
query += " LIMIT %s"
params.append(lim)

View File

@ -0,0 +1,21 @@
"""GET /api/dashboard/kpis: Auth (kein DB nötig)."""
from __future__ import annotations
import os
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from main import app
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def test_dashboard_kpis_unauthenticated_401(client: TestClient) -> None:
r = client.get("/api/dashboard/kpis")
assert r.status_code == 401

View File

@ -0,0 +1,82 @@
"""GET /api/exercises: Keyset-Parameter-Validierung (ohne DB-Zwang)."""
from __future__ import annotations
import os
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from auth import require_auth
from main import app
from tenant_context import TenantContext, get_tenant_context
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_overrides() -> None:
yield
app.dependency_overrides.pop(require_auth, None)
app.dependency_overrides.pop(get_tenant_context, None)
def test_list_exercises_keyset_incomplete_returns_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
r = client.get(
"/api/exercises",
params={"cursor_id": "42"},
headers={"X-Auth-Token": "test"},
)
assert r.status_code == 400
assert "cursor_updated_at" in r.json().get("detail", "").lower()
def test_list_exercises_keyset_with_offset_returns_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
r = client.get(
"/api/exercises",
params={
"cursor_id": "1",
"cursor_updated_at": "2026-01-01T12:00:00.000Z",
"offset": "10",
},
headers={"X-Auth-Token": "test"},
)
assert r.status_code == 400
assert "offset" in r.json().get("detail", "").lower()
def test_list_exercises_keyset_bad_timestamp_returns_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
r = client.get(
"/api/exercises",
params={"cursor_id": "1", "cursor_updated_at": "not-a-date"},
headers={"X-Auth-Token": "test"},
)
assert r.status_code == 400

View File

@ -0,0 +1,108 @@
"""GET /api/training-units: Keyset-Parameter-Validierung (ohne DB-Zwang)."""
from __future__ import annotations
import os
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from auth import require_auth
from main import app
from tenant_context import TenantContext, get_tenant_context
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_overrides() -> None:
yield
app.dependency_overrides.pop(require_auth, None)
app.dependency_overrides.pop(get_tenant_context, None)
def _tenant() -> TenantContext:
return TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
def test_list_training_units_keyset_incomplete_returns_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = _tenant
r = client.get(
"/api/training-units",
params={"cursor_id": "42"},
headers={"X-Auth-Token": "test"},
)
assert r.status_code == 400
assert "cursor_planned_date" in r.json().get("detail", "").lower()
def test_list_training_units_keyset_without_limit_returns_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = _tenant
r = client.get(
"/api/training-units",
params={
"cursor_id": "1",
"cursor_planned_date": "2026-05-10",
},
headers={"X-Auth-Token": "test"},
)
assert r.status_code == 400
assert "limit" in r.json().get("detail", "").lower()
def test_list_training_units_keyset_bad_date_returns_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = _tenant
r = client.get(
"/api/training-units",
params={
"cursor_id": "1",
"cursor_planned_date": "not-a-date",
"limit": "10",
},
headers={"X-Auth-Token": "test"},
)
assert r.status_code == 400
def test_list_training_units_keyset_bad_time_returns_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = _tenant
r = client.get(
"/api/training-units",
params={
"cursor_id": "1",
"cursor_planned_date": "2026-05-10",
"cursor_planned_time": "25:99",
"limit": "10",
},
headers={"X-Auth-Token": "test"},
)
assert r.status_code == 400
assert "cursor_planned_time" in r.json().get("detail", "").lower()
def test_list_training_units_keyset_time_without_id_returns_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = _tenant
r = client.get(
"/api/training-units",
params={
"cursor_planned_time": "18:00",
"limit": "10",
},
headers={"X-Auth-Token": "test"},
)
assert r.status_code == 400

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.110"
APP_VERSION = "0.8.119"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512057"
DB_SCHEMA_VERSION = "20260514062"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -21,10 +21,11 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.27.3", # load_combination_slots_for_exercise (gemeinsam mit GET Übung); Hydrate für Planung
"training_units": "0.2.0",
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
"training_units": "0.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak
"training_programs": "0.1.0",
"planning": "0.9.3", # GET training-units/:id Sektions-Items: combination_slots + Kandidaten-Titel für Druck/Run
"planning": "0.9.4", # list_training_units: Keyset-Pagination + stabile Sortierung (NULLS LAST + id)
"dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine)
"training_modules": "1.0.0",
"import_wiki": "1.0.0",
"admin": "1.0.0",
@ -35,6 +36,74 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.119",
"date": "2026-05-13",
"changes": [
"Frontend Phase 3 (Teil): Übungsliste — ExerciseListCard-Komponente, Progressions-Tab lazy (Suspense); Übungspicker-Modal mit @tanstack/react-virtual; content-visibility auf Karten im Übungs-Gitter; Playwright-Test 9 Übungsliste.",
],
},
{
"version": "0.8.118",
"date": "2026-05-14",
"changes": [
"GET /api/dashboard/kpis liefert training_home (upcoming, planned_with_notes, review_pending) — gleiche Logik wie zuvor zwei listTrainingUnits-Calls; Dashboard-Frontend ein Request.",
"Migration 062: Index exercise_skills(exercise_id, level_rank_expr) für list_exercises Stufenfilter; Ausdruck wie _EXERCISE_SKILL_LEVEL_RANK_SQL.",
"Phase 2: Vorlagen EXPLAIN unter scripts/load/explain-readpaths.sql; Playwright-Test 8 erwartet 0× GET /api/training-units auf dem Dashboard.",
],
},
{
"version": "0.8.117",
"date": "2026-05-14",
"changes": [
"GET /api/training-units: optionale Keyset-Pagination (cursor_planned_date YYYY-MM-DD, cursor_id, optional cursor_planned_time bei gesetzter Startzeit; bei Keyset ist limit erforderlich). Sortierung um stabile Tie-Breaks ergänzt: (planned_time_start IS NULL), id.",
"Migration 061: Teilindizes training_units für ASC/DESC-Keyset inkl. id (ersetzt idx_training_units_scheduled_order).",
"frontend api.listTrainingUnits: Query-Parameter für Cursor durchreichen.",
],
},
{
"version": "0.8.116",
"date": "2026-05-14",
"changes": [
"Frontend: Org-Posteingang lädt beim ersten Mount per requestIdleCallback (Fallback setTimeout), um parallele API-Aufrufe beim Dashboard-Start zu entzerren; refresh/Inbox-Seite unverändert sofort.",
],
},
{
"version": "0.8.115",
"date": "2026-05-14",
"changes": [
"GET /api/exercises: optionale Keyset-Pagination (cursor_updated_at ISO-8601 + cursor_id), stabile Sortierung updated_at DESC, id DESC; „Mehr laden“ in Übungsliste und Picker nutzt Keyset statt OFFSET.",
],
},
{
"version": "0.8.114",
"date": "2026-05-14",
"changes": [
"Migration 060: Skalierung GET /api/exercises — Partial-Indizes official/club (+ updated_at, ohne archiviert); Junction-Indizes (exercise_id, is_primary) für List-Subqueries.",
],
},
{
"version": "0.8.113",
"date": "2026-05-14",
"changes": [
"Migration 059: Teilindex training_units(planned_date, planned_time_start) nur für Zeilen ohne framework_slot_id — list_training_units Sortierung.",
],
},
{
"version": "0.8.112",
"date": "2026-05-14",
"changes": [
"Migration 058: Indizes exercises(updated_at DESC) und (created_by, updated_at DESC) für list_exercises-Sortierung und „meine Übungen“.",
],
},
{
"version": "0.8.111",
"date": "2026-05-13",
"changes": [
"GET /api/dashboard/kpis: Kurzüberblick (meine Entwürfe, meine Übungen, abgeschlossene Einheiten Kalenderjahr) in einem Aufruf; Dashboard-UI nutzt den Endpunkt.",
"Hilfsfunktion list_exercises_like_get in exercises-Router für programmatische Listen ohne Query-Defaults.",
],
},
{
"version": "0.8.110",
"date": "2026-05-12",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-12
**App-Version / DB-Schema:** App **0.8.110**, DB-Schema **`20260512057`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`)
**Stand:** 2026-05-13
**App-Version / DB-Schema:** App **0.8.119**, DB-Schema **`20260514062`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`)
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@ -20,6 +20,8 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| Thema | Pfad |
|--------|------|
| **Architektur-Zielbild, Refaktor, Roadmap, Regeln** | **`docs/architecture/README.md`** |
| **Performance-Baseline (Phase 0)** | **`docs/architecture/BASELINE_SNAPSHOT.md`**, **`scripts/load/README.md`** |
| Projekt-Setup, Domain grob | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
| **Projekt-Status (aktuell)** | `.claude/docs/PROJECT_STATUS.md` |
| **Medien-Archiv, Lifecycle, Inline-Plan (§11)** | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
@ -74,7 +76,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**.
- **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.110**)
### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.119**)
- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§10.2.1** IDs, **§10.4** Coaching-Stufen, **§10.6** Produkt-Backlog, **Anhang A** Abgleich).
- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4ag** — u.a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).

View File

@ -0,0 +1,105 @@
# Phase 0 Performance-Baseline (Shinkan Jinkendo)
**Zweck:** Reproduzierbarer Startpunkt **vor** Phase 2 (Backend-Lesepfade, Summary-API).
**Stand:** 2026-05-13 · Backend-App-Version laut `backend/version.py`: **0.8.110**
Nach grösseren Deployments oder Schema-Änderungen: Bundle-Abschnitt neu erfassen (`npm run build`); API-/k6-Werte bei Bedarf aktualisieren.
---
## 1. Frontend-Bundle (`npm run build`)
Messung: Repo-Root → `cd frontend && npm run build` (Vite Production).
**Hinweis:** Dateinamen mit Hash (`index-*.js`) ändern sich pro Build; relevant sind Grössenordnungen und gzip.
### 1.1 Einstieg & globale Vendor-Chunks (Auszug letzter Lauf CI-lokal)
| Asset (Muster) | raw kB | gzip kB | Rolle |
|----------------|--------|---------|--------|
| `index.html` | 1.84 | 0.73 | Einstieg |
| `index-*.css` | 127.55 | 21.58 | Globale Styles |
| `index-*.js` (App-Shell / Router) | 64.83 | 17.45 | Haupteinstieg nach Code-Splitting |
| `vendor-react-*.js` | 142.42 | 45.67 | React + DOM |
| `vendor-router-*.js` | 65.94 | 22.51 | react-router |
| `vendor-markdown-*.js` | 161.54 | 49.31 | Markdown-Stack (wird mit Routen geladen) |
| `vendor-pdf-*.js` | 390.80 | 128.98 | jsPDF (Route-bezogen) |
### 1.2 Schwerste Route-Chunks (lazy, nach Route)
| Bereich | typ. Chunk-Grösse (raw / gzip) | Datei-Muster (Beispiel) |
|---------|-------------------------------|-------------------------|
| Trainingsplanung | 71.81 kB / 18.67 kB | `TrainingPlanningPage-*.js` |
| Übung bearbeiten | 91.31 kB / 22.49 kB | `ExerciseFormPage-*.js` |
| Medienbibliothek | 59.42 kB / 13.69 kB | `MediaLibraryPage-*.js` |
| Dashboard | 19.97 kB / 5.93 kB | `Dashboard-*.js` |
**Abnahme Phase 0 (Bundle):** Zahlen dokumentiert; Re-Run: `npm run build` und Tabelle abgleichen.
---
## 2. API-Latenz (p95) Top-Routen
**Messung** erfolgt auf **Zielumgebung** (z.B. dev.shinkan / prod) mit gleicher Topologie wie Nutzer (HTTPS, Proxy). Nicht aus dem leeren Arbeitsverzeichnis ohne laufendes Backend messbar.
### 2.1 Vorgehen (empfohlen)
- **Access-Logs** des Reverse-Proxy (Request-Zeit), oder
- **APM** / OpenTelemetry, oder
- **k6** mit authentifizierten Szenarien (Token aus Testaccount; Header `X-Auth-Token`, ggf. `X-Active-Club-Id`), oder
- manuell: wiederholte `curl -w '%{time_total}\n'` mit gleichem Token
### 2.2 Vorlage (aus Umgebung ausfüllen)
| Route (Beispiel) | Methode | p95 (ms) | Datum / Umgebung | Bemerkung |
|------------------|---------|----------|------------------|-----------|
| `/api/profiles/me` | GET | *—* | *nach Messung* | |
| `/api/exercises` (Liste, typ. Query) | GET | *—* | *nach Messung* | |
| `/api/training-units` (Liste, typ. Query) | GET | *—* | *nach Messung* | |
| `/api/media-assets` (Liste) | GET | *—* | *nach Messung* | |
| `/health` | GET | *—* | *nach Messung* | k6: siehe `scripts/load/` |
**Abnahme Phase 0 (API):** Verfahren steht; Tabelle mindestens für **`/health`** nach erstem k6-Lauf befüllbar; übrige Zeilen bei nächstem Monitoring-Export.
---
## 3. Lasttestszenario
### 3.1 E2E-Smoke (fachlicher Pfad)
- **Befehl:** Repository-Root, `npm run test:e2e` (setzt `PLAYWRIGHT_BASE_URL`, Testuser per Env, siehe `.gitea/workflows/test.yml`).
- **Abdeckung:** Login, Dashboard, Navigation u.a. entspricht grob „Login → Dashboard → weitere Screens“.
- **Baseline notieren:** Dauer eines vollen Laufs, Anzahl passed (z.B. 26 Tests), Datum.
| Messung | Wert | Datum |
|---------|------|-------|
| Playwright Gesamtlauf (lokal/CI) | *—* | *nach Messung* |
| passed / total | 26 / 26 (Ziel) | |
### 3.2 EXPLAIN (Phase 2 Lesepfade)
- **Datei:** **`scripts/load/explain-readpaths.sql`** — repräsentative Statements für `list_exercises` / Stufenfilter / `training_units`; auf der Ziel-DB mit `EXPLAIN (ANALYZE, BUFFERS)` ausführen (Token/Tenant nicht im Skript; wie bei echten API-Queries filtern).
### 3.3 k6 parallele /health
- **Skript:** `scripts/load/k6-health-baseline.js`
- **CI:** Läuft **automatisch** im Gitea-Workflow im Job **`k6-health-baseline`** (eigenständig, ohne Playwright; `.gitea/workflows/test.yml`). Parallel dazu **Playwright** im Job **`playwright-tests`**.
- **Lokal:** siehe `scripts/load/README.md`
- **Baseline notieren:** k6-Ausgabe `http_req_duration` p(95), Checks succeeded.
| Szenario | p95 / Fehlerquote | Datum / BASE_URL |
|----------|-------------------|------------------|
| 10 VUs, 30 s `/health` | *—* | *nach Messung* |
---
## 4. Nächster Schritt (Roadmap)
- **Phase 0** ist für den Pipeline-Teil **abgeschlossen**: Bundle dokumentiert; **k6** läuft in CI nach jedem relevanten Deploy (mit Test-Suite); API-p95-Tabellen kann das Team aus Monitoring weiter befüllen (optional, kein Deploy-Blocker).
- **Phase 2** (Backend Lesepfade) ist **abgeschlossen** — siehe [UMSETZUNGSPLAN_ROADMAP.md](./UMSETZUNGSPLAN_ROADMAP.md); nach Deploy **p95 erneut messen** und mit den Werten aus Abschnitt 2 dieser Datei vergleichen (**Meilenstein M2**).
---
## Verweise
- Roadmap: [UMSETZUNGSPLAN_ROADMAP.md](./UMSETZUNGSPLAN_ROADMAP.md)
- k6: [scripts/load/README.md](../../scripts/load/README.md)

View File

@ -0,0 +1,26 @@
# Architektur: Zielbild, Refaktor, Regeln (Shinkan Jinkendo)
Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP. Es ergänzt die bestehenden Pflichtdokumente (`.claude/rules/ARCHITECTURE.md`, `CODING_RULES.md`, Zugriffsschicht, Media-Spec) und ist für **Wartbarkeit, Performance und sichere Erweiterung** verbindlich, soweit hier ausdrücklich festgelegt.
## Inhalt
| Datei | Zweck |
|--------|--------|
| [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md) | Zielarchitektur (Frontend, API, Daten), Qualitätsziele, Einbindung neuer Features |
| [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md) | Erfasste Architekturschuld, Reihenfolge und Massnahmen zur Behebung |
| [UMSETZUNGSPLAN_ROADMAP.md](./UMSETZUNGSPLAN_ROADMAP.md) | Phasen, Meilensteine, Abnahmekriterien, Aufwandsschwerpunkte |
| [BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md) | Phase 0: Bundle-, API- und Last-Baseline (Messvorlagen, Vergleich nach Phase 2) |
| [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md) | **Verbindliche** Shinkan-spezifische Regeln (Ergänzung zu den globalen Rules) |
## Tests (E2E / Refaktor-Budget)
- **`tests/dev-smoke-test.spec.js`** Playwright-Suite (Smoke + Compliance). Enthält u. a. **Test 8:** nach Login und **Reload** des Dashboards werden GET-Aufrufe zu `/api/profiles/me` und `/api/training-units` gezählt (Absicherung Dashboard-Refaktor Phase 1). Ausführung: `npm run test:e2e`; CI: `.gitea/workflows/test.yml` Job **playwright-tests**. **k6**-Baseline: Job **`k6-health-baseline`** (siehe `scripts/load/README.md`).
## Pflege
- Bei abgeschlossenen Phasen: Roadmap und Remediation-Dokument aktualisieren; bei Regeländerungen: nur mit **expliziter Projektfreigabe** (gleiches Verfahren wie bei `.claude/rules/ARCHITECTURE.md`).
- Querschnitt: **`docs/HANDOVER.md`** soll auf die aktive Roadmap-Phase verweisen.
## Bezug MVP
Die aktuelle Codebasis ist funktional MVP-tauglich; strukturell bestehen bekannte Schwerpunkte (grosse Seiten-Monolithen, API-Monolith im Client, redundante Lesepfade, schwere Listenqueries). Dieses Bündel definiert, wie nach **dem** MVP weitergebaut wird, ohne jedes neue Feature erneut mit **architektonischer Schuld** zu überfrachten.

View File

@ -0,0 +1,133 @@
# Architekturschuld Erfassung und Behebungsschritte
Dieses Dokument listet **bewusst** die aus MVP und Code-Review bekannten strukturellen Themen auf und ordnet **konkrete Massnahmen** zu. Reihenfolge ist an die Roadmap gekoppelt; hier die inhaltliche Detailierung.
---
## A. Frontend
### A1 „God Pages“ (Training, Übungsformular, Vereine)
**Schuld:** Sehr grosse Dateien (tausende Zeilen) mit viel State, vielen Effekten und eingebetteten Modals.
**Risiko:** Hohe Re-Render-Kosten, schwerer zu testen, hoher RAM auf schwachen Geräten, neue Features vergrössern die Datei weiter.
**Behebungsschritte:**
1. **Inventar:** pro Page kurze Gliederung (Abschnitte) und Ziel-Komponenten benennen.
2. **Extrahieren:** Zuerst isolierbare Blöcke (Listen, Modals, Sidebar, Form-Sektionen) in Unterkomponenten; Props/Oberfläche dokumentieren.
3. **Hooks:** wiederkehrende Logik (`useEffect`-Ketten, Filter-State) in `useXxx`-Hooks pro Domäne.
4. **Optional `features/training/` o. ä.:** wenn 3+ zusammengehörige Komponenten entstehen.
**Erfolgskriterium:** Page-Datei unter dem in `VERBINDLICHE_REGELN_SHINKAN.md` genannten Soft-Limit oder dokumentierte Ausnahme.
---
### A2 Monolithischer API-Client (`utils/api.js`)
**Schuld:** Eine Datei bündelt alle Endpoints; erschwert Tree-Shaking, Navigation und domänenweise Ownership.
**Behebungsschritte:**
1. Verzeichnisstruktur festlegen, z.B. `frontend/src/api/` mit `client.js` (Token, `request`), `exercises.js`, `planning.js`, …
2. Bestehende `api.js` schrittweise zur **Facade** (`export * from …`) degradieren oder re-exportieren.
3. Neue Features **nur** in domänenspezifischen Dateien implementieren.
**Erfolgskriterium:** Kein Wachstum des Monolithen über bestehende Endpoint-Anzahl hinaus; mittelfristig dominieren kleine Module.
---
### A3 Redundante und „chatty“ Client-Requests
**Schuld (Beispiele):** Dashboard lädt Profil erneut trotz Auth; mehrere nahezu gleiche `listTrainingUnits`-Aufrufe; doppelte `listExercises` für KPIs.
**Risiko:** Mehr Last auf API/DB, schlechtere UX auf langsamen Geräten.
**Behebungsschritte:**
1. **Profil:** eine kanonische Quelle (Auth-Profil reicht für Anzeige; fehlende Felder gezielt nachladen oder Auth-Check erweitern fachlich klären).
2. **Dashboard:** einen **Summary-Endpoint** spezifizieren und implementieren (siehe Backend B1) oder Client auf einen aggregierten Aufruf reduzieren.
3. **Org-Inbox / globale Fetches:** Ladestrategie definieren (on-demand vs. TTL vs. sichtbarkeitsabhängig) und `OrgInboxContext` entsprechend umbauen.
**Stand Umsetzung:** Gemeinsame Funktion `fetchOrgInboxSnapshot` für Mount und `refreshOrgInbox` (ein Codepfad, gleiche API-Calls). Optionales verzögertes Laden / TTL weiterhin offen.
**Erfolgskriterium:** Dashboard-Initialisierung ohne redundanten `getCurrentProfile`; ohne drei parallele fast gleiche Trainingslisten (oder dokumentierte Ausnahme).
---
### A4 Schwere Abhängigkeiten
**Schuld:** PDF/Markdown/Canvas-Pfade ziehen grosse Chunks.
**Behebungsschritte:** Strikte `import()` an Nutzeraktion; keine statischen Top-Level-Imports schwerer Libs in gemeinsamen Einstiegspfaden.
**Erfolgskriterium:** Lighthouse / Bundle-Analyse zeigt schwere Libs nur auf betroffenen Routen.
---
## B. Backend
### B1 Aggregations- und Summary-APIs
**Schuld:** Bildschirme holen mehrere Listen und aggregieren im Client.
**Behebungsschritte:**
1. Endpoint(s) z.B. `GET /api/dashboard/summary` oder domänenspezifisch mit gleicher Sichtbarkeitslogik wie Einzel-Listen.
2. Tests oder manuelle Checkliste gegen **Tenant-Leaks** (nur eigene/sehbare Daten).
3. Versionierung in `version.py` bei neuem Router-Block oder signifikantem Modul-Update.
**Erfolgskriterium:** Fertigest Dashboard mit einer serverseitigen Zusammenfassung (oder festgelegte Client-Reduktion mit Messung).
---
### B2 Listenqueries (z.B. Übungsliste)
**Schuld:** Korrelierte Subqueries pro Zeile können bei Wachstum teuer werden.
**Behebungsschritte:**
1. `EXPLAIN (ANALYZE, BUFFERS)` auf Produktions-näher Konfiguration mit realistischem `limit`.
2. Indizes für Filter und Sortierung ergänzen.
3. Refactoring: JOINs/LATERAL statt N-facher Subquery, wo messbar besser.
**Erfolgskriterium:** Dokumentierte p95-Zielwerte erreicht oder Trend verbessert (siehe Roadmap).
---
### B3 Pagination
**Schuld:** Tiefe `OFFSET`-Werte skalieren schlecht.
**Behebungsschritte:** Keyset-Pagination für grosse Listen in späteren Phasen einführen; API-Vertrag dokumentieren.
---
## C. Querschnitt
### C1 Messbarkeit
**Schuld:** Optimierung ohne Baseline.
**Behebungsschritte:** Einmalig Baseline (API p95, Bundle-Grössen Haupt-Route, ein Lasttest-Szenario) festhalten; wiederholen nach grossen Phasen.
---
### C2 Dokumentation und Audit
**Schuld:** Wissen nur in Chats.
**Behebungsschritte:** `HANDOVER.md` und `ACCESS_LAYER_ENDPOINT_AUDIT.md` bei jedem grösseren API-Block aktualisieren; Roadmap-Phase abhaken.
---
## Mapping: Schuld → Regel
| Schuld | Primär-Regel (Shinkan) |
|--------|-------------------------|
| God Pages | S1, S2 |
| API-Monolith | S3 |
| Globale Fetches | S4 |
| Chatty API | S5 |
| Caching-Ideen | S6 |
| Grössere Features ohne Messung | S7, S8 |

View File

@ -0,0 +1,147 @@
# Umsetzungsplan und Roadmap Refaktorierung Shinkan Jinkendo
**Aktueller Stand (laufend):**
- **Phase 0:** abgeschlossen siehe **[BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md)** (Bundle festgehalten, API-/k6-Vorlagen + Skripte unter `scripts/load/`).
- **Phase 1 (Teil):** Dashboard: kein zweites `getCurrentProfile`; Trainings-Vorschau über **`GET /api/dashboard/kpis`** (`training_home`); Playwright **Test 8** sichert API-Budget ab.
- **Phase 1 (Teil):** Org-Inbox: **ein** gemeinsamer Ladepfad `fetchOrgInboxSnapshot` für Mount-`useEffect` und `refreshOrgInbox` (gleiche Requests, weniger Drift-Risiko; Verhalten unverändert).
- **Phase 2:** **abgeschlossen** (2026-05-14) — Indizes 058062, Keyset `/api/exercises` + `/api/training-units`, **`/api/dashboard/kpis`** inkl. `training_home`, EXPLAIN-Vorlagen **`scripts/load/explain-readpaths.sql`**.
- **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget.
- **Phase 3 (gestartet 2026-05-13):** Übungsliste — extrahierte Karte, **virtualisierter** Picker, **lazy** Progressions-Panel; Playwright **Test 9**; Grid `data-testid`. Weiter: God-Pages (Planung/Formular) zerteilen. Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**.
**Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md).
---
## Leitplanken (vereinbart)
- **Kein Breaking** der Zugriffsschicht: neue und geänderte Endpoints folgen `get_tenant_context` / Audit wie bisher.
- **Inkrementell:** Jede Phase liefert **nutzbaren** Stand (kein Big-Bang-Stillstand).
- **Neue Features** während der Roadmap: **S8 Checkliste** und **S1/S3** strikt; wo möglich gleich im neuen API-Modul-Pfad.
---
## Phase 0 Baseline (kurz, Pflicht)
**Status:** **Erledigt** (2026-05-13). Siehe **`docs/architecture/BASELINE_SNAPSHOT.md`** und **`scripts/load/`**.
| Task | Output |
|------|--------|
| API p95 der Top-5-Routen messen (z.B. `profiles/me`, `exercises` list, `training-units` list, `media-assets` list) | Vorlage + Messverfahren in **BASELINE_SNAPSHOT.md**; Werte nach erstem Lauf auf Dev/Prod eintragen |
| Ein Lasttestszenario (Login → Dashboard → Übungen → Planung) | Playwright `npm run test:e2e` + k6 **`scripts/load/k6-health-baseline.js`** (README dort) |
| Bundle: Grösse Einstieg + schwerste Route | In **BASELINE_SNAPSHOT.md** dokumentiert (Auszug `vite build`) |
**Abnahme:** Bundle dokumentiert; Mess- und Lastskripte vorhanden; API-Tabelle iterativ befüllbar. **Phase 2** beginnt nach diesem Freeze-Punkt.
---
## Phase 1 Quick Wins Netzwerk (hoher ROI, geringes Risiko)
**Fokus:** Weniger redundante Requests, bessere Mobile-UX, kaum strukturelle Risiken.
| Task | Bezug Remediation | Status |
|------|-------------------|--------|
| Dashboard: Doppel-`getCurrentProfile` auflösen; kanonisches Profil klären | A3 | erledigt |
| Dashboard: `listTrainingUnits`-Reduktion (ein Call statt zweier identischer) | A3 | erledigt |
| Dashboard: `listExercises`-Doppelabruf / Summary-Call | A3, B1 | erledigt (`GET /api/dashboard/kpis`) |
| Org-Inbox: Ladestrategie; Umsetzung Teil 1 (gemeinsamer Ladepfad, keine doppelte Logik) | A3 | erledigt |
| Org-Inbox: TTL / verzögertes Laden (nur nach Bedarf) | A3 | teils (Erstlade per `requestIdleCallback`, max. 1,5s) |
**Abnahme:** Kein funktionales Leck; Netzwerk-Tab zeigt messbar weniger parallele gleiche Muster beim ersten Dashboard-Load.
---
## Phase 2 Backend Lesepfade (Skalierung „viele Nutzer“)
**Status:** **Abgeschlossen** (2026-05-14).
**Voraussetzung:** Phase 0 abgeschlossen (**[BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md)**). Nach Deploy: p95 der Top-Routen erneut messen und mit Baseline vergleichen ([M2](#meilensteine-empfohlen)).
**Fokus:** DB und API stabil unter parallelen Lesern.
| Task | Bezug | Status |
|------|-------|--------|
| `EXPLAIN` + Index-Tuning für `list_exercises` und nächste schwere Listen | B2 | erledigt (Indizes 058060, 062; Vorlagen **[explain-readpaths.sql](../../scripts/load/explain-readpaths.sql)**; Messung Team) |
| Summary-API finalisieren/erweitern falls in P1 nur Teilbereich | B1 | erledigt (`GET /api/dashboard/kpis` + **`training_home`**) |
| Keyset-Pagination für Listen mit Sort-Key | B3 | erledigt (`/api/exercises`, `/api/training-units`) |
**Lieferung:** Migrationen **058062**; Keyset-Parameter wie dokumentiert in OpenAPI/Router; Dashboard nutzt **ein** KPI-Request für Kennzahlen und Trainings-Vorschau.
**Abnahme:** p95 der optimierten Routen nach Messung dokumentiert verbessert ggü. Phase 0 oder Obergrenze notiert (siehe Baseline-Tabelle).
---
## Phase 3 Frontend-Struktur (Wartbarkeit + Client-Performance)
**Fokus:** God-Pages abbauen, Virtualisierung wo nötig.
| Task | Bezug |
|------|--------|
| Eine Page komplett zerteilen als Referenz (z.B. `TrainingPlanningPage` **oder** `ExerciseFormPage`) Rest priorisiert nach Nutzung | A1 |
| Virtualisierung für die längste produktive Liste | A1, S2 |
| Schwere Imports auf `import()` umziehen (gezielt) | A4 |
**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten in `components/exercises/ExerciseListCard.jsx`; Tab „Progressionsgraphen“ lädt **`ExerciseProgressionGraphPanel`** per `React.lazy` + `Suspense`; **`ExercisePickerModal`** virtualisiert (`@tanstack/react-virtual`, Scroll-Container `data-testid="exercise-picker-scroll"`); Gitter `data-testid="exercises-list-grid"` + `content-visibility` in `app.css`; Playwright **Test 9**. Offen: Seite unter Soft-Limit (~500 Zeilen), vollständige Zerteilung `TrainingPlanningPage` / `ExerciseFormPage`.
**Abnahme:** Referenz-Page unter Soft-Limit; Regel S1 für neue Änderungen durchsetzbar.
---
## Phase 4 API-Client Modularisierung
**Fokus:** Wartbarkeit für viele neue Features.
| Task | Bezug |
|------|--------|
| `frontend/src/api/` anlegen, `request`/`client` zentral | A2 |
| Facade: bestehende Importe von `utils/api` nicht sofort alle brechen; Migration in Wellen | A2 |
| Neue Endpoints nur noch in Domänen-Dateien | S3 |
**Abnahme:** Anteil neuer Module > X% der neuen Zeilen (Team-Ziel); Monolith wächst nicht weiter.
---
## Phase 5 Vertiefung DB & Pagination
**Fokus:** Wachstum Datenbestand.
| Task | Bezug |
|------|--------|
| Keyset für weitere Listen | B3 |
| Weitere Query-Refactorings nach Monitoring | B2 |
**Abnahme:** Dokumentierte Paginierungs-API; keine Regression in der Zugriffsschicht.
---
## Meilensteine (empfohlen)
| Meilenstein | Inhalt |
|-------------|--------|
| **M1** | Phase 0 + 1 abgeschlossen, HANDOVER aktualisiert |
| **M2** | Phase 2 abgeschlossen, Lasttest / p95 nachziehen |
| **M3** | Phase 3 Referenz-Page + Virtualisierung live |
| **M4** | Phase 4 migrationsbereit für alle neuen Features |
| **M5** | Phase 5 für Top-Listen abgeschlossen |
---
## Parallel: neue Features
- Jedes Feature: [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md) **S8**.
- Berührung schwerer Pfade: kurzer Performance-Nachweis (S7).
---
## Risiken und Mitigation
| Risiko | Mitigation |
|--------|------------|
| Summary-Endpoint falsch gefiltert | Code-Review + Abgleich mit Einzel-Endpoint-Logik; Tests mit mehreren Rollen |
| Refaktor bricht PWA/Offline | Smoke-Test nach grossen Frontend-Phasen |
| Keyset bricht alte Clients | Versionierte Query-Parameter oder Übergangsfenster |
---
## Pflege
Nach jeder Phase: **README** dieses Bündels prüfen; **Roadmap** Checkboxen/Status; **HANDOVER** nächste Phase nennen.

View File

@ -0,0 +1,62 @@
# Verbindliche Architekturregeln Shinkan (Ergänzung)
**Status:** verbindlich für die Shinkan-Codebasis, **ergänzend** zu:
- `.claude/rules/ARCHITECTURE.md`
- `.claude/rules/CODING_RULES.md`
- `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`
- `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`
Bei Widerspruch gewinnt die **spezifischere** Regel zur **Zugriffsschicht und Governance** (Sicherheit vor Komfort). Bei Widerspruch zwischen diesem Dokument und allgemeinen Mitai-Template-Resten in `ARCHITECTURE.md` gilt für **Shinkan** dieses Dokument und die Shinkan-Pflichtlektüre in `CLAUDE.md`.
---
## S1 Frontend: Grösse und Zerlegung von Seiten
1. **Soft-Limit:** Neue oder stark erweiterte Seiten sollen **unter ~500 Zeilen** im Page-File bleiben. Darüber: Auslagern in Komponenten/Hooks/Feature-Module mit klaren Namen.
2. **Ausnahmen** nur mit Kurzbegründung im PR und Verweis auf Messung (Bundle/Performance) oder fachliche Unteilbarkeit.
3. **Wiederkehrende UI-Blöcke** nicht per Copy-Paste über Seiten hinweg duplizieren; extrahieren in `components/` oder `features/`.
## S2 Frontend: Listen und Speicher
1. Listen, die **typischerweise > 100 sichtbare oder gehaltene Einträge** im DOM ermöglichen, **müssen** virtualisiert werden (oder serverseitig strikt begrenzt + „mehr laden“ mit dokumentiertem UX nicht beides unbegründet ignorieren).
2. **Modals und zweite Raster** gleichzeitig zum Hauptbaum nur laden, wenn geöffnet (lazy mount), wo technisch machbar ohne UX-Bruch.
## S3 Frontend: API-Zugriff
1. **Alle** API-Aufrufe über die zentrale Schicht (`utils/api` bzw. nach Modularisierung dessen Module). **Kein** `fetch('/api/...')` ohne diese Schicht.
2. Während der Migration vom API-Monolithen: **neue** Endpoints ausschliesslich im **domänenspezifischen** Modul anlegen; nur bei Bedarf Re-Export über die Facade.
## S4 Frontend: Globale Daten und Context
1. Neue **global** geladene Daten (jede authentifizierte Session) **bedürfen** technischer Begründung (Badge-Kritikalität, Sicherheit). Alternative: **on-demand** beim ersten Bezug oder **TTL-Cache** mit dokumentierter Invalidierung (`shinkan:…`-Events bleiben möglich).
2. Context-`value`-Objekte **müssen** stabil gehalten werden (`useMemo` / `useCallback`), wenn nicht-triviale Unterbäume davon abhängen (bereits etabliert für Auth; gleiches Muster für neue Contexts).
## S5 Backend: Lesepfad-Design
1. **Keine** mehrfachen fast identischen Listenaufrufe durch den Client für **denselben** zusammensetzbaren Bildschirm, wenn ein **einzelner** Summary-Endpoint unter gleicher Sichtbarkeitslogik möglich ist. Ausnahme: nachweislich unterschiedliche Cache-Lebensdauer oder unterschiedliche Rechte dokumentieren.
2. Neue Listen-Endpoints: **Paginierung** (`limit`/`offset` oder Keyset nach Roadmap) und Obergrenzen; keine „unbegrenzt alles“-Defaults für grosse Tabellen.
3. Schwere SQL-Konstruktionen (viele korrelierte Subqueries pro Zeile) **nur** mit Kommentar **Warum** und Hinweis auf Indexlage oder geplantes Refactoring-Ticket.
## S6 Backend: Mandanten und Caching
1. **Kein** HTTP- oder Anwendungs-Cache für mandantenspezifische oder nutzerspezifische Daten **ohne** expliziten Schlüssel (mindestens: Tenant-Kontext + relevante Parameter) und **Invalidierungsstrategie**.
2. Öffentliche oder global geteilte Katalogdaten dürfen mit `ETag` / kurzem Cache optimiert werden **nach** Abgleich mit Governance.
## S7 Performance und Messung (Definition of Done für grössere Features)
1. Features, die neue Listen schwerer als bestehende Top-10-Queries machen oder **> ~50 KB** zusätzliches Client-JS pro Route erzeugen: **kurz** messen (Lighthouse mobil oder Netzwerk-Timing) und im PR festhalten.
2. Regressions in **p95** der betroffenen API nach Deploy: bei Bedarf Rollback- oder Nachsteuerungskriterium mit Team vereinbaren (Zahlen Zielbild/Roadmap).
## S8 Feature-Checkliste (DoD)
Vor Merge einer grösseren Erweiterung:
- [ ] Zugriffsschicht / Audit aktualisiert (falls zutreffend)
- [ ] Kein Verstoss gegen S1S7 ohne dokumentierte Ausnahme
- [ ] Keine neue direkte DB-Nutzung im Frontend
- [ ] Medien/Lifecycle (falls Medien betroffen) nach Media-Spec
---
**Änderungen** an diesen Regeln nur mit **expliziter Projektfreigabe** (analog zu `ARCHITECTURE.md`).

View File

@ -0,0 +1,78 @@
# Architektur-Zielbild Shinkan Jinkendo
**Geltungsbereich:** Trainer-/Vereinsplattform, Multi-Tenancy und Governance nach bestehender Zugriffsschicht.
**Ziele:** dauerhaft tragfähig, performant bei vielen gleichzeitigen Nutzern, akzeptabel auf **geringer Client-Leistung** (wenig RAM/CPU), **wartbar** und so strukturiert, dass **neue Features** ohne neue Grosseinkaufe an technischer Schuld einbindbar sind.
---
## 1. Leitprinzipien
1. **API-first, Mandanten-sicher** Fachlogik und Sichtbarkeit serverseitig; das Frontend orchestriert und zeigt. Unverändert gemäss bestehender Regeln (`ACCESS_LAYER`, Governance-Helfer).
2. **Schlanke Client-Oberfläche** JavaScript pro Route begrenzen; schwere Abhängigkeiten nur bei Bedarf laden; Listen dort virtualisieren, wo Grössenordnungen wachsen.
3. **Explizite Lesepfade** Aggregation und Zusammenfassungen dort, wo mehrere fast gleiche Requests heute nötig sind (Dashboard, Badges), **statt** Chatty-Client-Muster.
4. **Vorhersehbarkeit für die DB** Listenqueries ohne unnötige O(n)·Subquery-Kosten pro Zeile; Indizes und Paginierungsstrategie sind Teil des Designs.
5. **Feature-Einbindung per Checkliste** Jedes neue Feature durchläuft die gleiche Architektur- und Performance-Checkliste (siehe Regeldokument), bevor es als „fertig“ gilt.
---
## 2. Zielbild Frontend
### 2.1 Struktur
- **Seiten (`pages/`)** bleiben Routing-Einstiege und Komposition; **keine** Dauerlösung für Logikblöcke > ~400500 Zeilen in einer Datei Auslagerung in `components/`, `hooks/`, `features/<name>/`.
- **Feature-Ordner (Ziel):** wo sinnvoll `frontend/src/features/<domäne>/` mit klarer Grenze: UI + feature-spezifische Hooks; geteilte Helfer in `utils/` nur wenn domänenübergreifend.
- **State:** Server-State über API (keine Business-Duplikation); UI-State lokal oder in bestehenden Contexts nur, wenn mehrere Schichten der Shell betroffen sind.
### 2.2 Performance und schwache Endgeräte
- Route-basiertes Code-Splitting bleibt Standard; **zusätzlich** innere `dynamic import()` für schwere Pakete (PDF, grosse Editoren), sobald eine Route sie braucht.
- Lange Listen: **Virtualisierung** ab einer projektdefinierten Schwelle (siehe Regeln).
- Globale Daten (Posteingang, Badges): **bedarfsgesteuert oder mit klar dokumentiertem Cache/TTL**, nicht pauschal jede Session mit voller Last konkrete Strategie in Roadmap/Remediation.
### 2.3 API-Schicht im Client
- **Ziel:** Aufteilung des heutigen `utils/api.js`-Monolithen in **domänenspezifische Module** (z.B. `api/exercises`, `api/planning`, `api/media`), mit einer dünnen **Barrel- oder Facade-Export** für Kompatibilität während der Migration.
- **Konstante:** alle HTTP-Aufrufe mit Token/Mandanten-Headern zentral; kein Rohtransport aus Komponenten.
---
## 3. Zielbild Backend / API
- **Router-Disziplin** unverändert: ein fachliches Modul, ein Router (bestehende Architekturregeln).
- **Read-Model / Summary-Endpoints** für Dashboards und wiederkehrende Kacheln: **eine** abgestimmte Antwort pro Bildschirm, wo heute mehrere Listen parallel zusammengerechnet werden unter strikt gleicher Sichtbarkeitslogik wie die Einzel-Endpoints.
- **Listen:** sortierte Indizes passend zu `WHERE` + `ORDER BY`; für grosse Datenmengen langfristig **Keyset-Pagination** statt tiefer Offsets.
- **Schwere Queries:** Korrelierte Subqueries pro Zeile nur, wenn messbar unkritisch; sonst JOIN-/Aggregate-Refactoring mit Review.
---
## 4. Zielbild Datenhaltung
- PostgreSQL bleibt System der Wahrheit; Migrationen nummeriert, wie heute.
- Kein Mandanten-Cache ohne expliziten Key und Invalidierungskonzept (Regeldokument).
---
## 5. Einbindung neuer Features (vereinbartes Muster)
1. Fachliche Kurzspez (oder Ticket) mit **Sichtbarkeit** und **Nutzungskontext** (Mobile/Desktop, erwartete Listenlängen).
2. API-Design: Endpoints, Payload-Grösse, Paginierung; Zugriffsschicht-Check.
3. UI-Modul: Route lazy, Komponentengrösse, ggf. Virtualisierung.
4. Messung: minimal Lighthouse/Netzwerk oder Server-Timing für den neuen Pfad.
5. Audit-Eintrag bei neuen geschützten Endpoints (bestehendes Verfahren).
---
## 6. Nicht-Ziele dieses Zielbilds
- Ersetzen der Zugriffsschicht oder der Medien-Spec.
- Microservices oder zweite Schreib-Datenbank ohne ausdrücklichen Projektbeschluss.
- „Framework-Wechsel“ (React bleibt, solange nicht separat entschieden).
---
## 7. Abnahme „Zielbild erreicht“ (high level)
- Keine bekannten **God-Pages** oberhalb dokumentierter Schwellen ohne dokumentierte Ausnahme.
- API-Client modularisiert oder klar phasierter Migrationsstand mit festem Enddatum.
- Dashboard und vergleichbare Homescreens ohne redundante Mehrfach-Listen desselben Objekttyps (oder dokumentierte technische Begründung + Messung).
- Datenbank-Lesepfade der Top-5-Listen unter definierter Latenz-Schwelle auf Referenz-Hardware in Lasttests (Werte in Roadmap festzulegen).

View File

@ -8,6 +8,7 @@
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
"jspdf": "^4.2.1",
"lucide-react": "^0.344.0",
"marked": "^18.0.3",

View File

@ -1,4 +1,4 @@
import React from 'react'
import React, { Suspense, lazy } from 'react'
import {
RouterProvider,
createBrowserRouter,
@ -12,45 +12,66 @@ import { ToastProvider } from './context/ToastContext'
import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext'
import DesktopSidebar from './components/DesktopSidebar'
import { getMainNavItems } from './config/appNav'
import LoginPage from './pages/LoginPage'
import VerifyPage from './pages/VerifyPage'
import Dashboard from './pages/Dashboard'
import AccountSettingsPage from './pages/AccountSettingsPage'
import SettingsSystemInfoPage from './pages/SettingsSystemInfoPage'
import ExercisesListPage from './pages/ExercisesListPage'
import ExerciseDetailPage from './pages/ExerciseDetailPage'
import ExerciseFormPage from './pages/ExerciseFormPage'
import ClubsPage from './pages/ClubsPage'
import InboxPage from './pages/InboxPage'
import SkillsPage from './pages/SkillsPage'
import TrainingPlanningPage from './pages/TrainingPlanningPage'
import TrainingFrameworkProgramsListPage from './pages/TrainingFrameworkProgramsListPage'
import TrainingFrameworkProgramEditPage from './pages/TrainingFrameworkProgramEditPage'
import TrainingModulesListPage from './pages/TrainingModulesListPage'
import TrainingModuleEditPage from './pages/TrainingModuleEditPage'
import TrainingUnitRunPage from './pages/TrainingUnitRunPage'
import TrainingCoachPage from './pages/TrainingCoachPage'
import AdminCatalogsPage from './pages/AdminCatalogsPage'
import AdminHierarchyPage from './pages/AdminHierarchyPage'
import AdminMaturityModelsPage from './pages/AdminMaturityModelsPage'
import TrainerContextsPage from './pages/TrainerContextsPage'
import MediaWikiImportPage from './pages/MediaWikiImportPage'
import AdminUsersPage from './pages/AdminUsersPage'
import AdminHomeRedirect from './components/AdminHomeRedirect'
import PlatformAdminRoute from './components/PlatformAdminRoute'
import MediaLibraryPage from './pages/MediaLibraryPage'
import LegalPage from './pages/LegalPage'
import AdminLegalDocumentsPage from './pages/AdminLegalDocumentsPage'
import SettingsLegalPage from './pages/SettingsLegalPage'
import ActiveClubSwitcher from './components/ActiveClubSwitcher'
import InactiveMembershipBanner from './components/InactiveMembershipBanner'
import './app.css'
const LoginPage = lazy(() => import('./pages/LoginPage'))
const VerifyPage = lazy(() => import('./pages/VerifyPage'))
const Dashboard = lazy(() => import('./pages/Dashboard'))
const AccountSettingsPage = lazy(() => import('./pages/AccountSettingsPage'))
const SettingsSystemInfoPage = lazy(() => import('./pages/SettingsSystemInfoPage'))
const ExercisesListPage = lazy(() => import('./pages/ExercisesListPage'))
const ExerciseDetailPage = lazy(() => import('./pages/ExerciseDetailPage'))
const ExerciseFormPage = lazy(() => import('./pages/ExerciseFormPage'))
const ClubsPage = lazy(() => import('./pages/ClubsPage'))
const InboxPage = lazy(() => import('./pages/InboxPage'))
const SkillsPage = lazy(() => import('./pages/SkillsPage'))
const TrainingPlanningPage = lazy(() => import('./pages/TrainingPlanningPage'))
const TrainingFrameworkProgramsListPage = lazy(() =>
import('./pages/TrainingFrameworkProgramsListPage'),
)
const TrainingFrameworkProgramEditPage = lazy(() =>
import('./pages/TrainingFrameworkProgramEditPage'),
)
const TrainingModulesListPage = lazy(() => import('./pages/TrainingModulesListPage'))
const TrainingModuleEditPage = lazy(() => import('./pages/TrainingModuleEditPage'))
const TrainingUnitRunPage = lazy(() => import('./pages/TrainingUnitRunPage'))
const TrainingCoachPage = lazy(() => import('./pages/TrainingCoachPage'))
const AdminCatalogsPage = lazy(() => import('./pages/AdminCatalogsPage'))
const AdminHierarchyPage = lazy(() => import('./pages/AdminHierarchyPage'))
const AdminMaturityModelsPage = lazy(() => import('./pages/AdminMaturityModelsPage'))
const TrainerContextsPage = lazy(() => import('./pages/TrainerContextsPage'))
const MediaWikiImportPage = lazy(() => import('./pages/MediaWikiImportPage'))
const AdminUsersPage = lazy(() => import('./pages/AdminUsersPage'))
const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage'))
const LegalPage = lazy(() => import('./pages/LegalPage'))
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
function computeShowAdminNav(currentUser) {
return currentUser?.role === 'superadmin'
}
function AppRouteFallback() {
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg)',
}}
>
<div className="spinner"></div>
</div>
)
}
// Bottom Navigation (Mobile)
function Nav({ showAdminNav }) {
const { canAccessOrgInbox, inboxCount } = useOrgInbox()
@ -270,7 +291,9 @@ function App() {
return (
<AuthProvider>
<ToastProvider>
<RouterProvider router={appRouter} />
<Suspense fallback={<AppRouteFallback />}>
<RouterProvider router={appRouter} />
</Suspense>
</ToastProvider>
</AuthProvider>
)

View File

@ -2578,6 +2578,8 @@ a.analysis-split__nav-item {
.exercises-list-grid > .exercise-card {
height: 100%;
min-height: 0;
content-visibility: auto;
contain-intrinsic-size: auto 240px;
}
.exercise-card-layout {
display: flex;
@ -5412,22 +5414,80 @@ a.analysis-split__nav-item {
0 2px 12px rgba(15, 23, 42, 0.05);
}
/* KombinationsStrip: volle Breite unter der Zeile, begrenzte Textbreite — Hauptzeile (Name/Min.) nicht verdrängen */
/* Kombinationszeile: immer unter Hauptzeile (Titel / Minuten / Aktionen), nicht daneben */
.training-unit-sections-editor .tu-item-row--exercise.tu-item-row--combo {
flex-direction: column;
align-items: stretch;
flex-wrap: nowrap;
gap: 0;
}
.training-unit-sections-editor .tu-item-row--exercise.tu-item-row--combo .tu-item-row__mainline {
flex: none;
width: 100%;
}
/* KombinationsStrip: volle Breite; oben „Ablauf bearbeiten“, darunter KlammerVorschau */
.training-unit-sections-editor .tu-combo-planning-strip {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 10px 12px 12px;
border-top: 1px solid color-mix(in srgb, var(--border2) 85%, var(--accent) 12%);
background: color-mix(in srgb, var(--surface2) 65%, var(--surface));
margin-top: 2px;
}
.training-unit-sections-editor .tu-combo-planning-strip__meta {
width: 100%;
max-width: min(100%, 42rem);
.training-unit-sections-editor--item-drag .tu-item-row--combo .tu-combo-planning-strip {
padding-left: 44px;
}
.training-unit-sections-editor .tu-combo-planning-strip__toolbar {
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.training-unit-sections-editor .tu-combo-planning-strip__meta--fallback {
font-size: 0.78rem;
color: var(--text2);
line-height: 1.45;
}
.training-unit-sections-editor .tu-combo-planning-strip__bracket-wrap {
min-width: 0;
overflow-x: auto;
}
.training-unit-sections-editor .tu-combo-planning-strip > .btn {
align-self: flex-start;
.training-unit-sections-editor .combo-plan-bracket--planning-embed {
font-size: 0.93rem;
}
.training-unit-sections-editor .combo-plan-bracket--planning-embed .combo-plan-bracket__station {
padding: 8px 9px;
}
.training-unit-sections-editor .combo-plan-bracket--planning-embed .combo-plan-bracket__chip {
padding: 5px 8px;
}
.training-unit-sections-editor .combo-plan-bracket--planning-embed .combo-plan-bracket__globals-title {
font-size: 0.72rem;
}
.training-unit-sections-editor .combo-plan-bracket--planning-embed .combo-plan-bracket__head-main {
flex-wrap: wrap;
}
.training-unit-sections-editor .combo-plan-bracket--planning-embed .combo-plan-bracket__kicker {
font-size: 0.62rem;
}
.training-unit-sections-editor .combo-plan-bracket--planning-embed .combo-plan-bracket__archetype {
font-size: 0.88rem;
}
.tu-planning-mod-tag {

View File

@ -41,6 +41,7 @@ export default function CombinationPlanBracket({
/** 'none' | 'link' (Router) | 'button' (z. B. ExercisePeekModal / PWA-sicher) */
candidateInteraction = 'none',
onCandidatePeek,
className,
}) {
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
const archLabel = arch ? combinationArchetypeLabel(arch) : null
@ -59,7 +60,7 @@ export default function CombinationPlanBracket({
const coachHint = arch ? archetypeCoachHint(arch) : ''
return (
<div className="combo-plan-bracket">
<div className={['combo-plan-bracket', className].filter(Boolean).join(' ')}>
<div className="combo-plan-bracket__accent" aria-hidden />
<div className="combo-plan-bracket__body">
<header className="combo-plan-bracket__head">

View File

@ -2,7 +2,8 @@
* Übungssuche mit Volltext-, KI-/Semantikfeld (aktuell gleiche Engine wie Suche) und erweiterten Filtern.
* Paginierung bis max. 100 Treffer pro Request (API-Limit).
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
@ -53,13 +54,13 @@ export default function ExercisePickerModal({
const [list, setList] = useState([])
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [multiPicked, setMultiPicked] = useState([])
const [quickOpen, setQuickOpen] = useState(false)
const [quickTitle, setQuickTitle] = useState('')
const [quickSummary, setQuickSummary] = useState('')
const [quickSaving, setQuickSaving] = useState(false)
const pickerScrollRef = useRef(null)
const toggleMultiPick = (ex) => {
setMultiPicked((prev) =>
@ -118,7 +119,6 @@ export default function ExercisePickerModal({
setFilters({ ...INITIAL_FILTERS })
setFilterOpen(false)
setList([])
setOffset(0)
setHasMore(false)
setMultiPicked([])
setQuickOpen(false)
@ -227,7 +227,6 @@ export default function ExercisePickerModal({
const reload = useCallback(async () => {
if (!open || !catalogsReady) return
setLoading(true)
setOffset(0)
try {
const batch = await api.listExercises({
...queryBase,
@ -238,7 +237,6 @@ export default function ExercisePickerModal({
})
setList(Array.isArray(batch) ? batch : [])
setHasMore(batch?.length === PAGE_SIZE)
setOffset(batch?.length ?? 0)
} catch (e) {
console.error(e)
alert(e.message || 'Laden fehlgeschlagen')
@ -255,6 +253,8 @@ export default function ExercisePickerModal({
const loadMore = async () => {
if (!hasMore || loadingMore || loading) return
const last = list[list.length - 1]
if (!last?.id || last.updated_at == null) return
setLoadingMore(true)
try {
const batch = await api.listExercises({
@ -262,11 +262,14 @@ export default function ExercisePickerModal({
include_archived: true,
include_variants: true,
limit: PAGE_SIZE,
offset,
cursor_updated_at:
typeof last.updated_at === 'string'
? last.updated_at
: new Date(last.updated_at).toISOString(),
cursor_id: last.id,
})
setList((prev) => [...prev, ...(Array.isArray(batch) ? batch : [])])
setHasMore(batch?.length === PAGE_SIZE)
setOffset((o) => o + (batch?.length ?? 0))
} catch (e) {
console.error(e)
alert(e.message || 'Mehr laden fehlgeschlagen')
@ -275,6 +278,14 @@ export default function ExercisePickerModal({
}
}
const rowVirtualizer = useVirtualizer({
count: list.length,
getScrollElement: () => pickerScrollRef.current,
estimateSize: () => 88,
overscan: 8,
getItemKey: (index) => String(list[index]?.id ?? index),
})
const resetFilters = () => setFilters({ ...INITIAL_FILTERS })
const submitQuickCreate = async () => {
@ -584,7 +595,11 @@ export default function ExercisePickerModal({
</div>
</div>
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '12px 1rem' }}>
<div
ref={pickerScrollRef}
data-testid="exercise-picker-scroll"
style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '12px 1rem' }}
>
{!catalogsReady || (loading && list.length === 0) ? (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<div className="spinner" />
@ -596,8 +611,18 @@ export default function ExercisePickerModal({
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: 10 }}>
{list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}
</p>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{list.map((ex) => {
<div
role="list"
aria-label="Übungstreffer"
style={{
position: 'relative',
width: '100%',
height: rowVirtualizer.getTotalSize(),
}}
>
{rowVirtualizer.getVirtualItems().map((vi) => {
const ex = list[vi.index]
if (!ex) return null
const picked = multiPicked.some((p) => p.id === ex.id)
const rowInner = (
<>
@ -629,9 +654,22 @@ export default function ExercisePickerModal({
) : null}
</>
)
if (multiSelect) {
return (
<li key={ex.id}>
return (
<div
key={vi.key}
role="listitem"
data-index={vi.index}
ref={rowVirtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
paddingBottom: 8,
}}
>
{multiSelect ? (
<label
className="tu-ex-picker-multi-row"
style={{
@ -641,7 +679,6 @@ export default function ExercisePickerModal({
width: '100%',
textAlign: 'left',
padding: '10px 12px',
marginBottom: 8,
borderRadius: '8px',
border: picked ? '2px solid var(--accent)' : '1px solid var(--border)',
background: 'var(--surface2)',
@ -658,34 +695,30 @@ export default function ExercisePickerModal({
/>
<div style={{ flex: 1, minWidth: 0 }}>{rowInner}</div>
</label>
</li>
)
}
return (
<li key={ex.id}>
<button
type="button"
onClick={() => {
onSelectExercise(ex)
onClose()
}}
style={{
width: '100%',
textAlign: 'left',
padding: '10px 12px',
marginBottom: 8,
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
}}
>
{rowInner}
</button>
</li>
) : (
<button
type="button"
onClick={() => {
onSelectExercise(ex)
onClose()
}}
style={{
width: '100%',
textAlign: 'left',
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
}}
>
{rowInner}
</button>
)}
</div>
)
})}
</ul>
</div>
{hasMore && (
<div style={{ textAlign: 'center', marginTop: 12 }}>
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>

View File

@ -3,7 +3,7 @@ import { GripVertical, Pencil } from 'lucide-react'
import CombinationMethodProfileEditor from './CombinationMethodProfileEditor'
import CombinationPlanBracket from './CombinationPlanBracket'
import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
import { combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
import {
cloneJsonSerializablePlanningProfile,
comboSlotsOutlineForProfileEditor,
@ -13,7 +13,6 @@ import {
sectionPlannedMinutes,
} from '../utils/trainingUnitSectionsForm'
import api from '../utils/api'
import { effectiveStationTimingSummary, readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
import { isCompactTagLegendMode } from '../config/planningModuleUx'
import { useAuth } from '../context/AuthContext'
@ -74,60 +73,6 @@ function compactComboPlanningCaption(it) {
return overridden ? 'Planung angepasst' : 'wie Katalog'
}
/** Globale Eckdaten aus effective profile (optional unter Stationenliste). */
function comboRoughGlobalTimingHint(profileObj, archetypeKey) {
if (!profileObj || typeof profileObj !== 'object' || Array.isArray(profileObj)) return null
const bits = []
const rounds = profileObj.rounds
const ws = profileObj.work_seconds
const rb = profileObj.rest_between_rounds_sec
const hint = profileObj.hint_step_duration_sec
const globRest = profileObj.rest_between_sets_sec
if (rounds != null && rounds !== '') bits.push(`${rounds} Runden`)
if (ws != null && ws !== '') bits.push(`${ws}s Arbeit`)
if (rb != null && rb !== '') bits.push(`Pause ${rb}s`)
if (globRest != null && globRest !== '') bits.push(`Sets-Pause ${globRest}s`)
if (hint != null && hint !== '') bits.push(`Orientierung ~${hint}s`)
const arch = (archetypeKey || '').trim()
if (arch === 'time_domain_interval') {
const iw = profileObj.interval_work_sec
const ir = profileObj.interval_rest_sec
const ig = profileObj.interval_groups
if (iw != null && iw !== '') bits.push(`${iw}s Intervall`)
if (ir != null && ir !== '') bits.push(`${ir}s Erholung`)
if (ig != null && ig !== '') bits.push(`${ig} Gruppen`)
}
return bits.length ? bits.join(' · ') : null
}
/** Pro Station eine kompakte Textzeile für die Planungsliste. */
function comboPlanningStripBulletTexts(it) {
const slots = sortCombinationSlotsForDisplay(it.combination_slots || [])
if (!slots.length) return []
const mp = effectiveComboMethodProfile(it.catalog_method_profile || {}, it.planning_method_profile)
const archRaw = String(it.catalog_method_archetype || '').trim()
const byIx = new Map(readSlotProfilesV1(mp).map((r) => [Number(r.slot_index), r]))
const titles = it.combo_member_title_by_id || {}
return slots.map((slot, idx) => {
const siRaw = slot.slot_index
const siParsed =
siRaw === '' || siRaw == null ? idx : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10)
const ix = Number.isFinite(siParsed) ? siParsed : idx
const stationLbl = ((slot.title || '').trim() || `Station ${idx + 1}`)
const candIds = (slot.candidate_exercise_ids || [])
.map((raw) => (typeof raw === 'number' ? raw : parseInt(String(raw), 10)))
.filter((n) => Number.isFinite(n))
const namesJoined =
candIds.length === 0
? '(keine Übung)'
: candIds.map((id) => titles[String(id)] || `Übung ${id}`).join(' ↔ ')
const timing = effectiveStationTimingSummary(archRaw, mp, byIx.get(ix))
let line = `${stationLbl}: ${namesJoined}`
if (timing) line += ` · ${timing}`
return line
})
}
/** Stabile Farbzurodnung aus Modul-ID (nur Darstellung). */
function planningModulePalette(moduleId) {
const id = normalizedPlanningModuleChainId(moduleId)
@ -703,7 +648,8 @@ export default function TrainingUnitSectionsEditor({
<div
className={
'training-unit-sections-editor' +
(wideExerciseGrid ? ' training-unit-sections-editor--wide' : '')
(wideExerciseGrid ? ' training-unit-sections-editor--wide' : '') +
(enableItemDragReorder ? ' training-unit-sections-editor--item-drag' : '')
}
>
{(!hideHeading || headingAccessory) ? (
@ -1017,10 +963,6 @@ export default function TrainingUnitSectionsEditor({
const stripArchRaw =
isCombination && it.exercise_id ? String(it.catalog_method_archetype || '').trim() : ''
const stripArchLbl =
stripArchRaw && isCombination ? combinationArchetypeLabel(stripArchRaw) : null
const stripBullets =
isCombination && it.exercise_id ? comboPlanningStripBulletTexts(it) : []
const stripMpEff =
isCombination && it.exercise_id
? effectiveComboMethodProfile(
@ -1028,17 +970,15 @@ export default function TrainingUnitSectionsEditor({
it.planning_method_profile,
)
: null
const stripGlobalRough =
isCombination && it.exercise_id && stripMpEff
? comboRoughGlobalTimingHint(stripMpEff, stripArchRaw)
: null
return (
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
{!planningCompactLegend &&
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
<div
className={`${rowCommon} tu-item-row--exercise${fromModClass}`}
className={`${rowCommon} tu-item-row--exercise${fromModClass}${
isCombination && it.exercise_id ? ' tu-item-row--combo' : ''
}`}
{...dndRowProps}
style={modBorderVarStyle}
>
@ -1215,76 +1155,48 @@ export default function TrainingUnitSectionsEditor({
</div>
{isCombination && it.exercise_id ? (
<div
className="tu-combo-planning-strip"
style={{
padding: '8px 12px 10px',
paddingLeft: enableItemDragReorder ? 44 : 12,
borderTop: '1px solid var(--border)',
background: 'var(--surface2)',
}}
>
<div
className="tu-combo-planning-strip__meta"
style={{
fontSize: '0.78rem',
color: 'var(--text2)',
lineHeight: 1.45,
}}
title="Stationen und grobe Zeiten aus Katalog bzw. Planungs-Anpassung — Details unter „Ablauf bearbeiten“ oder „Vorschau“"
>
<div style={{ marginBottom: stripBullets.length || stripGlobalRough ? 6 : 0 }}>
<strong style={{ color: 'var(--text1)', fontWeight: 600 }}>Archetyp:&nbsp;</strong>
<span style={{ color: 'var(--text1)' }}>
{stripArchLbl || stripArchRaw || '—'}
</span>
<span style={{ marginLeft: 10, fontWeight: 500, whiteSpace: 'nowrap' }}>
{compactComboPlanningCaption(it)}
</span>
</div>
{stripGlobalRough ? (
<div
style={{
marginBottom: stripBullets.length ? 6 : 0,
fontSize: '0.74rem',
color: 'var(--text3)',
}}
>
<strong style={{ color: 'var(--text2)', fontWeight: 600 }}>Block:&nbsp;</strong>
{stripGlobalRough}
</div>
) : null}
{stripBullets.length > 0 ? (
<ul
style={{
margin: 0,
paddingLeft: '1.05rem',
fontSize: '0.74rem',
color: 'var(--text2)',
}}
>
{stripBullets.map((line, bi) => (
<li key={`combo-strip-${sIdx}-${iIdx}-${bi}`} style={{ marginBottom: 2 }}>
{line}
</li>
))}
</ul>
) : (
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', fontStyle: 'italic' }}>
Stationen laden oder noch keine Kombi-Stationen im Katalog
</div>
)}
<div className="tu-combo-planning-strip">
<div className="tu-combo-planning-strip__toolbar">
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
aria-haspopup="dialog"
aria-label="Ablaufprofil Kombination für diese Planung bearbeiten"
onClick={() => setComboPlanningModal({ sIdx, iIdx })}
>
Ablauf bearbeiten
</button>
</div>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
style={{ flexShrink: 0 }}
aria-haspopup="dialog"
aria-label="Ablaufprofil Kombination für diese Planung bearbeiten"
onClick={() => setComboPlanningModal({ sIdx, iIdx })}
>
Ablauf bearbeiten
</button>
{(it.combination_slots || []).length > 0 ? (
<div className="tu-combo-planning-strip__bracket-wrap">
<CombinationPlanBracket
className="combo-plan-bracket--planning-embed"
methodArchetype={stripArchRaw}
methodProfile={stripMpEff || {}}
combinationSlots={sortCombinationSlotsForDisplay(it.combination_slots)}
planningAdjusted={
it.planning_method_profile != null &&
typeof it.planning_method_profile === 'object' &&
!Array.isArray(it.planning_method_profile)
}
candidateInteraction={onPeekExercise ? 'button' : 'none'}
onCandidatePeek={
onPeekExercise
? (exId) => onPeekExercise(Number(exId), null, undefined)
: undefined
}
/>
</div>
) : (
<div
className="tu-combo-planning-strip__meta tu-combo-planning-strip__meta--fallback"
title="Stationen aus dem Katalog — nach ersten Laden oder wenn die Kombination noch keine Slots hat."
>
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', fontStyle: 'italic', margin: 0 }}>
Stationen werden geladen oder die Kombination hat im Katalog noch keine Stationsliste
</div>
</div>
)}
</div>
) : null}

View File

@ -0,0 +1,174 @@
import React from 'react'
import { Link } from 'react-router-dom'
import {
Eye,
Pencil,
Trash2,
Globe,
Users,
Lock,
CheckCircle2,
Archive,
CircleDot,
FilePenLine,
} from 'lucide-react'
import ExerciseRichTextBlock from '../ExerciseRichTextBlock'
import { coerceApiNameList } from '../../utils/sanitizeHtml'
import { canUserRequestExerciseDelete } from '../../utils/exercisePermissions'
const VIS_LABELS = { official: 'Global', club: 'Verein', private: 'Privat' }
const STATUS_LABELS = {
draft: 'Entwurf',
in_review: 'In Prüfung',
approved: 'Freigegeben',
archived: 'Archiv',
}
function visibilityLabel(v) {
return VIS_LABELS[v] || v || '—'
}
function statusLabel(s) {
return STATUS_LABELS[s] || s || '—'
}
function exerciseFocusNames(ex) {
const fromApi = coerceApiNameList(ex.focus_area_names)
if (fromApi.length) return fromApi
if (ex.focus_area) return [ex.focus_area]
return []
}
function exerciseCardClassName(exercise, userId) {
const vis = exercise.visibility || 'private'
const visKey = vis === 'official' || vis === 'club' || vis === 'private' ? vis : 'private'
const mine = userId != null && Number(exercise.created_by) === Number(userId)
return ['card', 'exercise-card', `exercise-card--scope-${visKey}`, mine ? 'exercise-card--mine' : '']
.filter(Boolean)
.join(' ')
}
function ExerciseCardScopeStatus({ exercise }) {
const v = exercise.visibility || 'private'
const s = exercise.status || 'draft'
const visLabel = visibilityLabel(v)
const stLabel = statusLabel(s)
const tip = `${visLabel} · ${stLabel}`
let VisIcon = Lock
if (v === 'official') VisIcon = Globe
else if (v === 'club') VisIcon = Users
let StatIcon = FilePenLine
if (s === 'approved') StatIcon = CheckCircle2
else if (s === 'archived') StatIcon = Archive
else if (s === 'in_review') StatIcon = CircleDot
return (
<div
className="exercise-card__meta-compact"
title={tip}
aria-label={`Sichtbarkeit: ${visLabel}. Status: ${stLabel}.`}
>
<span className="exercise-card__meta-glyph">
<VisIcon size={15} strokeWidth={2} aria-hidden />
</span>
<span className="exercise-card__meta-sep" aria-hidden>
·
</span>
<span className="exercise-card__meta-glyph">
<StatIcon size={15} strokeWidth={2} aria-hidden />
</span>
</div>
)
}
/**
* Kartenzeile in der Übungsliste (Fokus/Planung keine Virtualisierung im Grid, dafür content-visibility in app.css).
*/
export default function ExerciseListCard({ exercise, user, selectedIds, toggleSelect, onDelete }) {
const focusNames = exerciseFocusNames(exercise)
const styleNames = coerceApiNameList(exercise.style_direction_names)
const typeNames = coerceApiNameList(exercise.training_type_names)
return (
<div className={exerciseCardClassName(exercise, user?.id)}>
<div className="exercise-card-layout exercise-card-layout--grow">
<input
type="checkbox"
checked={selectedIds.has(Number(exercise.id))}
onChange={() => toggleSelect(exercise.id)}
aria-label={`${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
className="exercise-card-layout__check"
/>
<div className="exercise-card__body exercise-card-body-flex">
<h3 className="exercise-card-title">
<Link to={`/exercises/${exercise.id}`}>{exercise.title}</Link>
</h3>
<div className="exercise-card-tags">
{focusNames.map((name) => (
<span key={`fa:${name}`} className="exercise-tag exercise-tag--accent">
{name}
</span>
))}
{styleNames.map((name) => (
<span key={`sd:${name}`} className="exercise-tag exercise-tag--style">
{name}
</span>
))}
{typeNames.map((name) => (
<span key={`tt:${name}`} className="exercise-tag exercise-tag--training">
{name}
</span>
))}
{(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' ? (
<span
className="exercise-tag"
style={{ background: 'var(--accent-soft)', color: 'var(--accent-dark)' }}
>
Kombination
</span>
) : null}
</div>
{exercise.summary && String(exercise.summary).trim() ? (
<div className="exercise-card-summary exercise-card-summary--rich">
<ExerciseRichTextBlock
html={exercise.summary}
exerciseId={exercise.id}
media={exercise.media || []}
/>
</div>
) : null}
</div>
</div>
<div className="exercise-card__footer">
<ExerciseCardScopeStatus exercise={exercise} />
<div className="exercise-card__actions exercise-card__actions--icons">
<Link
to={`/exercises/${exercise.id}`}
className="exercise-card__icon-btn"
title="Ansehen"
aria-label={`${(exercise.title || 'Übung').replace(/"/g, '')}“ ansehen`}
>
<Eye size={18} strokeWidth={2} aria-hidden />
</Link>
<Link
to={`/exercises/${exercise.id}/edit`}
className="exercise-card__icon-btn"
title="Bearbeiten"
aria-label={`${(exercise.title || 'Übung').replace(/"/g, '')}“ bearbeiten`}
>
<Pencil size={18} strokeWidth={2} aria-hidden />
</Link>
{canUserRequestExerciseDelete(user, exercise) ? (
<button
type="button"
className="exercise-card__icon-btn exercise-card__icon-btn--danger"
title="Löschen"
aria-label={`${(exercise.title || 'Übung').replace(/"/g, '')}“ löschen`}
onClick={() => onDelete(exercise)}
>
<Trash2 size={18} strokeWidth={2} aria-hidden />
</button>
) : null}
</div>
</div>
</div>
)
}

View File

@ -1,4 +1,12 @@
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
useMemo,
useRef,
} from 'react'
import api, { ACTIVE_CLUB_STORAGE_KEY } from '../utils/api'
import { activeClubMemberships } from '../utils/activeClub'
@ -94,7 +102,7 @@ export function AuthProvider({ children }) {
}, [])
/** Fallback, falls ohne checkAuth gesetzt wird (Legacy / Token-Injektion) */
const login = (payload) => {
const login = useCallback((payload) => {
if (payload?.profile != null) {
syncStoredActiveClub(payload.profile)
setUser(payload.profile)
@ -112,9 +120,9 @@ export function AuthProvider({ children }) {
return
}
setUser(payload)
}
}, [])
const logout = () => {
const logout = useCallback(() => {
setUser(null)
localStorage.removeItem('authToken')
localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY)
@ -123,17 +131,20 @@ export function AuthProvider({ children }) {
sessionStorage.removeItem(key)
}
}
}
}, [])
const value = {
user,
isAuthenticated: !!user,
loading,
login,
logout,
checkAuth,
setActiveClub,
}
const value = useMemo(
() => ({
user,
isAuthenticated: !!user,
loading,
login,
logout,
checkAuth,
setActiveClub,
}),
[user, loading, login, logout, checkAuth, setActiveClub],
)
return (
<AuthContext.Provider value={value}>

View File

@ -27,6 +27,29 @@ export function notifyOrgInboxChanged() {
window.dispatchEvent(new Event('shinkan:inbox-changed'))
}
/** Eine konsistente Ladepfad-Logik für Join-Requests + Content-Reports (ein Codepfad für Mount + refresh). */
async function fetchOrgInboxSnapshot(canAccess, canAccessReports) {
const out = { items: [], contentReports: [], contentReportsError: null }
if (canAccess) {
try {
const data = await api.getInboxJoinRequests()
out.items = Array.isArray(data) ? data : []
} catch {
out.items = []
}
}
if (canAccessReports) {
try {
const data = await api.getInboxContentReports()
out.contentReports = Array.isArray(data) ? data : []
} catch (err) {
out.contentReports = []
out.contentReportsError = err?.message || String(err)
}
}
return out
}
export function OrgInboxProvider({ user, children }) {
const [items, setItems] = useState([])
const [contentReports, setContentReports] = useState([])
@ -35,30 +58,16 @@ export function OrgInboxProvider({ user, children }) {
const canAccessReports = useMemo(() => canSeeContentReports(user), [user])
const refresh = useCallback(async () => {
if (!canAccess) {
if (!canAccess && !canAccessReports) {
setItems([])
} else {
try {
const data = await api.getInboxJoinRequests()
setItems(Array.isArray(data) ? data : [])
} catch {
setItems([])
}
}
if (!canAccessReports) {
setContentReports([])
setContentReportsError(null)
} else {
try {
const data = await api.getInboxContentReports()
setContentReports(Array.isArray(data) ? data : [])
setContentReportsError(null)
} catch (err) {
setContentReports([])
setContentReportsError(err?.message || String(err))
}
return
}
const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports)
setItems(snap.items)
setContentReports(snap.contentReports)
setContentReportsError(canAccessReports ? snap.contentReportsError : null)
}, [canAccess, canAccessReports])
useEffect(() => {
@ -69,32 +78,43 @@ export function OrgInboxProvider({ user, children }) {
return undefined
}
let cancelled = false
;(async () => {
if (canAccess) {
try {
const data = await api.getInboxJoinRequests()
if (!cancelled) setItems(Array.isArray(data) ? data : [])
} catch {
if (!cancelled) setItems([])
}
let idleId = null
let timeoutId = null
const load = async () => {
const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports)
if (cancelled) return
setItems(snap.items)
setContentReports(snap.contentReports)
setContentReportsError(canAccessReports ? snap.contentReportsError : null)
}
const schedule = () => {
if (cancelled) return
if (typeof window.requestIdleCallback === 'function') {
idleId = window.requestIdleCallback(
() => {
idleId = null
void load()
},
{ timeout: 1500 },
)
} else {
timeoutId = window.setTimeout(() => {
timeoutId = null
void load()
}, 0)
}
if (canAccessReports) {
try {
const data = await api.getInboxContentReports()
if (!cancelled) {
setContentReports(Array.isArray(data) ? data : [])
setContentReportsError(null)
}
} catch (err) {
if (!cancelled) {
setContentReports([])
setContentReportsError(err?.message || String(err))
}
}
}
})()
}
schedule()
return () => {
cancelled = true
if (idleId != null && typeof window.cancelIdleCallback === 'function') {
window.cancelIdleCallback(idleId)
}
if (timeoutId != null) window.clearTimeout(timeoutId)
}
}, [canAccess, canAccessReports, user?.id])

View File

@ -21,122 +21,46 @@ function formatCappedCount(n, capped) {
}
function Dashboard() {
const [profile, setProfile] = useState(null)
const [loading, setLoading] = useState(true)
const [trainingHome, setTrainingHome] = useState(null)
const [trainingHomeErr, setTrainingHomeErr] = useState(null)
const [phase0Stats, setPhase0Stats] = useState(null)
const [phase0Err, setPhase0Err] = useState(null)
const { user } = useAuth()
const [dashboardKpisErr, setDashboardKpisErr] = useState(null)
const { user, loading: authLoading } = useAuth()
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
useEffect(() => {
loadData()
}, [])
useEffect(() => {
if (!user?.id) {
setTrainingHome(null)
setTrainingHomeErr(null)
return undefined
}
let cancelled = false
;(async () => {
setTrainingHomeErr(null)
try {
const today = new Date().toISOString().slice(0, 10)
const [upcomingRaw, reviewPendingRaw, plannedPool] = await Promise.all([
api.listTrainingUnits({
assigned_to_me: true,
status: 'planned',
start_date: today,
sort: 'asc',
limit: 8,
}),
api.listTrainingUnits({
assigned_to_me: true,
debrief_pending: true,
sort: 'desc',
limit: 8,
}),
api.listTrainingUnits({
assigned_to_me: true,
status: 'planned',
start_date: today,
sort: 'asc',
limit: 40,
}),
])
const noteHits = (plannedPool || []).filter((u) => {
const tn = (u.trainer_notes || '').trim()
const n = (u.notes || '').trim()
return Boolean(tn || n)
}).slice(0, 5)
if (!cancelled) {
setTrainingHome({
upcoming: Array.isArray(upcomingRaw) ? upcomingRaw : [],
reviewPending: Array.isArray(reviewPendingRaw) ? reviewPendingRaw : [],
plannedWithNotes: noteHits,
})
}
} catch (e) {
if (!cancelled) {
console.error('Dashboard Trainingsübersicht:', e)
setTrainingHomeErr(e.message || 'Konnte Trainingsdaten nicht laden')
setTrainingHome(null)
}
}
})()
return () => {
cancelled = true
}
}, [user?.id, tenantClubDepKey])
useEffect(() => {
if (!user?.id) {
setPhase0Stats(null)
setPhase0Err(null)
setDashboardKpisErr(null)
return undefined
}
let cancelled = false
;(async () => {
setPhase0Err(null)
setDashboardKpisErr(null)
try {
const year = new Date().getFullYear()
const yearStart = `${year}-01-01`
const yearEnd = `${year}-12-31`
const [draftList, mineList, ytdCompleted] = await Promise.all([
api.listExercises({ created_by_me: true, status: 'draft', limit: 100 }),
api.listExercises({ created_by_me: true, limit: 100 }),
api.listTrainingUnits({
assigned_to_me: true,
status: 'completed',
start_date: yearStart,
end_date: yearEnd,
limit: 250,
sort: 'desc',
}),
])
if (!cancelled) {
const drafts = Array.isArray(draftList) ? draftList : []
setPhase0Stats({
year,
draftCount: drafts.length,
draftCapped: drafts.length >= 100,
draftPreview: drafts.slice(0, 8).map((ex) => ({
id: ex.id,
title: ex.title || `Übung #${ex.id}`,
})),
mineCount: Array.isArray(mineList) ? mineList.length : 0,
mineCapped: Array.isArray(mineList) && mineList.length >= 100,
ytdCompletedCount: Array.isArray(ytdCompleted) ? ytdCompleted.length : 0,
ytdCapped: Array.isArray(ytdCompleted) && ytdCompleted.length >= 250,
})
}
const data = await api.getDashboardKpis()
if (cancelled || !data || typeof data !== 'object') return
const th = data.training_home && typeof data.training_home === 'object' ? data.training_home : {}
setTrainingHome({
upcoming: Array.isArray(th.upcoming) ? th.upcoming : [],
reviewPending: Array.isArray(th.review_pending) ? th.review_pending : [],
plannedWithNotes: Array.isArray(th.planned_with_notes) ? th.planned_with_notes : [],
})
setPhase0Stats({
year: data.year,
draftCount: data.draft_count,
draftCapped: Boolean(data.draft_capped),
draftPreview: Array.isArray(data.draft_preview) ? data.draft_preview : [],
mineCount: data.mine_count ?? 0,
mineCapped: Boolean(data.mine_capped),
ytdCompletedCount: data.ytd_completed_count ?? 0,
ytdCapped: Boolean(data.ytd_capped),
})
} catch (e) {
if (!cancelled) {
console.error('Dashboard Übungs-Kennzahlen:', e)
setPhase0Err(e.message || 'Konnte Übungs-Kennzahlen nicht laden')
console.error('Dashboard KPIs / Trainingsübersicht:', e)
setDashboardKpisErr(e.message || 'Konnte Dashboard-Daten nicht laden')
setTrainingHome(null)
setPhase0Stats(null)
}
}
@ -146,18 +70,7 @@ function Dashboard() {
}
}, [user?.id, tenantClubDepKey])
const loadData = async () => {
try {
const profileData = await api.getCurrentProfile()
setProfile(profileData)
} catch (err) {
console.error('Failed to load data:', err)
} finally {
setLoading(false)
}
}
if (loading) {
if (authLoading) {
return (
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}>
<div className="spinner"></div>
@ -182,7 +95,7 @@ function Dashboard() {
</p>
</div>
</div>
{profile && <EmailVerificationBanner profile={profile} />}
{user ? <EmailVerificationBanner profile={user} /> : null}
{user?.id ? (
<>
@ -199,15 +112,15 @@ function Dashboard() {
</p>
</div>
</div>
{phase0Err ? (
{dashboardKpisErr ? (
<p className="dashboard-phase0-kpis__err" role="alert">
{phase0Err}
{dashboardKpisErr}
</p>
) : null}
{!phase0Err && !phase0Stats ? (
{!dashboardKpisErr && !phase0Stats ? (
<div className="dashboard-phase0-kpis__loading muted">Zahlen werden geladen</div>
) : null}
{!phase0Err && phase0Stats ? (
{!dashboardKpisErr && phase0Stats ? (
<div className="dashboard-phase0-kpis">
<Link className="dashboard-kpi-card" to={draftsHref}>
<span className="dashboard-kpi-card__icon" aria-hidden>
@ -241,7 +154,7 @@ function Dashboard() {
</div>
</div>
) : null}
{!phase0Err && phase0Stats?.draftPreview?.length ? (
{!dashboardKpisErr && phase0Stats?.draftPreview?.length ? (
<div className="card dashboard-draft-preview" style={{ marginTop: '1rem' }}>
<h3 className="dashboard-preview-card__title" style={{ marginTop: 0 }}>
Entwürfe fertigstellen
@ -286,8 +199,8 @@ function Dashboard() {
<div className="dashboard-training-preview-grid">
<div className="card dashboard-preview-card">
<h3 className="dashboard-preview-card__title">Nächste Termine</h3>
{trainingHomeErr ? (
<p className="dashboard-preview-card__err">{trainingHomeErr}</p>
{dashboardKpisErr ? (
<p className="dashboard-preview-card__err">{dashboardKpisErr}</p>
) : trainingHome?.upcoming?.length ? (
<ul className="dashboard-preview-card__list">
{trainingHome.upcoming.map((u) => (
@ -316,8 +229,8 @@ function Dashboard() {
<div className="card dashboard-preview-card">
<h3 className="dashboard-preview-card__title">Hinweise (anstehend)</h3>
{trainingHomeErr ? (
<p className="dashboard-preview-card__err">{trainingHomeErr}</p>
{dashboardKpisErr ? (
<p className="dashboard-preview-card__err">{dashboardKpisErr}</p>
) : trainingHome?.plannedWithNotes?.length ? (
<ul className="dashboard-preview-card__list dashboard-preview-card__list--notes">
{trainingHome.plannedWithNotes.map((u) => {
@ -347,8 +260,8 @@ function Dashboard() {
<div className="card dashboard-preview-card">
<h3 className="dashboard-preview-card__title">Offene Rückschau</h3>
{trainingHomeErr ? (
<p className="dashboard-preview-card__err">{trainingHomeErr}</p>
{dashboardKpisErr ? (
<p className="dashboard-preview-card__err">{dashboardKpisErr}</p>
) : trainingHome?.reviewPending?.length ? (
<ul className="dashboard-preview-card__list">
{trainingHome.reviewPending.map((u) => (

View File

@ -2403,18 +2403,6 @@ function ExerciseFormPage() {
}
/>
)}
<ExercisePickerModal
open={comboStationPickerIx !== null}
onClose={() => setComboStationPickerIx(null)}
exerciseKindAny={['simple']}
multiSelect
enableQuickCreateDraft
onSelectExercises={(picked) => {
if (comboStationPickerIx === null) return
mergePickedExercisesIntoSlot(comboStationPickerIx, picked)
setComboStationPickerIx(null)
}}
/>
{reportTarget && (
<ReportContentModal
targetType="media_asset"
@ -2426,6 +2414,19 @@ function ExerciseFormPage() {
</div>
)}
<ExercisePickerModal
open={comboStationPickerIx !== null}
onClose={() => setComboStationPickerIx(null)}
exerciseKindAny={['simple']}
multiSelect
enableQuickCreateDraft
onSelectExercises={(picked) => {
if (comboStationPickerIx === null) return
mergePickedExercisesIntoSlot(comboStationPickerIx, picked)
setComboStationPickerIx(null)
}}
/>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
<strong>KI-Ausbaustufe:</strong> Backend laut Spec{' '}
<code style={{ fontSize: '11px' }}>POST /api/exercises/ai/suggest</code> und{' '}

View File

@ -1,17 +1,5 @@
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react'
import { Link } from 'react-router-dom'
import {
Eye,
Pencil,
Trash2,
Globe,
Users,
Lock,
CheckCircle2,
Archive,
CircleDot,
FilePenLine,
} from 'lucide-react'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
@ -19,9 +7,8 @@ import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo'
import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
import CatalogRulePicker from '../components/CatalogRulePicker'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
import PageSectionNav from '../components/PageSectionNav'
import ExerciseListCard from '../components/exercises/ExerciseListCard'
import {
INITIAL_EXERCISE_LIST_FILTERS,
mergeExerciseListPrefsFromApi,
@ -29,8 +16,8 @@ import {
splitMnCatalogRules,
splitScalarCatalogRules,
} from '../constants/exerciseListFilters'
import { coerceApiNameList } from '../utils/sanitizeHtml'
import { canUserRequestExerciseDelete } from '../utils/exercisePermissions'
const ExerciseProgressionGraphPanel = lazy(() => import('../components/ExerciseProgressionGraphPanel'))
const PAGE_SIZE = 100
const BULK_MAX_IDS = 500
@ -40,22 +27,6 @@ const EXERCISES_PAGE_TABS = [
]
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
const VIS_LABELS = { official: 'Global', club: 'Verein', private: 'Privat' }
const STATUS_LABELS = {
draft: 'Entwurf',
in_review: 'In Prüfung',
approved: 'Freigegeben',
archived: 'Archiv',
}
function visibilityLabel(v) {
return VIS_LABELS[v] || v || '—'
}
function statusLabel(s) {
return STATUS_LABELS[s] || s || '—'
}
function pushCatalogRuleFilterChips(chips, field, rules, options, topicLabel, setFilters) {
;(rules || []).forEach((r) => {
const rid = String(r.id ?? r.focus_area_id ?? '')
@ -72,54 +43,6 @@ function pushCatalogRuleFilterChips(chips, field, rules, options, topicLabel, se
})
}
function exerciseFocusNames(ex) {
const fromApi = coerceApiNameList(ex.focus_area_names)
if (fromApi.length) return fromApi
if (ex.focus_area) return [ex.focus_area]
return []
}
function exerciseCardClassName(exercise, userId) {
const vis = exercise.visibility || 'private'
const visKey = vis === 'official' || vis === 'club' || vis === 'private' ? vis : 'private'
const mine = userId != null && Number(exercise.created_by) === Number(userId)
return ['card', 'exercise-card', `exercise-card--scope-${visKey}`, mine ? 'exercise-card--mine' : '']
.filter(Boolean)
.join(' ')
}
function ExerciseCardScopeStatus({ exercise }) {
const v = exercise.visibility || 'private'
const s = exercise.status || 'draft'
const visLabel = visibilityLabel(v)
const stLabel = statusLabel(s)
const tip = `${visLabel} · ${stLabel}`
let VisIcon = Lock
if (v === 'official') VisIcon = Globe
else if (v === 'club') VisIcon = Users
let StatIcon = FilePenLine
if (s === 'approved') StatIcon = CheckCircle2
else if (s === 'archived') StatIcon = Archive
else if (s === 'in_review') StatIcon = CircleDot
return (
<div
className="exercise-card__meta-compact"
title={tip}
aria-label={`Sichtbarkeit: ${visLabel}. Status: ${stLabel}.`}
>
<span className="exercise-card__meta-glyph">
<VisIcon size={15} strokeWidth={2} aria-hidden />
</span>
<span className="exercise-card__meta-sep" aria-hidden>
·
</span>
<span className="exercise-card__meta-glyph">
<StatIcon size={15} strokeWidth={2} aria-hidden />
</span>
</div>
)
}
function levelOptionShort(levelStr) {
const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr))
return o ? String(o.level) : String(levelStr)
@ -177,7 +100,6 @@ function ExercisesListPage() {
const [catalogsReady, setCatalogsReady] = useState(false)
const [listFetching, setListFetching] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [searchInput, setSearchInput] = useState('')
const [aiSearchInput, setAiSearchInput] = useState('')
@ -604,13 +526,11 @@ function ExercisesListPage() {
let cancelled = false
const run = async () => {
setListFetching(true)
setOffset(0)
try {
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 })
if (cancelled) return
setExercises(batch)
setHasMore(batch.length === PAGE_SIZE)
setOffset(batch.length)
} catch (err) {
if (!cancelled) {
console.error('Failed to load data:', err)
@ -628,12 +548,21 @@ function ExercisesListPage() {
const loadMore = async () => {
if (loadingMore || !hasMore) return
const last = exercises[exercises.length - 1]
if (!last?.id || last.updated_at == null) return
setLoadingMore(true)
try {
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset })
const batch = await api.listExercises({
...queryBase,
limit: PAGE_SIZE,
cursor_updated_at:
typeof last.updated_at === 'string'
? last.updated_at
: new Date(last.updated_at).toISOString(),
cursor_id: last.id,
})
setExercises((prev) => [...prev, ...batch])
setHasMore(batch.length === PAGE_SIZE)
setOffset((o) => o + batch.length)
} catch (err) {
alert('Fehler: ' + err.message)
} finally {
@ -829,7 +758,18 @@ function ExercisesListPage() {
/>
{pageTab === 'progression' ? (
<ExerciseProgressionGraphPanel />
<Suspense
fallback={
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
<div className="spinner" />
<p className="muted" style={{ marginTop: '12px' }}>
Lade Progressionsgraphen
</p>
</div>
}
>
<ExerciseProgressionGraphPanel />
</Suspense>
) : (
<>
<div className="card exercise-search-bar">
@ -1378,89 +1318,17 @@ function ExercisesListPage() {
{exercises.length} angezeigt
{hasMore ? ' · es gibt weitere Einträge' : ''}
</p>
<div className="exercises-list-grid">
{exercises.map((exercise) => {
const focusNames = exerciseFocusNames(exercise)
const styleNames = coerceApiNameList(exercise.style_direction_names)
const typeNames = coerceApiNameList(exercise.training_type_names)
return (
<div key={exercise.id} className={exerciseCardClassName(exercise, user?.id)}>
<div className="exercise-card-layout exercise-card-layout--grow">
<input
type="checkbox"
checked={selectedIds.has(Number(exercise.id))}
onChange={() => toggleSelect(exercise.id)}
aria-label={`${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
className="exercise-card-layout__check"
/>
<div className="exercise-card__body exercise-card-body-flex">
<h3 className="exercise-card-title">
<Link to={`/exercises/${exercise.id}`}>
{exercise.title}
</Link>
</h3>
<div className="exercise-card-tags">
{focusNames.map((name) => (
<span key={`fa:${name}`} className="exercise-tag exercise-tag--accent">{name}</span>
))}
{styleNames.map((name) => (
<span key={`sd:${name}`} className="exercise-tag exercise-tag--style">{name}</span>
))}
{typeNames.map((name) => (
<span key={`tt:${name}`} className="exercise-tag exercise-tag--training">{name}</span>
))}
{(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' ? (
<span className="exercise-tag" style={{ background: 'var(--accent-soft)', color: 'var(--accent-dark)' }}>
Kombination
</span>
) : null}
</div>
{exercise.summary && String(exercise.summary).trim() ? (
<div className="exercise-card-summary exercise-card-summary--rich">
<ExerciseRichTextBlock
html={exercise.summary}
exerciseId={exercise.id}
media={exercise.media || []}
/>
</div>
) : null}
</div>
</div>
<div className="exercise-card__footer">
<ExerciseCardScopeStatus exercise={exercise} />
<div className="exercise-card__actions exercise-card__actions--icons">
<Link
to={`/exercises/${exercise.id}`}
className="exercise-card__icon-btn"
title="Ansehen"
aria-label={`${(exercise.title || 'Übung').replace(/"/g, '')}“ ansehen`}
>
<Eye size={18} strokeWidth={2} aria-hidden />
</Link>
<Link
to={`/exercises/${exercise.id}/edit`}
className="exercise-card__icon-btn"
title="Bearbeiten"
aria-label={`${(exercise.title || 'Übung').replace(/"/g, '')}“ bearbeiten`}
>
<Pencil size={18} strokeWidth={2} aria-hidden />
</Link>
{canUserRequestExerciseDelete(user, exercise) ? (
<button
type="button"
className="exercise-card__icon-btn exercise-card__icon-btn--danger"
title="Löschen"
aria-label={`${(exercise.title || 'Übung').replace(/"/g, '')}“ löschen`}
onClick={() => handleDelete(exercise)}
>
<Trash2 size={18} strokeWidth={2} aria-hidden />
</button>
) : null}
</div>
</div>
</div>
)
})}
<div className="exercises-list-grid" data-testid="exercises-list-grid">
{exercises.map((exercise) => (
<ExerciseListCard
key={exercise.id}
exercise={exercise}
user={user}
selectedIds={selectedIds}
toggleSelect={toggleSelect}
onDelete={handleDelete}
/>
))}
</div>
{hasMore && (
<div className="exercises-load-more">

View File

@ -1348,10 +1348,20 @@ export async function listTrainingUnits(filters = {}) {
if (filters.assigned_to_me === true) q.set('assigned_to_me', 'true')
if (filters.sort) q.set('sort', String(filters.sort))
if (filters.limit != null && filters.limit !== '') q.set('limit', String(filters.limit))
if (filters.cursor_planned_date) q.set('cursor_planned_date', String(filters.cursor_planned_date))
if (filters.cursor_planned_time != null && filters.cursor_planned_time !== '') {
q.set('cursor_planned_time', String(filters.cursor_planned_time))
}
if (filters.cursor_id != null && filters.cursor_id !== '') q.set('cursor_id', String(filters.cursor_id))
const qs = q.toString()
return request(`/api/training-units${qs ? `?${qs}` : ''}`)
}
/** Dashboard Kurzüberblick: Entwürfe / meine Übungen / YTD abgeschlossene Einheiten (ein Roundtrip). */
export async function getDashboardKpis() {
return request('/api/dashboard/kpis')
}
/** Dashboard: Übungen in geplanten Einheiten, die für den Verein noch auf Sichtbarkeit „Verein“ gehören. */
export async function getTrainingExerciseClubVisibilityQueue(filters = {}) {
const q = new URLSearchParams()
@ -1601,6 +1611,7 @@ export const api = {
// Training Planning
listTrainingUnits,
getDashboardKpis,
getTrainingExerciseClubVisibilityQueue,
getTrainingUnit,
createTrainingUnit,

View File

@ -9,6 +9,33 @@ export default defineConfig({
},
build: {
outDir: 'dist',
sourcemap: false
}
sourcemap: false,
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return
if (id.includes('jspdf')) return 'vendor-pdf'
if (id.includes('lucide-react')) return 'vendor-icons'
if (
id.includes('react-markdown') ||
id.includes('/marked/') ||
id.includes('remark-') ||
id.includes('mdast') ||
id.includes('micromark') ||
id.includes('unist')
) {
return 'vendor-markdown'
}
if (id.includes('react-router')) return 'vendor-router'
if (
/[/\\]node_modules[/\\]react-dom[/\\]/.test(id) ||
/[/\\]node_modules[/\\]react[/\\]/.test(id) ||
/[/\\]node_modules[/\\]scheduler[/\\]/.test(id)
) {
return 'vendor-react'
}
},
},
},
},
})

34
scripts/load/README.md Normal file
View File

@ -0,0 +1,34 @@
# k6 Health-Baseline (Phase 0)
Parallele GETs auf `/health` **ohne** Auth, geeignet für Dev/Prod hinter dem gleichen Proxy wie die App.
**CI / Deploy:** In **`.gitea/workflows/test.yml`** eigener Job **`k6-health-baseline`** (nur Checkout + /health-Wartezeit + k6). **Playwright** läuft parallel/im selben Workflow im Job **`playwright-tests`** — ohne k6. Gleiche `BASE_URL`-Logik (Dev oder Prod nach `workflow_run`).
## Voraussetzung
[k6 installieren](https://k6.io/docs/getting-started/installation/).
## Aufruf Beispiel
```bash
# Windows PowerShell
$env:BASE_URL="https://dev.shinkan.jinkendo.de"
k6 run scripts/load/k6-health-baseline.js
```
```bash
# Linux / macOS
BASE_URL=https://dev.shinkan.jinkendo.de k6 run scripts/load/k6-health-baseline.js
```
**Architektur:** Der Workflow lädt **linux-amd64** oder **linux-arm64** je nach `uname -m` (z.B. Gitea-Runner auf Raspberry Pi 5).
## Auswertung
In der k6-Zusammenfassung `http_req_duration`**p(95)** in [BASELINE_SNAPSHOT.md](../../docs/architecture/BASELINE_SNAPSHOT.md) eintragen.
Schwellwerte sind bewusst locker (`p95 < 3s`); bei Fehlschlag Proxy, Netz oder Backend prüfen.
## EXPLAIN (Phase 2)
Datei **`explain-readpaths.sql`**: Vorlagen für `EXPLAIN (ANALYZE, BUFFERS)` auf der Ziel-DB (manuell, nicht CI).

View File

@ -0,0 +1,56 @@
-- Phase 2: Vorlagen für EXPLAIN (ANALYZE, BUFFERS) auf Ziel-DB mit realistischem Datenbestand.
-- Ersetzen: :token (Session), ggf. :club_id / :group_id nach Tenant; in psql: \set token '...'
-- Hinweis: Routen sind auth-geschützt — sinnvoll mit Rolle ausführen, die der API entspricht,
-- oder SQL aus Postgres-Logs normalisieren.
-- GET /api/exercises — typische Liste (Filter anpassen)
EXPLAIN (ANALYZE, BUFFERS)
SELECT e.id, e.title
FROM exercises e
WHERE e.status <> 'archived'
AND e.visibility IN ('private', 'club', 'official')
ORDER BY e.updated_at DESC, e.id DESC
LIMIT 50;
-- GET /api/exercises — mit Stufenfilter (nutzt idx_exercise_skills_exercise_level_rank)
EXPLAIN (ANALYZE, BUFFERS)
SELECT e.id, e.title
FROM exercises e
WHERE e.status <> 'archived'
AND EXISTS (
SELECT 1 FROM exercise_skills es
WHERE es.exercise_id = e.id
AND (
CASE COALESCE(
NULLIF(TRIM(LOWER(es.target_level::text)), ''),
NULLIF(TRIM(LOWER(es.required_level::text)), '')
)
WHEN 'basis' THEN 1
WHEN 'grundlagen' THEN 2
WHEN 'aufbau' THEN 3
WHEN 'fortgeschritten' THEN 4
WHEN 'optimierung' THEN 5
WHEN 'einsteiger' THEN 1
WHEN 'experte' THEN 5
WHEN '1' THEN 1
WHEN '2' THEN 2
WHEN '3' THEN 3
WHEN '4' THEN 4
WHEN '5' THEN 5
ELSE NULL END
) BETWEEN 2 AND 4
)
ORDER BY e.updated_at DESC, e.id DESC
LIMIT 50;
-- GET /api/training-units — Kalenderliste (ohne Blueprint)
EXPLAIN (ANALYZE, BUFFERS)
SELECT tu.id, tu.planned_date, tu.planned_time_start
FROM training_units tu
LEFT JOIN training_groups tg ON tu.group_id = tg.id
WHERE tu.framework_slot_id IS NULL
ORDER BY tu.planned_date ASC,
(tu.planned_time_start IS NULL) ASC,
tu.planned_time_start ASC NULLS LAST,
tu.id ASC
LIMIT 40;

View File

@ -0,0 +1,32 @@
/**
* Phase-0-Baseline: parallele GET /health (kein Auth).
* BASE_URL optional, z. B. https://dev.shinkan.jinkendo.de
*/
import http from 'k6/http'
import { check } from 'k6'
export const options = {
scenarios: {
health: {
executor: 'constant-vus',
vus: 10,
duration: '30s',
gracefulStop: '5s',
tags: { scenario: 'health' },
exec: 'health',
},
},
thresholds: {
http_req_failed: ['rate<0.05'],
'http_req_duration{scenario:health}': ['p(95)<3000'],
},
}
const BASE = (__ENV.BASE_URL || 'https://dev.shinkan.jinkendo.de').replace(/\/$/, '')
export function health() {
const res = http.get(`${BASE}/health`, { tags: { scenario: 'health' } })
check(res, {
'health 2xx': (r) => r.status >= 200 && r.status < 300,
})
}

View File

@ -59,15 +59,30 @@ test('2. Dashboard lädt ohne Fehler', async ({ page }) => {
test('3. Navigation zu Übungen', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
// Bei Viewport ≥1024px ist .bottom-nav versteckt — Mobile garantieren wie in playwright.config.js
await page.setViewportSize({ width: 390, height: 844 });
// Desktop-Sidebar enthält ebenfalls Übungen nur Mobile-Bottom-Nav klicken (sichtbarer Link)
await page.locator('.bottom-nav a[href="/exercises"]').click();
// Bottom-Nav: Navigation und URL gemeinsam abwarten (vermeidet race mit networkidle)
const exercisesLink = page.locator('.bottom-nav').getByRole('link', { name: /Übungen/i });
await Promise.all([
page.waitForURL(
(u) => {
const path = u.pathname.replace(/\/$/, '') || '/'
return path === '/exercises'
},
{ timeout: 15000 },
),
exercisesLink.click(),
]);
await page.waitForLoadState('networkidle');
// Prüfe ob Übungen-Seite geladen
await expect(page.locator('h1, h2, .page-title')).toContainText(/übungen/i, { timeout: 5000 });
// Wie Test 4 (Vereine): eine eindeutige h1 — nicht h1,h2-Kombi (Strict Mode + mehrere Treffer)
const main = page.locator('.app-main');
await expect(main.getByRole('heading', { level: 1, name: /Übungen/i })).toBeVisible({
timeout: 10000,
});
await page.screenshot({ path: 'screenshots/03-uebungen.png' });
console.log('✓ Übungen-Seite erreichbar');
@ -143,6 +158,68 @@ test('7. Session-Persistenz nach Reload', async ({ page }) => {
console.log('✓ Session bleibt nach Reload erhalten');
});
/**
* Phase 2 (Dashboard): ein GET /api/dashboard/kpis (KPIs + Trainings-Home); keine direkten GET /api/training-units vom Dashboard.
* Production-ähnlicher Build empfohlen (kein React StrictMode-Doppel-Mount im lokalen Vite-Dev).
*/
test('8. Dashboard API-Budget nach Reload (profiles/me, dashboard/kpis)', async ({ page }) => {
await login(page);
let profilesMe = 0;
let trainingUnits = 0;
let dashboardKpis = 0;
const onRequest = (request) => {
if (request.method() !== 'GET') return;
let pathname = '';
try {
pathname = new URL(request.url()).pathname;
} catch {
return;
}
if (pathname === '/api/profiles/me') profilesMe += 1;
if (pathname === '/api/training-units') trainingUnits += 1;
if (pathname === '/api/dashboard/kpis') dashboardKpis += 1;
};
page.on('request', onRequest);
try {
await page.reload({ waitUntil: 'networkidle' });
const main = page.locator('.app-main');
await expect(main.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeVisible({
timeout: 15000,
});
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await expect(page.getByRole('heading', { name: 'Nächste Termine' })).toBeVisible({
timeout: 20000,
});
expect(profilesMe).toBe(1);
expect(trainingUnits).toBe(0);
expect(dashboardKpis).toBe(1);
} finally {
page.off('request', onRequest);
}
console.log('✓ Dashboard API-Budget: 1× profiles/me, 0× training-units, 1× dashboard/kpis');
});
test('9. Übungsliste: nach Laden entweder Treffer-Gitter oder Leerhinweis', async ({ page }) => {
await login(page);
await page.goto('/exercises', { waitUntil: 'networkidle' });
const main = page.locator('.app-main');
await expect(main.getByRole('heading', { level: 1, name: /Übungen/i })).toBeVisible({
timeout: 15000,
});
await expect(main.locator('.spinner')).toHaveCount(0, { timeout: 20000 });
const grid = main.getByTestId('exercises-list-grid');
const empty = main.locator('.exercises-empty-text');
await expect(grid.or(empty).first()).toBeVisible({ timeout: 15000 });
console.log('✓ Übungsliste: Endzustand sichtbar (Gitter oder leer)');
});
test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await login(page);
@ -457,7 +534,7 @@ test('P-06e: API-Endpoint /api/admin/media-rights/legacy-summary erreichbar (Sup
}
});
test('8. Keine kritischen Console-Fehler', async ({ page }) => {
test('9. Keine kritischen Console-Fehler', async ({ page }) => {
const errors = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());