diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 984c0eb..5bc6f55 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -15,6 +15,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | 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 A–C. **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`. diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index fba50b6..dd1bd90 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index f48905a..f7bb836 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/backend/main.py b/backend/main.py index 108cfe0..4fa97c7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/migrations/058_exercises_list_ordering_indexes.sql b/backend/migrations/058_exercises_list_ordering_indexes.sql new file mode 100644 index 0000000..d1f2418 --- /dev/null +++ b/backend/migrations/058_exercises_list_ordering_indexes.sql @@ -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); diff --git a/backend/migrations/059_training_units_list_ordering_index.sql b/backend/migrations/059_training_units_list_ordering_index.sql new file mode 100644 index 0000000..2586246 --- /dev/null +++ b/backend/migrations/059_training_units_list_ordering_index.sql @@ -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; diff --git a/backend/migrations/060_exercises_list_scale_indexes.sql b/backend/migrations/060_exercises_list_scale_indexes.sql new file mode 100644 index 0000000..e9d54ca --- /dev/null +++ b/backend/migrations/060_exercises_list_scale_indexes.sql @@ -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); diff --git a/backend/migrations/061_training_units_keyset_indexes.sql b/backend/migrations/061_training_units_keyset_indexes.sql new file mode 100644 index 0000000..98dba11 --- /dev/null +++ b/backend/migrations/061_training_units_keyset_indexes.sql @@ -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; diff --git a/backend/migrations/062_exercise_skills_level_rank_index.sql b/backend/migrations/062_exercise_skills_level_rank_index.sql new file mode 100644 index 0000000..e1a8c06 --- /dev/null +++ b/backend/migrations/062_exercise_skills_level_rank_index.sql @@ -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; diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py new file mode 100644 index 0000000..016f706 --- /dev/null +++ b/backend/routers/dashboard.py @@ -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, + }, + } diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 1cdae79..c85840b 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -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 (Tie‑break 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, diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 49e492f..d2c635b 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -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) diff --git a/backend/tests/test_dashboard_kpis.py b/backend/tests/test_dashboard_kpis.py new file mode 100644 index 0000000..8847f01 --- /dev/null +++ b/backend/tests/test_dashboard_kpis.py @@ -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 diff --git a/backend/tests/test_exercises_list_keyset.py b/backend/tests/test_exercises_list_keyset.py new file mode 100644 index 0000000..1a4abf8 --- /dev/null +++ b/backend/tests/test_exercises_list_keyset.py @@ -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 diff --git a/backend/tests/test_training_units_list_keyset.py b/backend/tests/test_training_units_list_keyset.py new file mode 100644 index 0000000..0a5257a --- /dev/null +++ b/backend/tests/test_training_units_list_keyset.py @@ -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 diff --git a/backend/version.py b/backend/version.py index b011ef4..be158f1 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 6ad7843..7879f36 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -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 **4a–g** — u. a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung). diff --git a/docs/architecture/BASELINE_SNAPSHOT.md b/docs/architecture/BASELINE_SNAPSHOT.md new file mode 100644 index 0000000..0018eca --- /dev/null +++ b/docs/architecture/BASELINE_SNAPSHOT.md @@ -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) diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 0000000..ac5fe6c --- /dev/null +++ b/docs/architecture/README.md @@ -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. diff --git a/docs/architecture/SCHULDEN_UND_REMEDIATION.md b/docs/architecture/SCHULDEN_UND_REMEDIATION.md new file mode 100644 index 0000000..f3c2e5a --- /dev/null +++ b/docs/architecture/SCHULDEN_UND_REMEDIATION.md @@ -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 | diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md new file mode 100644 index 0000000..ffc57fd --- /dev/null +++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md @@ -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 058–062, 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 058–060, 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 **058–062**; 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. diff --git a/docs/architecture/VERBINDLICHE_REGELN_SHINKAN.md b/docs/architecture/VERBINDLICHE_REGELN_SHINKAN.md new file mode 100644 index 0000000..a356748 --- /dev/null +++ b/docs/architecture/VERBINDLICHE_REGELN_SHINKAN.md @@ -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 S1–S7 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`). diff --git a/docs/architecture/ZIELBILD_ARCHITEKTUR.md b/docs/architecture/ZIELBILD_ARCHITEKTUR.md new file mode 100644 index 0000000..55819df --- /dev/null +++ b/docs/architecture/ZIELBILD_ARCHITEKTUR.md @@ -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 > ~400–500 Zeilen in einer Datei – Auslagerung in `components/`, `hooks/`, `features//`. +- **Feature-Ordner (Ziel):** wo sinnvoll `frontend/src/features//` 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). diff --git a/frontend/package.json b/frontend/package.json index 9ad335a..876f479 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5f05dae..65c338e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( +
+
+
+ ) +} + // Bottom Navigation (Mobile) function Nav({ showAdminNav }) { const { canAccessOrgInbox, inboxCount } = useOrgInbox() @@ -270,7 +291,9 @@ function App() { return ( - + }> + + ) diff --git a/frontend/src/app.css b/frontend/src/app.css index 2503d53..64c3d7a 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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); } -/* Kombinations‑Strip: 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%; +} + +/* Kombinations‑Strip: volle Breite; oben „Ablauf bearbeiten“, darunter Klammer‑Vorschau */ .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 { diff --git a/frontend/src/components/CombinationPlanBracket.jsx b/frontend/src/components/CombinationPlanBracket.jsx index 28438ee..cffe8bd 100644 --- a/frontend/src/components/CombinationPlanBracket.jsx +++ b/frontend/src/components/CombinationPlanBracket.jsx @@ -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 ( -
+
diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index e93a896..5d04d66 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -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({
-
+
{!catalogsReady || (loading && list.length === 0) ? (
@@ -596,8 +611,18 @@ export default function ExercisePickerModal({

{list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}

-
    - {list.map((ex) => { +
    + {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 ( -
  • + return ( +
    + {multiSelect ? ( -
  • - ) - } - return ( -
  • - -
  • + ) : ( + + )} +
    ) })} -
+
{hasMore && (
- + {(it.combination_slots || []).length > 0 ? ( +
+ onPeekExercise(Number(exId), null, undefined) + : undefined + } + /> +
+ ) : ( +
+
+ Stationen werden geladen oder die Kombination hat im Katalog noch keine Stationsliste … +
+
+ )}
) : null} diff --git a/frontend/src/components/exercises/ExerciseListCard.jsx b/frontend/src/components/exercises/ExerciseListCard.jsx new file mode 100644 index 0000000..1ef58e8 --- /dev/null +++ b/frontend/src/components/exercises/ExerciseListCard.jsx @@ -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 ( +
+ + + + + · + + + + +
+ ) +} + +/** + * 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 ( +
+
+ toggleSelect(exercise.id)} + aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`} + className="exercise-card-layout__check" + /> +
+

+ {exercise.title} +

+
+ {focusNames.map((name) => ( + + {name} + + ))} + {styleNames.map((name) => ( + + {name} + + ))} + {typeNames.map((name) => ( + + {name} + + ))} + {(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' ? ( + + Kombination + + ) : null} +
+ {exercise.summary && String(exercise.summary).trim() ? ( +
+ +
+ ) : null} +
+
+
+ +
+ + + + + + + {canUserRequestExerciseDelete(user, exercise) ? ( + + ) : null} +
+
+
+ ) +} diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index f02526d..50a877e 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -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 ( diff --git a/frontend/src/context/OrgInboxContext.jsx b/frontend/src/context/OrgInboxContext.jsx index f76f01c..5c6b6db 100644 --- a/frontend/src/context/OrgInboxContext.jsx +++ b/frontend/src/context/OrgInboxContext.jsx @@ -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]) diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index d37f622..5361236 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -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 (
@@ -182,7 +95,7 @@ function Dashboard() {

- {profile && } + {user ? : null} {user?.id ? ( <> @@ -199,15 +112,15 @@ function Dashboard() {

- {phase0Err ? ( + {dashboardKpisErr ? (

- {phase0Err} + {dashboardKpisErr}

) : null} - {!phase0Err && !phase0Stats ? ( + {!dashboardKpisErr && !phase0Stats ? (
Zahlen werden geladen…
) : null} - {!phase0Err && phase0Stats ? ( + {!dashboardKpisErr && phase0Stats ? (
@@ -241,7 +154,7 @@ function Dashboard() {
) : null} - {!phase0Err && phase0Stats?.draftPreview?.length ? ( + {!dashboardKpisErr && phase0Stats?.draftPreview?.length ? (

Entwürfe fertigstellen @@ -286,8 +199,8 @@ function Dashboard() {

Nächste Termine

- {trainingHomeErr ? ( -

{trainingHomeErr}

+ {dashboardKpisErr ? ( +

{dashboardKpisErr}

) : trainingHome?.upcoming?.length ? (
    {trainingHome.upcoming.map((u) => ( @@ -316,8 +229,8 @@ function Dashboard() {

    Hinweise (anstehend)

    - {trainingHomeErr ? ( -

    {trainingHomeErr}

    + {dashboardKpisErr ? ( +

    {dashboardKpisErr}

    ) : trainingHome?.plannedWithNotes?.length ? (
      {trainingHome.plannedWithNotes.map((u) => { @@ -347,8 +260,8 @@ function Dashboard() {

      Offene Rückschau

      - {trainingHomeErr ? ( -

      {trainingHomeErr}

      + {dashboardKpisErr ? ( +

      {dashboardKpisErr}

      ) : trainingHome?.reviewPending?.length ? (
        {trainingHome.reviewPending.map((u) => ( diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx index 975fa07..b82904a 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -2403,18 +2403,6 @@ function ExerciseFormPage() { } /> )} - setComboStationPickerIx(null)} - exerciseKindAny={['simple']} - multiSelect - enableQuickCreateDraft - onSelectExercises={(picked) => { - if (comboStationPickerIx === null) return - mergePickedExercisesIntoSlot(comboStationPickerIx, picked) - setComboStationPickerIx(null) - }} - /> {reportTarget && ( )} + setComboStationPickerIx(null)} + exerciseKindAny={['simple']} + multiSelect + enableQuickCreateDraft + onSelectExercises={(picked) => { + if (comboStationPickerIx === null) return + mergePickedExercisesIntoSlot(comboStationPickerIx, picked) + setComboStationPickerIx(null) + }} + /> +

        KI-Ausbaustufe: Backend laut Spec{' '} POST /api/exercises/ai/suggest und{' '} diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index de91e4d..1cd50ad 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -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 ( -

        - - - - - · - - - - -
        - ) -} - 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' ? ( - + +
        +

        + Lade Progressionsgraphen… +

        +
        + } + > + +
        ) : ( <>
        @@ -1378,89 +1318,17 @@ function ExercisesListPage() { {exercises.length} angezeigt {hasMore ? ' · es gibt weitere Einträge' : ''}

        -
        - {exercises.map((exercise) => { - const focusNames = exerciseFocusNames(exercise) - const styleNames = coerceApiNameList(exercise.style_direction_names) - const typeNames = coerceApiNameList(exercise.training_type_names) - return ( -
        -
        - toggleSelect(exercise.id)} - aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`} - className="exercise-card-layout__check" - /> -
        -

        - - {exercise.title} - -

        -
        - {focusNames.map((name) => ( - {name} - ))} - {styleNames.map((name) => ( - {name} - ))} - {typeNames.map((name) => ( - {name} - ))} - {(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' ? ( - - Kombination - - ) : null} -
        - {exercise.summary && String(exercise.summary).trim() ? ( -
        - -
        - ) : null} -
        -
        -
        - -
        - - - - - - - {canUserRequestExerciseDelete(user, exercise) ? ( - - ) : null} -
        -
        -
        - ) - })} +
        + {exercises.map((exercise) => ( + + ))}
        {hasMore && (
        diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index edb1596..5a46420 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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, diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 4b68d47..06a2b1e 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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' + } + }, + }, + }, + }, }) diff --git a/scripts/load/README.md b/scripts/load/README.md new file mode 100644 index 0000000..4833161 --- /dev/null +++ b/scripts/load/README.md @@ -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). diff --git a/scripts/load/explain-readpaths.sql b/scripts/load/explain-readpaths.sql new file mode 100644 index 0000000..e1c7efc --- /dev/null +++ b/scripts/load/explain-readpaths.sql @@ -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; diff --git a/scripts/load/k6-health-baseline.js b/scripts/load/k6-health-baseline.js new file mode 100644 index 0000000..8baf048 --- /dev/null +++ b/scripts/load/k6-health-baseline.js @@ -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, + }) +} diff --git a/tests/dev-smoke-test.spec.js b/tests/dev-smoke-test.spec.js index 4681ce2..e498118 100644 --- a/tests/dev-smoke-test.spec.js +++ b/tests/dev-smoke-test.spec.js @@ -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());