Compare commits

..

19 Commits

Author SHA1 Message Date
d153a22545 Merge pull request 'Bug Fixing Kombi-Übungen - Performance Update 1 (Phase 0-2)' (#33) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 32s
Test Suite / playwright-tests (push) Successful in 58s
Reviewed-on: #33
2026-05-14 09:09:55 +02:00
930a786315 refactor(ui): enhance styling and structure of training unit sections and combination plan bracket
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m5s
Test Suite / pytest-backend (pull_request) Successful in 34s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 11s
Test Suite / k6 /health Baseline (pull_request) Successful in 33s
Test Suite / playwright-tests (pull_request) Successful in 1m8s
- Updated CSS for training unit sections to improve layout and responsiveness, ensuring combo planning strips are displayed correctly.
- Refactored CombinationPlanBracket component to accept additional class names for better customization.
- Removed unused functions and streamlined imports in TrainingUnitSectionsEditor for cleaner code.
- Reintroduced ExercisePickerModal with improved placement in ExerciseFormPage for better user experience.
2026-05-14 09:05:15 +02:00
9da29a2231 chore(version): update version and changelog for release 0.8.119
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m2s
- Bumped APP_VERSION to 0.8.119 and updated the changelog to reflect new features.
- Introduced the ExerciseListCard component and implemented lazy loading for the Progression Tab using React's Suspense.
- Enhanced the ExercisePickerModal with virtualization for improved performance using @tanstack/react-virtual.
- Updated documentation to reflect the new app version and its corresponding changes.
2026-05-14 08:59:06 +02:00
b06d026dd0 chore(version): update version and changelog for release 0.8.118
All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 57s
- Bumped APP_VERSION to 0.8.118 and updated DB_SCHEMA_VERSION to 20260514062.
- Enhanced the dashboard API with a new endpoint that consolidates training home data, allowing for a single request to retrieve upcoming training sessions, planned sessions with notes, and review pending items.
- Updated the frontend Dashboard component to utilize the new API structure, improving data loading efficiency and user experience.
- Added migration details and changelog entries to reflect the latest changes and improvements.
2026-05-14 08:53:09 +02:00
32ba008660 chore(version): update version and changelog for release 0.8.117
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m2s
- Bumped APP_VERSION to 0.8.117 and updated DB_SCHEMA_VERSION to 20260514061.
- Enhanced the training units API with optional keyset pagination, allowing for more efficient data retrieval.
- Updated the changelog to reflect the new features and improvements, including changes to the frontend API integration for training units.
- Adjusted documentation to align with the new app version and its corresponding changes.
2026-05-14 08:44:59 +02:00
657fcc241a chore(version): update version and changelog for release 0.8.116
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m0s
- Bumped APP_VERSION to 0.8.116 and updated the changelog to reflect changes, including the implementation of a new loading strategy for the Org-Inbox that utilizes requestIdleCallback to optimize API calls during dashboard initialization.
- Updated documentation to reflect the new app version and its corresponding changes.
2026-05-14 08:36:31 +02:00
c69edc6952 feat(ci): refactor Gitea workflow to separate k6 health baseline tests
All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m5s
- Introduced a new job `k6-health-baseline` in the Gitea CI workflow to run health checks independently from Playwright tests, enhancing clarity and organization.
- Updated documentation to reflect the changes in the CI pipeline, specifying the execution order and purpose of each job.
- Adjusted environment variables and health check logic for both development and production modes, ensuring accurate testing conditions.
2026-05-14 08:32:26 +02:00
789b640ad0 chore(version): update version and changelog for release 0.8.115
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 1m28s
- Bumped APP_VERSION to 0.8.115 and updated the changelog to reflect changes, including the introduction of keyset pagination for the GET /api/exercises endpoint.
- Enhanced the exercises router to support cursor-based pagination using cursor_updated_at and cursor_id, improving performance and user experience.
- Updated frontend components to utilize the new pagination method, removing offset-based loading logic.
2026-05-14 08:24:47 +02:00
14cf8a1a53 feat(tests): enhance smoke test for exercise navigation
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 1m42s
- Added a check to ensure the loading spinner is not visible before navigating to the exercises page, improving test reliability.
- Updated navigation logic to wait for both the URL change and the click event on the exercises link, reducing race conditions.
- Modified the assertion to check for the visibility of the main heading on the exercises page, ensuring stricter validation of page load success.
2026-05-14 08:17:15 +02:00
ea4c1f87f6 chore(version): update version and changelog for release 0.8.114
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Failing after 1m31s
- Bumped APP_VERSION to 0.8.114 and updated DB_SCHEMA_VERSION to 20260514060.
- Added changelog entry for version 0.8.114, detailing migration 060 for exercise scaling and indexing improvements.
2026-05-14 08:06:39 +02:00
2fa1db55fd chore(version): update version and changelog for release 0.8.113
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / playwright-tests (push) Successful in 1m29s
- Bumped APP_VERSION to 0.8.113 and updated DB_SCHEMA_VERSION to 20260514059.
- Added changelog entry for version 0.8.113, detailing migration 059 for training unit sorting without framework_slot_id.
2026-05-14 08:02:49 +02:00
75ddd06d6a chore(version): update version and changelog for release 0.8.112
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / build-frontend (push) Waiting to run
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Has been cancelled
- Bumped APP_VERSION to 0.8.112 and updated DB_SCHEMA_VERSION to 20260514058.
- Added changelog entry for version 0.8.112, detailing migration 058 for exercise sorting indices.
2026-05-14 08:01:52 +02:00
597486bef1 feat(dashboard): add GET /api/dashboard/kpis endpoint and integrate into frontend
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 1m32s
- Implemented a new API endpoint for retrieving dashboard KPIs, providing a consolidated overview of drafts, personal exercises, and year-to-date completed units.
- Updated the Dashboard component to utilize the new endpoint, enhancing data retrieval efficiency and user experience.
- Added a helper function in the exercises router for programmatic access to exercise listings.
- Updated versioning and changelog to reflect the addition of the dashboard feature.
2026-05-14 07:47:27 +02:00
ebad8025f4 fix(ci): update k6 installation script to support multiple architectures
All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / playwright-tests (push) Successful in 1m30s
- Modified the k6 installation script in the Gitea CI workflow to dynamically select the appropriate binary for linux-amd64 or linux-arm64 based on the system architecture.
- Updated README.md to reflect the changes in architecture handling for k6 installation, providing clearer guidance for users on different platforms.
2026-05-14 07:03:37 +02:00
c7650cac2f feat(ci): integrate k6 health baseline testing into Gitea workflow
Some checks failed
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Failing after 5s
- Added a new job to the Gitea CI workflow to install k6 and run health baseline tests after the health wait period.
- Updated documentation to reflect the automatic execution of k6 in the CI pipeline and clarified local execution instructions.
- Enhanced architecture documentation to indicate the completion of Phase 0 for the pipeline part, with k6 running after each relevant deploy.
2026-05-14 06:56:50 +02:00
4b2848c7c3 feat(docs): add performance baseline documentation and update architecture references
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 57s
- Introduced a new section for the Performance-Baseline in CLAUDE.md and updated HANDOVER.md to include references to the new BASELINE_SNAPSHOT.md.
- Enhanced architecture documentation in README.md to clarify the purpose of the baseline snapshot and its relevance to the refactor roadmap.
- Refactored OrgInboxContext to implement a unified loading logic for join requests and content reports, improving code maintainability and performance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 06:53:37 +02:00
255fa45e90 feat(tests): add E2E test for Dashboard API budget and update documentation
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 57s
- Introduced a new E2E test to validate API call counts for `/api/profiles/me` and `/api/training-units` after reloading the Dashboard, ensuring compliance with refactor phase requirements.
- Updated architecture documentation to include details about the new test and its execution within the CI pipeline.
2026-05-14 06:49:15 +02:00
7043addd15 feat(docs): update architecture documentation references and enhance handover details
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 57s
- Added references to the architecture target image, refactor roadmap, and binding Shinkan rules in CLAUDE.md and HANDOVER.md for better project clarity.
- Updated the Dashboard component to improve user authentication handling and optimize data loading, enhancing overall performance and user experience.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 06:42:13 +02:00
1c268555f6 feat(App): implement code-splitting for improved performance and user experience
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 56s
- Refactored the App component to utilize React's lazy loading for page components, enhancing load times and performance.
- Introduced a fallback UI with a spinner during component loading, improving user feedback during navigation.
- Updated the AuthContext to use useCallback and useMemo for optimized performance in login and logout functions, reducing unnecessary re-renders.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:10:02 +02:00
41 changed files with 2221 additions and 610 deletions

View File

@ -15,6 +15,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) | | 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 | | 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 | | 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_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 | | 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` | | admin_users | `GET /api/admin/users` | Plattform | `require_auth` | Admin-Rolle | EXEMPT `check_access_layer_hints.py` |
@ -37,13 +38,13 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen. **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) ### 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:** 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`. - **2026-05-07 (Phase 3):** CSP SPA (nginx); API `nosniff`-Middleware — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.

View File

@ -88,6 +88,90 @@ jobs:
npm run build npm run build
echo "✓ Frontend build OK" 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: playwright-tests:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -14,6 +14,8 @@
> | Medien-Archiv, Lifecycle, Inline (Plan §11) | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | > | Medien-Archiv, Lifecycle, Inline (Plan §11) | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
> | Handover / nächste Session | **`docs/HANDOVER.md`** | > | Handover / nächste Session | **`docs/HANDOVER.md`** |
> | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.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 ## Projekt-Übersicht

View File

@ -193,7 +193,7 @@ def read_root():
return out return out
# Register routers # 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(auth.router)
app.include_router(profiles.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(media_assets.admin_legal_hold_router)
app.include_router(skills.router) app.include_router(skills.router)
app.include_router(training_planning.router) app.include_router(training_planning.router)
app.include_router(dashboard.router)
app.include_router(training_modules.router) app.include_router(training_modules.router)
app.include_router(training_framework_programs.router) app.include_router(training_framework_programs.router)
app.include_router(catalogs.router) app.include_router(catalogs.router)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import json
import logging import logging
import os import os
import re import re
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple
from urllib.parse import quote from urllib.parse import quote
@ -96,6 +97,7 @@ CASE COALESCE(
WHEN '5' THEN 5 WHEN '5' THEN 5
ELSE NULL END ELSE NULL END
""".strip() """.strip()
# Bei Änderung: Migration 062 idx_exercise_skills_exercise_level_rank (SQL-Ausdruck) synchron halten.
def normalize_exercise_skill_level(value) -> Optional[str]: 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") @router.get("/exercises")
def list_exercises( def list_exercises(
focus_area_ids: list[int] = Query(default=[], description="ODER: mind. einer dieser Fokusbereiche"), 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), limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
cursor_updated_at: Optional[str] = Query(
default=None,
description="Keyset: ISO-8601 von updated_at der letzten Zeile; zusammen mit cursor_id (offset dann 0)",
),
cursor_id: Optional[int] = Query(
default=None,
ge=1,
description="Keyset: id der letzten Zeile (Tiebreak bei gleichem updated_at); mit cursor_updated_at",
),
include_variants: bool = Query( include_variants: bool = Query(
default=False, default=False,
description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI", 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. Liste aller Übungen mit Filtern.
Lightweight Response (ohne M:N Details, nur IDs und Namen). Lightweight Response (ohne M:N Details, nur IDs und Namen).
Optional include_variants für Variantenauswahl in der Trainingsplanung. 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 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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -1981,6 +2023,12 @@ def list_exercises(
where.append("e.search_vector @@ plainto_tsquery('german', %s)") where.append("e.search_vector @@ plainto_tsquery('german', %s)")
params.append(qtext) 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 = "" variants_sql = ""
if include_variants: if include_variants:
variants_sql = """, variants_sql = """,
@ -2046,10 +2094,10 @@ def list_exercises(
LEFT JOIN profiles p ON e.created_by = p.id LEFT JOIN profiles p ON e.created_by = p.id
LEFT JOIN clubs c ON e.club_id = c.id LEFT JOIN clubs c ON e.club_id = c.id
WHERE {' AND '.join(where)} WHERE {' AND '.join(where)}
ORDER BY e.updated_at DESC ORDER BY e.updated_at DESC, e.id DESC
LIMIT %s OFFSET %s LIMIT %s OFFSET %s
""" """
params.extend([limit, offset]) params.extend([limit, 0 if use_keyset else offset])
cur.execute(query, params) cur.execute(query, params)
rows = cur.fetchall() rows = cur.fetchall()
@ -2076,6 +2124,58 @@ def list_exercises(
return out 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}") @router.get("/exercises/{exercise_id}")
def get_exercise( def get_exercise(
exercise_id: int, exercise_id: int,

View File

@ -4,8 +4,8 @@ und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung).
Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin. Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin.
""" """
from datetime import date, timedelta from datetime import date, datetime, time as dt_time, timedelta
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from psycopg2.extras import Json as PsycopgJson from psycopg2.extras import Json as PsycopgJson
@ -42,6 +42,78 @@ def _optional_positive_int(val, field_name: str) -> Optional[int]:
return i 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]): def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id: Optional[int]):
if not exercise_id: if not exercise_id:
if variant_id: if variant_id:
@ -1254,6 +1326,19 @@ def list_training_units(
), ),
sort: str = Query(default="desc"), sort: str = Query(default="desc"),
limit: Optional[int] = Query(default=None), 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), tenant: TenantContext = Depends(get_tenant_context),
): ):
profile_id = tenant.profile_id profile_id = tenant.profile_id
@ -1264,6 +1349,40 @@ def list_training_units(
if gid and cid: if gid and cid:
raise HTTPException(status_code=400, detail="Nur eines der Parameter group_id oder club_id angeben") 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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -1286,17 +1405,6 @@ def list_training_units(
if not (ok_staff or ok_org or ok_member): if not (ok_staff or ok_org or ok_member):
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe") 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 = """ query = """
SELECT tu.*, SELECT tu.*,
tg.name as group_name, tg.name as group_name,
@ -1379,10 +1487,25 @@ def list_training_units(
where.append("tu.status = %s") where.append("tu.status = %s")
params.append(status) 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: if where:
query += " WHERE " + " AND ".join(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: if lim is not None:
query += " LIMIT %s" query += " LIMIT %s"
params.append(lim) params.append(lim)

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.110" APP_VERSION = "0.8.119"
BUILD_DATE = "2026-05-12" BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512057" DB_SCHEMA_VERSION = "20260514062"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -21,10 +21,11 @@ MODULE_VERSIONS = {
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.27.3", # load_combination_slots_for_exercise (gemeinsam mit GET Übung); Hydrate für Planung "exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
"training_units": "0.2.0", "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", "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", "training_modules": "1.0.0",
"import_wiki": "1.0.0", "import_wiki": "1.0.0",
"admin": "1.0.0", "admin": "1.0.0",
@ -35,6 +36,74 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.110",
"date": "2026-05-12", "date": "2026-05-12",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover # Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-12 **Stand:** 2026-05-13
**App-Version / DB-Schema:** App **0.8.110**, DB-Schema **`20260512057`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`) **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**. 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 | | 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-Setup, Domain grob | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
| **Projekt-Status (aktuell)** | `.claude/docs/PROJECT_STATUS.md` | | **Projekt-Status (aktuell)** | `.claude/docs/PROJECT_STATUS.md` |
| **Medien-Archiv, Lifecycle, Inline-Plan (§11)** | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.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`**. - **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`. - **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). - **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§10.2.1** IDs, **§10.4** Coaching-Stufen, **§10.6** Produkt-Backlog, **Anhang A** Abgleich).
- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4ag** — u.a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung). - **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4ag** — u.a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import React from 'react' import React, { Suspense, lazy } from 'react'
import { import {
RouterProvider, RouterProvider,
createBrowserRouter, createBrowserRouter,
@ -12,45 +12,66 @@ import { ToastProvider } from './context/ToastContext'
import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext' import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext'
import DesktopSidebar from './components/DesktopSidebar' import DesktopSidebar from './components/DesktopSidebar'
import { getMainNavItems } from './config/appNav' 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 AdminHomeRedirect from './components/AdminHomeRedirect'
import PlatformAdminRoute from './components/PlatformAdminRoute' 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 ActiveClubSwitcher from './components/ActiveClubSwitcher'
import InactiveMembershipBanner from './components/InactiveMembershipBanner' import InactiveMembershipBanner from './components/InactiveMembershipBanner'
import './app.css' 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. */ /** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
function computeShowAdminNav(currentUser) { function computeShowAdminNav(currentUser) {
return currentUser?.role === 'superadmin' return currentUser?.role === 'superadmin'
} }
function AppRouteFallback() {
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg)',
}}
>
<div className="spinner"></div>
</div>
)
}
// Bottom Navigation (Mobile) // Bottom Navigation (Mobile)
function Nav({ showAdminNav }) { function Nav({ showAdminNav }) {
const { canAccessOrgInbox, inboxCount } = useOrgInbox() const { canAccessOrgInbox, inboxCount } = useOrgInbox()
@ -270,7 +291,9 @@ function App() {
return ( return (
<AuthProvider> <AuthProvider>
<ToastProvider> <ToastProvider>
<Suspense fallback={<AppRouteFallback />}>
<RouterProvider router={appRouter} /> <RouterProvider router={appRouter} />
</Suspense>
</ToastProvider> </ToastProvider>
</AuthProvider> </AuthProvider>
) )

View File

@ -2578,6 +2578,8 @@ a.analysis-split__nav-item {
.exercises-list-grid > .exercise-card { .exercises-list-grid > .exercise-card {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
content-visibility: auto;
contain-intrinsic-size: auto 240px;
} }
.exercise-card-layout { .exercise-card-layout {
display: flex; display: flex;
@ -5412,22 +5414,80 @@ a.analysis-split__nav-item {
0 2px 12px rgba(15, 23, 42, 0.05); 0 2px 12px rgba(15, 23, 42, 0.05);
} }
/* KombinationsStrip: volle Breite unter der Zeile, begrenzte Textbreite — Hauptzeile (Name/Min.) nicht verdrängen */ /* Kombinationszeile: immer unter Hauptzeile (Titel / Minuten / Aktionen), nicht daneben */
.training-unit-sections-editor .tu-item-row--exercise.tu-item-row--combo {
flex-direction: column;
align-items: stretch;
flex-wrap: nowrap;
gap: 0;
}
.training-unit-sections-editor .tu-item-row--exercise.tu-item-row--combo .tu-item-row__mainline {
flex: none;
width: 100%;
}
/* KombinationsStrip: volle Breite; oben „Ablauf bearbeiten“, darunter KlammerVorschau */
.training-unit-sections-editor .tu-combo-planning-strip { .training-unit-sections-editor .tu-combo-planning-strip {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
gap: 10px; 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 { .training-unit-sections-editor--item-drag .tu-item-row--combo .tu-combo-planning-strip {
width: 100%; padding-left: 44px;
max-width: min(100%, 42rem); }
.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; min-width: 0;
overflow-x: auto;
} }
.training-unit-sections-editor .tu-combo-planning-strip > .btn { .training-unit-sections-editor .combo-plan-bracket--planning-embed {
align-self: flex-start; 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 { .tu-planning-mod-tag {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -27,6 +27,29 @@ export function notifyOrgInboxChanged() {
window.dispatchEvent(new Event('shinkan:inbox-changed')) 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 }) { export function OrgInboxProvider({ user, children }) {
const [items, setItems] = useState([]) const [items, setItems] = useState([])
const [contentReports, setContentReports] = useState([]) const [contentReports, setContentReports] = useState([])
@ -35,30 +58,16 @@ export function OrgInboxProvider({ user, children }) {
const canAccessReports = useMemo(() => canSeeContentReports(user), [user]) const canAccessReports = useMemo(() => canSeeContentReports(user), [user])
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!canAccess) { if (!canAccess && !canAccessReports) {
setItems([]) setItems([])
} else {
try {
const data = await api.getInboxJoinRequests()
setItems(Array.isArray(data) ? data : [])
} catch {
setItems([])
}
}
if (!canAccessReports) {
setContentReports([]) setContentReports([])
setContentReportsError(null) setContentReportsError(null)
} else { return
try {
const data = await api.getInboxContentReports()
setContentReports(Array.isArray(data) ? data : [])
setContentReportsError(null)
} catch (err) {
setContentReports([])
setContentReportsError(err?.message || String(err))
}
} }
const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports)
setItems(snap.items)
setContentReports(snap.contentReports)
setContentReportsError(canAccessReports ? snap.contentReportsError : null)
}, [canAccess, canAccessReports]) }, [canAccess, canAccessReports])
useEffect(() => { useEffect(() => {
@ -69,32 +78,43 @@ export function OrgInboxProvider({ user, children }) {
return undefined return undefined
} }
let cancelled = false let cancelled = false
;(async () => { let idleId = null
if (canAccess) { let timeoutId = null
try {
const data = await api.getInboxJoinRequests() const load = async () => {
if (!cancelled) setItems(Array.isArray(data) ? data : []) const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports)
} catch { if (cancelled) return
if (!cancelled) setItems([]) 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 { schedule()
const data = await api.getInboxContentReports()
if (!cancelled) {
setContentReports(Array.isArray(data) ? data : [])
setContentReportsError(null)
}
} catch (err) {
if (!cancelled) {
setContentReports([])
setContentReportsError(err?.message || String(err))
}
}
}
})()
return () => { return () => {
cancelled = true cancelled = true
if (idleId != null && typeof window.cancelIdleCallback === 'function') {
window.cancelIdleCallback(idleId)
}
if (timeoutId != null) window.clearTimeout(timeoutId)
} }
}, [canAccess, canAccessReports, user?.id]) }, [canAccess, canAccessReports, user?.id])

View File

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

View File

@ -2403,6 +2403,17 @@ function ExerciseFormPage() {
} }
/> />
)} )}
{reportTarget && (
<ReportContentModal
targetType="media_asset"
targetId={reportTarget.media_asset_id || reportTarget.id}
targetLabel={reportTarget.title || reportTarget.original_filename || `Medium #${reportTarget.id}`}
onClose={() => setReportTarget(null)}
/>
)}
</div>
)}
<ExercisePickerModal <ExercisePickerModal
open={comboStationPickerIx !== null} open={comboStationPickerIx !== null}
onClose={() => setComboStationPickerIx(null)} onClose={() => setComboStationPickerIx(null)}
@ -2415,16 +2426,6 @@ function ExerciseFormPage() {
setComboStationPickerIx(null) setComboStationPickerIx(null)
}} }}
/> />
{reportTarget && (
<ReportContentModal
targetType="media_asset"
targetId={reportTarget.media_asset_id || reportTarget.id}
targetLabel={reportTarget.title || reportTarget.original_filename || `Medium #${reportTarget.id}`}
onClose={() => setReportTarget(null)}
/>
)}
</div>
)}
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}> <p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
<strong>KI-Ausbaustufe:</strong> Backend laut Spec{' '} <strong>KI-Ausbaustufe:</strong> Backend laut Spec{' '}

View File

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

View File

@ -1348,10 +1348,20 @@ export async function listTrainingUnits(filters = {}) {
if (filters.assigned_to_me === true) q.set('assigned_to_me', 'true') if (filters.assigned_to_me === true) q.set('assigned_to_me', 'true')
if (filters.sort) q.set('sort', String(filters.sort)) if (filters.sort) q.set('sort', String(filters.sort))
if (filters.limit != null && filters.limit !== '') q.set('limit', String(filters.limit)) 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() const qs = q.toString()
return request(`/api/training-units${qs ? `?${qs}` : ''}`) 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. */ /** Dashboard: Übungen in geplanten Einheiten, die für den Verein noch auf Sichtbarkeit „Verein“ gehören. */
export async function getTrainingExerciseClubVisibilityQueue(filters = {}) { export async function getTrainingExerciseClubVisibilityQueue(filters = {}) {
const q = new URLSearchParams() const q = new URLSearchParams()
@ -1601,6 +1611,7 @@ export const api = {
// Training Planning // Training Planning
listTrainingUnits, listTrainingUnits,
getDashboardKpis,
getTrainingExerciseClubVisibilityQueue, getTrainingExerciseClubVisibilityQueue,
getTrainingUnit, getTrainingUnit,
createTrainingUnit, createTrainingUnit,

View File

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

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

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

View File

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

View File

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

View File

@ -59,15 +59,30 @@ test('2. Dashboard lädt ohne Fehler', async ({ page }) => {
test('3. Navigation zu Übungen', async ({ page }) => { test('3. Navigation zu Übungen', async ({ page }) => {
await login(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 // Bei Viewport ≥1024px ist .bottom-nav versteckt — Mobile garantieren wie in playwright.config.js
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
// Desktop-Sidebar enthält ebenfalls Übungen nur Mobile-Bottom-Nav klicken (sichtbarer Link) // Bottom-Nav: Navigation und URL gemeinsam abwarten (vermeidet race mit networkidle)
await page.locator('.bottom-nav a[href="/exercises"]').click(); 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'); await page.waitForLoadState('networkidle');
// Prüfe ob Übungen-Seite geladen // Wie Test 4 (Vereine): eine eindeutige h1 — nicht h1,h2-Kombi (Strict Mode + mehrere Treffer)
await expect(page.locator('h1, h2, .page-title')).toContainText(/übungen/i, { timeout: 5000 }); 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' }); await page.screenshot({ path: 'screenshots/03-uebungen.png' });
console.log('✓ Übungen-Seite erreichbar'); console.log('✓ Übungen-Seite erreichbar');
@ -143,6 +158,68 @@ test('7. Session-Persistenz nach Reload', async ({ page }) => {
console.log('✓ Session bleibt nach Reload erhalten'); 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 }) => { test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 }); await page.setViewportSize({ width: 1280, height: 800 });
await login(page); 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 = []; const errors = [];
page.on('console', msg => { page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text()); if (msg.type() === 'error') errors.push(msg.text());