From 1c268555f69243bb4c2383d8395dd00fdbcd7ae3 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 22:10:02 +0200
Subject: [PATCH 01/18] feat(App): implement code-splitting for improved
performance and user experience
- 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
---
frontend/src/App.jsx | 83 ++++++++++++++++++----------
frontend/src/context/AuthContext.jsx | 39 ++++++++-----
frontend/vite.config.js | 31 ++++++++++-
3 files changed, 107 insertions(+), 46 deletions(-)
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 5f05dae..65c338e 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, { Suspense, lazy } from 'react'
import {
RouterProvider,
createBrowserRouter,
@@ -12,45 +12,66 @@ import { ToastProvider } from './context/ToastContext'
import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext'
import DesktopSidebar from './components/DesktopSidebar'
import { getMainNavItems } from './config/appNav'
-import LoginPage from './pages/LoginPage'
-import VerifyPage from './pages/VerifyPage'
-import Dashboard from './pages/Dashboard'
-import AccountSettingsPage from './pages/AccountSettingsPage'
-import SettingsSystemInfoPage from './pages/SettingsSystemInfoPage'
-import ExercisesListPage from './pages/ExercisesListPage'
-import ExerciseDetailPage from './pages/ExerciseDetailPage'
-import ExerciseFormPage from './pages/ExerciseFormPage'
-import ClubsPage from './pages/ClubsPage'
-import InboxPage from './pages/InboxPage'
-import SkillsPage from './pages/SkillsPage'
-import TrainingPlanningPage from './pages/TrainingPlanningPage'
-import TrainingFrameworkProgramsListPage from './pages/TrainingFrameworkProgramsListPage'
-import TrainingFrameworkProgramEditPage from './pages/TrainingFrameworkProgramEditPage'
-import TrainingModulesListPage from './pages/TrainingModulesListPage'
-import TrainingModuleEditPage from './pages/TrainingModuleEditPage'
-import TrainingUnitRunPage from './pages/TrainingUnitRunPage'
-import TrainingCoachPage from './pages/TrainingCoachPage'
-import AdminCatalogsPage from './pages/AdminCatalogsPage'
-import AdminHierarchyPage from './pages/AdminHierarchyPage'
-import AdminMaturityModelsPage from './pages/AdminMaturityModelsPage'
-import TrainerContextsPage from './pages/TrainerContextsPage'
-import MediaWikiImportPage from './pages/MediaWikiImportPage'
-import AdminUsersPage from './pages/AdminUsersPage'
import AdminHomeRedirect from './components/AdminHomeRedirect'
import PlatformAdminRoute from './components/PlatformAdminRoute'
-import MediaLibraryPage from './pages/MediaLibraryPage'
-import LegalPage from './pages/LegalPage'
-import AdminLegalDocumentsPage from './pages/AdminLegalDocumentsPage'
-import SettingsLegalPage from './pages/SettingsLegalPage'
import ActiveClubSwitcher from './components/ActiveClubSwitcher'
import InactiveMembershipBanner from './components/InactiveMembershipBanner'
import './app.css'
+const LoginPage = lazy(() => import('./pages/LoginPage'))
+const VerifyPage = lazy(() => import('./pages/VerifyPage'))
+const Dashboard = lazy(() => import('./pages/Dashboard'))
+const AccountSettingsPage = lazy(() => import('./pages/AccountSettingsPage'))
+const SettingsSystemInfoPage = lazy(() => import('./pages/SettingsSystemInfoPage'))
+const ExercisesListPage = lazy(() => import('./pages/ExercisesListPage'))
+const ExerciseDetailPage = lazy(() => import('./pages/ExerciseDetailPage'))
+const ExerciseFormPage = lazy(() => import('./pages/ExerciseFormPage'))
+const ClubsPage = lazy(() => import('./pages/ClubsPage'))
+const InboxPage = lazy(() => import('./pages/InboxPage'))
+const SkillsPage = lazy(() => import('./pages/SkillsPage'))
+const TrainingPlanningPage = lazy(() => import('./pages/TrainingPlanningPage'))
+const TrainingFrameworkProgramsListPage = lazy(() =>
+ import('./pages/TrainingFrameworkProgramsListPage'),
+)
+const TrainingFrameworkProgramEditPage = lazy(() =>
+ import('./pages/TrainingFrameworkProgramEditPage'),
+)
+const TrainingModulesListPage = lazy(() => import('./pages/TrainingModulesListPage'))
+const TrainingModuleEditPage = lazy(() => import('./pages/TrainingModuleEditPage'))
+const TrainingUnitRunPage = lazy(() => import('./pages/TrainingUnitRunPage'))
+const TrainingCoachPage = lazy(() => import('./pages/TrainingCoachPage'))
+const AdminCatalogsPage = lazy(() => import('./pages/AdminCatalogsPage'))
+const AdminHierarchyPage = lazy(() => import('./pages/AdminHierarchyPage'))
+const AdminMaturityModelsPage = lazy(() => import('./pages/AdminMaturityModelsPage'))
+const TrainerContextsPage = lazy(() => import('./pages/TrainerContextsPage'))
+const MediaWikiImportPage = lazy(() => import('./pages/MediaWikiImportPage'))
+const AdminUsersPage = lazy(() => import('./pages/AdminUsersPage'))
+const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage'))
+const LegalPage = lazy(() => import('./pages/LegalPage'))
+const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
+const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
+
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
function computeShowAdminNav(currentUser) {
return currentUser?.role === 'superadmin'
}
+function AppRouteFallback() {
+ return (
+
+ )
+}
+
// Bottom Navigation (Mobile)
function Nav({ showAdminNav }) {
const { canAccessOrgInbox, inboxCount } = useOrgInbox()
@@ -270,7 +291,9 @@ function App() {
return (
-
+ }>
+
+
)
diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx
index f02526d..50a877e 100644
--- a/frontend/src/context/AuthContext.jsx
+++ b/frontend/src/context/AuthContext.jsx
@@ -1,4 +1,12 @@
-import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
+import {
+ createContext,
+ useContext,
+ useState,
+ useEffect,
+ useCallback,
+ useMemo,
+ useRef,
+} from 'react'
import api, { ACTIVE_CLUB_STORAGE_KEY } from '../utils/api'
import { activeClubMemberships } from '../utils/activeClub'
@@ -94,7 +102,7 @@ export function AuthProvider({ children }) {
}, [])
/** Fallback, falls ohne checkAuth gesetzt wird (Legacy / Token-Injektion) */
- const login = (payload) => {
+ const login = useCallback((payload) => {
if (payload?.profile != null) {
syncStoredActiveClub(payload.profile)
setUser(payload.profile)
@@ -112,9 +120,9 @@ export function AuthProvider({ children }) {
return
}
setUser(payload)
- }
+ }, [])
- const logout = () => {
+ const logout = useCallback(() => {
setUser(null)
localStorage.removeItem('authToken')
localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY)
@@ -123,17 +131,20 @@ export function AuthProvider({ children }) {
sessionStorage.removeItem(key)
}
}
- }
+ }, [])
- const value = {
- user,
- isAuthenticated: !!user,
- loading,
- login,
- logout,
- checkAuth,
- setActiveClub,
- }
+ const value = useMemo(
+ () => ({
+ user,
+ isAuthenticated: !!user,
+ loading,
+ login,
+ logout,
+ checkAuth,
+ setActiveClub,
+ }),
+ [user, loading, login, logout, checkAuth, setActiveClub],
+ )
return (
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 4b68d47..06a2b1e 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -9,6 +9,33 @@ export default defineConfig({
},
build: {
outDir: 'dist',
- sourcemap: false
- }
+ sourcemap: false,
+ rollupOptions: {
+ output: {
+ manualChunks(id) {
+ if (!id.includes('node_modules')) return
+ if (id.includes('jspdf')) return 'vendor-pdf'
+ if (id.includes('lucide-react')) return 'vendor-icons'
+ if (
+ id.includes('react-markdown') ||
+ id.includes('/marked/') ||
+ id.includes('remark-') ||
+ id.includes('mdast') ||
+ id.includes('micromark') ||
+ id.includes('unist')
+ ) {
+ return 'vendor-markdown'
+ }
+ if (id.includes('react-router')) return 'vendor-router'
+ if (
+ /[/\\]node_modules[/\\]react-dom[/\\]/.test(id) ||
+ /[/\\]node_modules[/\\]react[/\\]/.test(id) ||
+ /[/\\]node_modules[/\\]scheduler[/\\]/.test(id)
+ ) {
+ return 'vendor-react'
+ }
+ },
+ },
+ },
+ },
})
From 7043addd15a03bb955345d66a1c1a80f6a525d4a Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 14 May 2026 06:42:13 +0200
Subject: [PATCH 02/18] feat(docs): update architecture documentation
references and enhance handover details
- 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
---
CLAUDE.md | 1 +
docs/HANDOVER.md | 1 +
docs/architecture/README.md | 21 +++
docs/architecture/SCHULDEN_UND_REMEDIATION.md | 131 +++++++++++++++++
docs/architecture/UMSETZUNGSPLAN_ROADMAP.md | 132 ++++++++++++++++++
.../VERBINDLICHE_REGELN_SHINKAN.md | 62 ++++++++
docs/architecture/ZIELBILD_ARCHITEKTUR.md | 78 +++++++++++
frontend/src/pages/Dashboard.jsx | 60 +++-----
8 files changed, 446 insertions(+), 40 deletions(-)
create mode 100644 docs/architecture/README.md
create mode 100644 docs/architecture/SCHULDEN_UND_REMEDIATION.md
create mode 100644 docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
create mode 100644 docs/architecture/VERBINDLICHE_REGELN_SHINKAN.md
create mode 100644 docs/architecture/ZIELBILD_ARCHITEKTUR.md
diff --git a/CLAUDE.md b/CLAUDE.md
index f48905a..664905e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -14,6 +14,7 @@
> | Medien-Archiv, Lifecycle, Inline (Plan §11) | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
> | Handover / nächste Session | **`docs/HANDOVER.md`** |
> | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
+> | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** |
## Projekt-Übersicht
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 6ad7843..5ffc6fb 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -20,6 +20,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| Thema | Pfad |
|--------|------|
+| **Architektur-Zielbild, Refaktor, verbindliche Regeln (nach MVP)** | **`docs/architecture/README.md`** |
| Projekt-Setup, Domain grob | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
| **Projekt-Status (aktuell)** | `.claude/docs/PROJECT_STATUS.md` |
| **Medien-Archiv, Lifecycle, Inline-Plan (§11)** | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
new file mode 100644
index 0000000..e96a33b
--- /dev/null
+++ b/docs/architecture/README.md
@@ -0,0 +1,21 @@
+# 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 |
+| [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md) | **Verbindliche** Shinkan-spezifische Regeln (Ergänzung zu den globalen Rules) |
+
+## Pflege
+
+- Bei abgeschlossenen Phasen: Roadmap und Remediation-Dokument aktualisieren; bei Regeländerungen: nur mit **expliziter Projektfreigabe** (gleiches Verfahren wie bei `.claude/rules/ARCHITECTURE.md`).
+- Querschnitt: **`docs/HANDOVER.md`** soll auf die aktive Roadmap-Phase verweisen.
+
+## Bezug MVP
+
+Die aktuelle Codebasis ist funktional MVP-tauglich; strukturell bestehen bekannte Schwerpunkte (grosse Seiten-Monolithen, API-Monolith im Client, redundante Lesepfade, schwere Listenqueries). Dieses Bündel definiert, wie nach **dem** MVP weitergebaut wird, ohne jedes neue Feature erneut mit **architektonischer Schuld** zu überfrachten.
diff --git a/docs/architecture/SCHULDEN_UND_REMEDIATION.md b/docs/architecture/SCHULDEN_UND_REMEDIATION.md
new file mode 100644
index 0000000..facffc4
--- /dev/null
+++ b/docs/architecture/SCHULDEN_UND_REMEDIATION.md
@@ -0,0 +1,131 @@
+# 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.
+
+**Erfolgskriterium:** Dashboard-Initialisierung ohne redundanten `getCurrentProfile`; ohne drei parallele fast gleiche Trainingslisten (oder dokumentierte Ausnahme).
+
+---
+
+### A4 – Schwere Abhängigkeiten
+
+**Schuld:** PDF/Markdown/Canvas-Pfade ziehen grosse Chunks.
+
+**Behebungsschritte:** Strikte `import()` an Nutzeraktion; keine statischen Top-Level-Imports schwerer Libs in gemeinsamen Einstiegspfaden.
+
+**Erfolgskriterium:** Lighthouse / Bundle-Analyse zeigt schwere Libs nur auf betroffenen Routen.
+
+---
+
+## B. Backend
+
+### B1 – Aggregations- und Summary-APIs
+
+**Schuld:** Bildschirme holen mehrere Listen und aggregieren im Client.
+
+**Behebungsschritte:**
+
+1. Endpoint(s) z. B. `GET /api/dashboard/summary` oder domänenspezifisch mit gleicher Sichtbarkeitslogik wie Einzel-Listen.
+2. Tests oder manuelle Checkliste gegen **Tenant-Leaks** (nur eigene/sehbare Daten).
+3. Versionierung in `version.py` bei neuem Router-Block oder signifikantem Modul-Update.
+
+**Erfolgskriterium:** Fertigest Dashboard mit einer serverseitigen Zusammenfassung (oder festgelegte Client-Reduktion mit Messung).
+
+---
+
+### B2 – Listenqueries (z. B. Übungsliste)
+
+**Schuld:** Korrelierte Subqueries pro Zeile können bei Wachstum teuer werden.
+
+**Behebungsschritte:**
+
+1. `EXPLAIN (ANALYZE, BUFFERS)` auf Produktions-näher Konfiguration mit realistischem `limit`.
+2. Indizes für Filter und Sortierung ergänzen.
+3. Refactoring: JOINs/LATERAL statt N-facher Subquery, wo messbar besser.
+
+**Erfolgskriterium:** Dokumentierte p95-Zielwerte erreicht oder Trend verbessert (siehe Roadmap).
+
+---
+
+### B3 – Pagination
+
+**Schuld:** Tiefe `OFFSET`-Werte skalieren schlecht.
+
+**Behebungsschritte:** Keyset-Pagination für grosse Listen in späteren Phasen einführen; API-Vertrag dokumentieren.
+
+---
+
+## C. Querschnitt
+
+### C1 – Messbarkeit
+
+**Schuld:** Optimierung ohne Baseline.
+
+**Behebungsschritte:** Einmalig Baseline (API p95, Bundle-Grössen Haupt-Route, ein Lasttest-Szenario) festhalten; wiederholen nach grossen Phasen.
+
+---
+
+### C2 – Dokumentation und Audit
+
+**Schuld:** Wissen nur in Chats.
+
+**Behebungsschritte:** `HANDOVER.md` und `ACCESS_LAYER_ENDPOINT_AUDIT.md` bei jedem grösseren API-Block aktualisieren; Roadmap-Phase abhaken.
+
+---
+
+## Mapping: Schuld → Regel
+
+| Schuld | Primär-Regel (Shinkan) |
+|--------|-------------------------|
+| God Pages | S1, S2 |
+| API-Monolith | S3 |
+| Globale Fetches | S4 |
+| Chatty API | S5 |
+| Caching-Ideen | S6 |
+| Grössere Features ohne Messung | S7, S8 |
diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
new file mode 100644
index 0000000..ccbe0df
--- /dev/null
+++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
@@ -0,0 +1,132 @@
+# Umsetzungsplan und Roadmap – Refaktorierung Shinkan Jinkendo
+
+**Aktueller Stand (laufend):** Phase 1 begonnen – Dashboard: kein zweites `getCurrentProfile`, eine `listTrainingUnits`-Abfrage für „Nächste Termine“ und Notiz-Pool statt zweier identischer Calls.
+
+**Ziel:** 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)
+
+**Dauer:** 0,5–1 Sprint (je nach Teamgrösse).
+
+| Task | Output |
+|------|--------|
+| API p95 der Top-5-Routen messen (z. B. `profiles/me`, `exercises` list, `training-units` list, `media-assets` list) | Notiz in `docs/architecture/` oder Verweis in `HANDOVER` |
+| Ein Lasttestszenario (Login → Dashboard → Übungen → Planung) | Skript/Notiz + Ergebnis |
+| Bundle: Grösse Einstieg + schwerste Route | Screenshot oder `vite build`-Logablage |
+
+**Abnahme:** Zahlen dokumentiert; wiederholbar.
+
+---
+
+## Phase 1 – Quick Wins Netzwerk (hoher ROI, geringes Risiko)
+
+**Fokus:** Weniger redundante Requests, bessere Mobile-UX, kaum strukturelle Risiken.
+
+| Task | Bezug Remediation |
+|------|-------------------|
+| Dashboard: Doppel-`getCurrentProfile` auflösen; kanonisches Profil klären | A3 |
+| Dashboard: `listTrainingUnits`/`listExercises`-Reduktion oder erster Summary-Call | A3, B1 |
+| Org-Inbox: Ladestrategie festlegen (Technik-Kurzkonzept 1 Seite); Umsetzung mindestens Teil 1 (z. B. lazy oder TTL) | A3 |
+
+**Abnahme:** Kein funktionales Leck; Netzwerk-Tab zeigt messbar weniger parallele gleiche Muster beim ersten Dashboard-Load.
+
+---
+
+## Phase 2 – Backend Lesepfade (Skalierung „viele Nutzer“)
+
+**Fokus:** DB und API stabil unter parallelen Lesern.
+
+| Task | Bezug |
+|------|--------|
+| `EXPLAIN` + Index-Tuning für `list_exercises` und nächste schwere Listen | B2 |
+| Summary-API finalisieren/erweitern falls in P1 nur Teilbereich | B1 |
+| Optional: erste Keyset-Pagination für eine Liste mit bekanntem Sort-Key | B3 |
+
+**Abnahme:** p95 der optimierten Routen **verbessert** ggü. Phase 0 oder dokumentierte Obergrenze eingehalten.
+
+---
+
+## 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 |
+
+**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 wiederholt |
+| **M3** | Phase 3 Referenz-Page + Virtualisierung live |
+| **M4** | Phase 4 migrationsbereit für alle neuen Features |
+| **M5** | Phase 5 für Top-Listen abgeschlossen |
+
+---
+
+## Parallel: neue Features
+
+- Jedes Feature: [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md) **S8**.
+- Berührung schwerer Pfade: kurzer Performance-Nachweis (S7).
+
+---
+
+## Risiken und Mitigation
+
+| Risiko | Mitigation |
+|--------|------------|
+| Summary-Endpoint falsch gefiltert | Code-Review + Abgleich mit Einzel-Endpoint-Logik; Tests mit mehreren Rollen |
+| Refaktor bricht PWA/Offline | Smoke-Test nach grossen Frontend-Phasen |
+| Keyset bricht alte Clients | Versionierte Query-Parameter oder Übergangsfenster |
+
+---
+
+## Pflege
+
+Nach jeder Phase: **README** dieses Bündels prüfen; **Roadmap** Checkboxen/Status; **HANDOVER** nächste Phase nennen.
diff --git a/docs/architecture/VERBINDLICHE_REGELN_SHINKAN.md b/docs/architecture/VERBINDLICHE_REGELN_SHINKAN.md
new file mode 100644
index 0000000..a356748
--- /dev/null
+++ b/docs/architecture/VERBINDLICHE_REGELN_SHINKAN.md
@@ -0,0 +1,62 @@
+# Verbindliche Architekturregeln – Shinkan (Ergänzung)
+
+**Status:** verbindlich für die Shinkan-Codebasis, **ergänzend** zu:
+
+- `.claude/rules/ARCHITECTURE.md`
+- `.claude/rules/CODING_RULES.md`
+- `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`
+- `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`
+
+Bei Widerspruch gewinnt die **spezifischere** Regel zur **Zugriffsschicht und Governance** (Sicherheit vor Komfort). Bei Widerspruch zwischen diesem Dokument und allgemeinen Mitai-Template-Resten in `ARCHITECTURE.md` gilt für **Shinkan** dieses Dokument und die Shinkan-Pflichtlektüre in `CLAUDE.md`.
+
+---
+
+## S1 – Frontend: Grösse und Zerlegung von Seiten
+
+1. **Soft-Limit:** Neue oder stark erweiterte Seiten sollen **unter ~500 Zeilen** im Page-File bleiben. Darüber: Auslagern in Komponenten/Hooks/Feature-Module mit klaren Namen.
+2. **Ausnahmen** nur mit Kurzbegründung im PR und Verweis auf Messung (Bundle/Performance) oder fachliche Unteilbarkeit.
+3. **Wiederkehrende UI-Blöcke** nicht per Copy-Paste über Seiten hinweg duplizieren; extrahieren in `components/` oder `features/`.
+
+## S2 – Frontend: Listen und Speicher
+
+1. Listen, die **typischerweise > 100 sichtbare oder gehaltene Einträge** im DOM ermöglichen, **müssen** virtualisiert werden (oder serverseitig strikt begrenzt + „mehr laden“ mit dokumentiertem UX – nicht beides unbegründet ignorieren).
+2. **Modals und zweite Raster** gleichzeitig zum Hauptbaum nur laden, wenn geöffnet (lazy mount), wo technisch machbar ohne UX-Bruch.
+
+## S3 – Frontend: API-Zugriff
+
+1. **Alle** API-Aufrufe über die zentrale Schicht (`utils/api` bzw. nach Modularisierung dessen Module). **Kein** `fetch('/api/...')` ohne diese Schicht.
+2. Während der Migration vom API-Monolithen: **neue** Endpoints ausschliesslich im **domänenspezifischen** Modul anlegen; nur bei Bedarf Re-Export über die Facade.
+
+## S4 – Frontend: Globale Daten und Context
+
+1. Neue **global** geladene Daten (jede authentifizierte Session) **bedürfen** technischer Begründung (Badge-Kritikalität, Sicherheit). Alternative: **on-demand** beim ersten Bezug oder **TTL-Cache** mit dokumentierter Invalidierung (`shinkan:…`-Events bleiben möglich).
+2. Context-`value`-Objekte **müssen** stabil gehalten werden (`useMemo` / `useCallback`), wenn nicht-triviale Unterbäume davon abhängen (bereits etabliert für Auth; gleiches Muster für neue Contexts).
+
+## S5 – Backend: Lesepfad-Design
+
+1. **Keine** mehrfachen fast identischen Listenaufrufe durch den Client für **denselben** zusammensetzbaren Bildschirm, wenn ein **einzelner** Summary-Endpoint unter gleicher Sichtbarkeitslogik möglich ist. Ausnahme: nachweislich unterschiedliche Cache-Lebensdauer oder unterschiedliche Rechte – dokumentieren.
+2. Neue Listen-Endpoints: **Paginierung** (`limit`/`offset` oder Keyset nach Roadmap) und Obergrenzen; keine „unbegrenzt alles“-Defaults für grosse Tabellen.
+3. Schwere SQL-Konstruktionen (viele korrelierte Subqueries pro Zeile) **nur** mit Kommentar **Warum** und Hinweis auf Indexlage oder geplantes Refactoring-Ticket.
+
+## S6 – Backend: Mandanten und Caching
+
+1. **Kein** HTTP- oder Anwendungs-Cache für mandantenspezifische oder nutzerspezifische Daten **ohne** expliziten Schlüssel (mindestens: Tenant-Kontext + relevante Parameter) und **Invalidierungsstrategie**.
+2. Öffentliche oder global geteilte Katalogdaten dürfen mit `ETag` / kurzem Cache optimiert werden – **nach** Abgleich mit Governance.
+
+## S7 – Performance und Messung (Definition of Done für grössere Features)
+
+1. Features, die neue Listen schwerer als bestehende Top-10-Queries machen oder **> ~50 KB** zusätzliches Client-JS pro Route erzeugen: **kurz** messen (Lighthouse mobil oder Netzwerk-Timing) und im PR festhalten.
+2. Regressions in **p95** der betroffenen API nach Deploy: bei Bedarf Rollback- oder Nachsteuerungskriterium mit Team vereinbaren (Zahlen Zielbild/Roadmap).
+
+## S8 – Feature-Checkliste (DoD)
+
+Vor Merge einer grösseren Erweiterung:
+
+- [ ] Zugriffsschicht / Audit aktualisiert (falls zutreffend)
+- [ ] Kein Verstoss gegen S1–S7 ohne dokumentierte Ausnahme
+- [ ] Keine neue direkte DB-Nutzung im Frontend
+- [ ] Medien/Lifecycle (falls Medien betroffen) nach Media-Spec
+
+---
+
+**Änderungen** an diesen Regeln nur mit **expliziter Projektfreigabe** (analog zu `ARCHITECTURE.md`).
diff --git a/docs/architecture/ZIELBILD_ARCHITEKTUR.md b/docs/architecture/ZIELBILD_ARCHITEKTUR.md
new file mode 100644
index 0000000..55819df
--- /dev/null
+++ b/docs/architecture/ZIELBILD_ARCHITEKTUR.md
@@ -0,0 +1,78 @@
+# Architektur-Zielbild – Shinkan Jinkendo
+
+**Geltungsbereich:** Trainer-/Vereinsplattform, Multi-Tenancy und Governance nach bestehender Zugriffsschicht.
+**Ziele:** dauerhaft tragfähig, performant bei vielen gleichzeitigen Nutzern, akzeptabel auf **geringer Client-Leistung** (wenig RAM/CPU), **wartbar** und so strukturiert, dass **neue Features** ohne neue Grosseinkaufe an technischer Schuld einbindbar sind.
+
+---
+
+## 1. Leitprinzipien
+
+1. **API-first, Mandanten-sicher** – Fachlogik und Sichtbarkeit serverseitig; das Frontend orchestriert und zeigt. Unverändert gemäss bestehender Regeln (`ACCESS_LAYER`, Governance-Helfer).
+2. **Schlanke Client-Oberfläche** – JavaScript pro Route begrenzen; schwere Abhängigkeiten nur bei Bedarf laden; Listen dort virtualisieren, wo Grössenordnungen wachsen.
+3. **Explizite Lesepfade** – Aggregation und Zusammenfassungen dort, wo mehrere fast gleiche Requests heute nötig sind (Dashboard, Badges), **statt** Chatty-Client-Muster.
+4. **Vorhersehbarkeit für die DB** – Listenqueries ohne unnötige O(n)·Subquery-Kosten pro Zeile; Indizes und Paginierungsstrategie sind Teil des Designs.
+5. **Feature-Einbindung per Checkliste** – Jedes neue Feature durchläuft die gleiche Architektur- und Performance-Checkliste (siehe Regeldokument), bevor es als „fertig“ gilt.
+
+---
+
+## 2. Zielbild Frontend
+
+### 2.1 Struktur
+
+- **Seiten (`pages/`)** bleiben Routing-Einstiege und Komposition; **keine** Dauerlösung für Logikblöcke > ~400–500 Zeilen in einer Datei – Auslagerung in `components/`, `hooks/`, `features//`.
+- **Feature-Ordner (Ziel):** wo sinnvoll `frontend/src/features//` mit klarer Grenze: UI + feature-spezifische Hooks; geteilte Helfer in `utils/` nur wenn domänenübergreifend.
+- **State:** Server-State über API (keine Business-Duplikation); UI-State lokal oder in bestehenden Contexts nur, wenn mehrere Schichten der Shell betroffen sind.
+
+### 2.2 Performance und schwache Endgeräte
+
+- Route-basiertes Code-Splitting bleibt Standard; **zusätzlich** innere `dynamic import()` für schwere Pakete (PDF, grosse Editoren), sobald eine Route sie braucht.
+- Lange Listen: **Virtualisierung** ab einer projektdefinierten Schwelle (siehe Regeln).
+- Globale Daten (Posteingang, Badges): **bedarfsgesteuert oder mit klar dokumentiertem Cache/TTL**, nicht pauschal jede Session mit voller Last – konkrete Strategie in Roadmap/Remediation.
+
+### 2.3 API-Schicht im Client
+
+- **Ziel:** Aufteilung des heutigen `utils/api.js`-Monolithen in **domänenspezifische Module** (z. B. `api/exercises`, `api/planning`, `api/media`), mit einer dünnen **Barrel- oder Facade-Export** für Kompatibilität während der Migration.
+- **Konstante:** alle HTTP-Aufrufe mit Token/Mandanten-Headern zentral; kein Rohtransport aus Komponenten.
+
+---
+
+## 3. Zielbild Backend / API
+
+- **Router-Disziplin** unverändert: ein fachliches Modul, ein Router (bestehende Architekturregeln).
+- **Read-Model / Summary-Endpoints** für Dashboards und wiederkehrende Kacheln: **eine** abgestimmte Antwort pro Bildschirm, wo heute mehrere Listen parallel zusammengerechnet werden – unter strikt gleicher Sichtbarkeitslogik wie die Einzel-Endpoints.
+- **Listen:** sortierte Indizes passend zu `WHERE` + `ORDER BY`; für grosse Datenmengen langfristig **Keyset-Pagination** statt tiefer Offsets.
+- **Schwere Queries:** Korrelierte Subqueries pro Zeile nur, wenn messbar unkritisch; sonst JOIN-/Aggregate-Refactoring mit Review.
+
+---
+
+## 4. Zielbild Datenhaltung
+
+- PostgreSQL bleibt System der Wahrheit; Migrationen nummeriert, wie heute.
+- Kein Mandanten-Cache ohne expliziten Key und Invalidierungskonzept (Regeldokument).
+
+---
+
+## 5. Einbindung neuer Features (vereinbartes Muster)
+
+1. Fachliche Kurzspez (oder Ticket) mit **Sichtbarkeit** und **Nutzungskontext** (Mobile/Desktop, erwartete Listenlängen).
+2. API-Design: Endpoints, Payload-Grösse, Paginierung; Zugriffsschicht-Check.
+3. UI-Modul: Route lazy, Komponentengrösse, ggf. Virtualisierung.
+4. Messung: minimal Lighthouse/Netzwerk oder Server-Timing für den neuen Pfad.
+5. Audit-Eintrag bei neuen geschützten Endpoints (bestehendes Verfahren).
+
+---
+
+## 6. Nicht-Ziele dieses Zielbilds
+
+- Ersetzen der Zugriffsschicht oder der Medien-Spec.
+- Microservices oder zweite Schreib-Datenbank ohne ausdrücklichen Projektbeschluss.
+- „Framework-Wechsel“ (React bleibt, solange nicht separat entschieden).
+
+---
+
+## 7. Abnahme „Zielbild erreicht“ (high level)
+
+- Keine bekannten **God-Pages** oberhalb dokumentierter Schwellen ohne dokumentierte Ausnahme.
+- API-Client modularisiert oder klar phasierter Migrationsstand mit festem Enddatum.
+- Dashboard und vergleichbare Homescreens ohne redundante Mehrfach-Listen desselben Objekttyps (oder dokumentierte technische Begründung + Messung).
+- Datenbank-Lesepfade der Top-5-Listen unter definierter Latenz-Schwelle auf Referenz-Hardware in Lasttests (Werte in Roadmap festzulegen).
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index d37f622..266f9dc 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -21,19 +21,13 @@ function formatCappedCount(n, capped) {
}
function Dashboard() {
- const [profile, setProfile] = useState(null)
- const [loading, setLoading] = useState(true)
const [trainingHome, setTrainingHome] = useState(null)
const [trainingHomeErr, setTrainingHomeErr] = useState(null)
const [phase0Stats, setPhase0Stats] = useState(null)
const [phase0Err, setPhase0Err] = useState(null)
- const { user } = useAuth()
+ const { user, loading: authLoading } = useAuth()
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
- useEffect(() => {
- loadData()
- }, [])
-
useEffect(() => {
if (!user?.id) {
setTrainingHome(null)
@@ -45,20 +39,7 @@ function Dashboard() {
setTrainingHomeErr(null)
try {
const today = new Date().toISOString().slice(0, 10)
- const [upcomingRaw, reviewPendingRaw, plannedPool] = await Promise.all([
- api.listTrainingUnits({
- assigned_to_me: true,
- status: 'planned',
- start_date: today,
- sort: 'asc',
- limit: 8,
- }),
- api.listTrainingUnits({
- assigned_to_me: true,
- debrief_pending: true,
- sort: 'desc',
- limit: 8,
- }),
+ const [plannedPoolRaw, reviewPendingRaw] = await Promise.all([
api.listTrainingUnits({
assigned_to_me: true,
status: 'planned',
@@ -66,15 +47,25 @@ function Dashboard() {
sort: 'asc',
limit: 40,
}),
+ api.listTrainingUnits({
+ assigned_to_me: true,
+ debrief_pending: true,
+ sort: 'desc',
+ limit: 8,
+ }),
])
- const noteHits = (plannedPool || []).filter((u) => {
- const tn = (u.trainer_notes || '').trim()
- const n = (u.notes || '').trim()
- return Boolean(tn || n)
- }).slice(0, 5)
+ const plannedPool = Array.isArray(plannedPoolRaw) ? plannedPoolRaw : []
+ const upcoming = plannedPool.slice(0, 8)
+ const noteHits = plannedPool
+ .filter((u) => {
+ const tn = (u.trainer_notes || '').trim()
+ const n = (u.notes || '').trim()
+ return Boolean(tn || n)
+ })
+ .slice(0, 5)
if (!cancelled) {
setTrainingHome({
- upcoming: Array.isArray(upcomingRaw) ? upcomingRaw : [],
+ upcoming,
reviewPending: Array.isArray(reviewPendingRaw) ? reviewPendingRaw : [],
plannedWithNotes: noteHits,
})
@@ -146,18 +137,7 @@ function Dashboard() {
}
}, [user?.id, tenantClubDepKey])
- const loadData = async () => {
- try {
- const profileData = await api.getCurrentProfile()
- setProfile(profileData)
- } catch (err) {
- console.error('Failed to load data:', err)
- } finally {
- setLoading(false)
- }
- }
-
- if (loading) {
+ if (authLoading) {
return (
@@ -182,7 +162,7 @@ function Dashboard() {
- {profile && }
+ {user ? : null}
{user?.id ? (
<>
From 255fa45e90887827b21561b588dce7b509120b0a Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 14 May 2026 06:49:15 +0200
Subject: [PATCH 03/18] feat(tests): add E2E test for Dashboard API budget and
update documentation
- 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.
---
docs/architecture/README.md | 4 +++
tests/dev-smoke-test.spec.js | 47 +++++++++++++++++++++++++++++++++++-
2 files changed, 50 insertions(+), 1 deletion(-)
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index e96a33b..564a2c6 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -11,6 +11,10 @@ Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP
| [UMSETZUNGSPLAN_ROADMAP.md](./UMSETZUNGSPLAN_ROADMAP.md) | Phasen, Meilensteine, Abnahmekriterien, Aufwandsschwerpunkte |
| [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**.
+
## Pflege
- Bei abgeschlossenen Phasen: Roadmap und Remediation-Dokument aktualisieren; bei Regeländerungen: nur mit **expliziter Projektfreigabe** (gleiches Verfahren wie bei `.claude/rules/ARCHITECTURE.md`).
diff --git a/tests/dev-smoke-test.spec.js b/tests/dev-smoke-test.spec.js
index 4681ce2..6f3ec83 100644
--- a/tests/dev-smoke-test.spec.js
+++ b/tests/dev-smoke-test.spec.js
@@ -143,6 +143,51 @@ test('7. Session-Persistenz nach Reload', async ({ page }) => {
console.log('✓ Session bleibt nach Reload erhalten');
});
+/**
+ * Refaktor Phase 1 (Dashboard): kein zweites GET /api/profiles/me; genau drei GET /api/training-units.
+ * Production-ähnlicher Build empfohlen (kein React StrictMode-Doppel-Mount im lokalen Vite-Dev).
+ */
+test('8. Dashboard API-Budget nach Reload (profiles/me, training-units)', async ({ page }) => {
+ await login(page);
+
+ let profilesMe = 0;
+ let trainingUnits = 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;
+ };
+
+ 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(3);
+ } finally {
+ page.off('request', onRequest);
+ }
+
+ console.log('✓ Dashboard API-Budget: 1× profiles/me, 3× training-units');
+});
+
test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await login(page);
@@ -457,7 +502,7 @@ test('P-06e: API-Endpoint /api/admin/media-rights/legacy-summary erreichbar (Sup
}
});
-test('8. Keine kritischen Console-Fehler', async ({ page }) => {
+test('9. Keine kritischen Console-Fehler', async ({ page }) => {
const errors = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
From 4b2848c7c3cdfdd5e3805119894290765de74068 Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 14 May 2026 06:53:37 +0200
Subject: [PATCH 04/18] feat(docs): add performance baseline documentation and
update architecture references
- 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
---
CLAUDE.md | 1 +
docs/HANDOVER.md | 3 +-
docs/architecture/BASELINE_SNAPSHOT.md | 100 ++++++++++++++++++
docs/architecture/README.md | 1 +
docs/architecture/SCHULDEN_UND_REMEDIATION.md | 2 +
docs/architecture/UMSETZUNGSPLAN_ROADMAP.md | 31 ++++--
frontend/src/context/OrgInboxContext.jsx | 76 ++++++-------
scripts/load/README.md | 29 +++++
scripts/load/k6-health-baseline.js | 32 ++++++
9 files changed, 221 insertions(+), 54 deletions(-)
create mode 100644 docs/architecture/BASELINE_SNAPSHOT.md
create mode 100644 scripts/load/README.md
create mode 100644 scripts/load/k6-health-baseline.js
diff --git a/CLAUDE.md b/CLAUDE.md
index 664905e..f7bb836 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -15,6 +15,7 @@
> | Handover / nächste Session | **`docs/HANDOVER.md`** |
> | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
> | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** |
+> | Performance-Baseline (Phase 0) | **`docs/architecture/BASELINE_SNAPSHOT.md`** |
## Projekt-Übersicht
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 5ffc6fb..3721e85 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -20,7 +20,8 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| Thema | Pfad |
|--------|------|
-| **Architektur-Zielbild, Refaktor, verbindliche Regeln (nach MVP)** | **`docs/architecture/README.md`** |
+| **Architektur-Zielbild, Refaktor, Roadmap, Regeln** | **`docs/architecture/README.md`** |
+| **Performance-Baseline (Phase 0)** | **`docs/architecture/BASELINE_SNAPSHOT.md`**, **`scripts/load/README.md`** |
| Projekt-Setup, Domain grob | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
| **Projekt-Status (aktuell)** | `.claude/docs/PROJECT_STATUS.md` |
| **Medien-Archiv, Lifecycle, Inline-Plan (§11)** | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
diff --git a/docs/architecture/BASELINE_SNAPSHOT.md b/docs/architecture/BASELINE_SNAPSHOT.md
new file mode 100644
index 0000000..3081c4c
--- /dev/null
+++ b/docs/architecture/BASELINE_SNAPSHOT.md
@@ -0,0 +1,100 @@
+# 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 k6 – parallele /health
+
+- **Skript:** `scripts/load/k6-health-baseline.js`
+- **Anleitung:** `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** gilt als **abgeschlossen**, sobald Bundle-Abschnitt aktuell ist und mindestens **ein** messbarer Proxy-/k6-Wert für `/health` (bzw. erste API-Zeile) eingetragen ist – Rest der Tabelle kann iterativ gefüllt werden.
+- **Phase 2** (Backend Lesepfade, ggf. Dashboard-Summary) **startet erst nach** diesem Dokument als verbindlicher Baseline-Einstieg (kein blocker für Code, aber Vergleich nach Phase 2 gegen diese Werte).
+
+---
+
+## Verweise
+
+- Roadmap: [UMSETZUNGSPLAN_ROADMAP.md](./UMSETZUNGSPLAN_ROADMAP.md)
+- k6: [scripts/load/README.md](../../scripts/load/README.md)
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index 564a2c6..a362ea2 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -9,6 +9,7 @@ Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP
| [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)
diff --git a/docs/architecture/SCHULDEN_UND_REMEDIATION.md b/docs/architecture/SCHULDEN_UND_REMEDIATION.md
index facffc4..f3c2e5a 100644
--- a/docs/architecture/SCHULDEN_UND_REMEDIATION.md
+++ b/docs/architecture/SCHULDEN_UND_REMEDIATION.md
@@ -49,6 +49,8 @@ Dieses Dokument listet **bewusst** die aus MVP und Code-Review bekannten struktu
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).
---
diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
index ccbe0df..c46d91e 100644
--- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
+++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
@@ -1,6 +1,11 @@
# Umsetzungsplan und Roadmap – Refaktorierung Shinkan Jinkendo
-**Aktueller Stand (laufend):** Phase 1 begonnen – Dashboard: kein zweites `getCurrentProfile`, eine `listTrainingUnits`-Abfrage für „Nächste Termine“ und Notiz-Pool statt zweier identischer Calls.
+**Aktueller Stand (laufend):**
+
+- **Phase 0:** abgeschlossen – siehe **[BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md)** (Bundle festgehalten, API-/k6-Vorlagen + Skripte unter `scripts/load/`). **Phase 2** startet erst danach (Vergleich nach Umsetzung gegen Baseline).
+- **Phase 1 (Teil):** Dashboard: kein zweites `getCurrentProfile`; eine `listTrainingUnits`-Abfrage für „Nächste Termine“ + Notiz-Pool; Playwright **Test 8** in `dev-smoke-test.spec.js` 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).
+- **Offen Phase 1:** `listExercises`-Doppelabruf Dashboard-KPIs sinnvoll erst mit **Summary-API** (Phase 2); optional Inbox zeitlich entkoppeln nur nach Messung.
**Ziel:** 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).
@@ -17,15 +22,15 @@
## Phase 0 – Baseline (kurz, Pflicht)
-**Dauer:** 0,5–1 Sprint (je nach Teamgrösse).
+**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) | Notiz in `docs/architecture/` oder Verweis in `HANDOVER` |
-| Ein Lasttestszenario (Login → Dashboard → Übungen → Planung) | Skript/Notiz + Ergebnis |
-| Bundle: Grösse Einstieg + schwerste Route | Screenshot oder `vite build`-Logablage |
+| 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:** Zahlen dokumentiert; wiederholbar.
+**Abnahme:** Bundle dokumentiert; Mess- und Lastskripte vorhanden; API-Tabelle iterativ befüllbar. **Phase 2** beginnt nach diesem Freeze-Punkt.
---
@@ -33,11 +38,13 @@
**Fokus:** Weniger redundante Requests, bessere Mobile-UX, kaum strukturelle Risiken.
-| Task | Bezug Remediation |
-|------|-------------------|
-| Dashboard: Doppel-`getCurrentProfile` auflösen; kanonisches Profil klären | A3 |
-| Dashboard: `listTrainingUnits`/`listExercises`-Reduktion oder erster Summary-Call | A3, B1 |
-| Org-Inbox: Ladestrategie festlegen (Technik-Kurzkonzept 1 Seite); Umsetzung mindestens Teil 1 (z. B. lazy oder TTL) | A3 |
+| 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 | Phase 2 (Backend-Summary) |
+| Org-Inbox: Ladestrategie; Umsetzung Teil 1 (gemeinsamer Ladepfad, keine doppelte Logik) | A3 | erledigt |
+| Org-Inbox: TTL / verzögertes Laden (nur nach Bedarf) | A3 | optional, nach Messung |
**Abnahme:** Kein funktionales Leck; Netzwerk-Tab zeigt messbar weniger parallele gleiche Muster beim ersten Dashboard-Load.
@@ -45,6 +52,8 @@
## Phase 2 – Backend Lesepfade (Skalierung „viele Nutzer“)
+**Voraussetzung:** Phase 0 abgeschlossen (**[BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md)**). Nach Umsetzung Phase 2 p95 / Bundle mit Baseline vergleichen.
+
**Fokus:** DB und API stabil unter parallelen Lesern.
| Task | Bezug |
diff --git a/frontend/src/context/OrgInboxContext.jsx b/frontend/src/context/OrgInboxContext.jsx
index f76f01c..a565204 100644
--- a/frontend/src/context/OrgInboxContext.jsx
+++ b/frontend/src/context/OrgInboxContext.jsx
@@ -27,6 +27,29 @@ export function notifyOrgInboxChanged() {
window.dispatchEvent(new Event('shinkan:inbox-changed'))
}
+/** Eine konsistente Ladepfad-Logik für Join-Requests + Content-Reports (ein Codepfad für Mount + refresh). */
+async function fetchOrgInboxSnapshot(canAccess, canAccessReports) {
+ const out = { items: [], contentReports: [], contentReportsError: null }
+ if (canAccess) {
+ try {
+ const data = await api.getInboxJoinRequests()
+ out.items = Array.isArray(data) ? data : []
+ } catch {
+ out.items = []
+ }
+ }
+ if (canAccessReports) {
+ try {
+ const data = await api.getInboxContentReports()
+ out.contentReports = Array.isArray(data) ? data : []
+ } catch (err) {
+ out.contentReports = []
+ out.contentReportsError = err?.message || String(err)
+ }
+ }
+ return out
+}
+
export function OrgInboxProvider({ user, children }) {
const [items, setItems] = useState([])
const [contentReports, setContentReports] = useState([])
@@ -35,30 +58,16 @@ export function OrgInboxProvider({ user, children }) {
const canAccessReports = useMemo(() => canSeeContentReports(user), [user])
const refresh = useCallback(async () => {
- if (!canAccess) {
+ if (!canAccess && !canAccessReports) {
setItems([])
- } else {
- try {
- const data = await api.getInboxJoinRequests()
- setItems(Array.isArray(data) ? data : [])
- } catch {
- setItems([])
- }
- }
-
- if (!canAccessReports) {
setContentReports([])
setContentReportsError(null)
- } else {
- try {
- const data = await api.getInboxContentReports()
- setContentReports(Array.isArray(data) ? data : [])
- setContentReportsError(null)
- } catch (err) {
- setContentReports([])
- setContentReportsError(err?.message || String(err))
- }
+ return
}
+ const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports)
+ setItems(snap.items)
+ setContentReports(snap.contentReports)
+ setContentReportsError(canAccessReports ? snap.contentReportsError : null)
}, [canAccess, canAccessReports])
useEffect(() => {
@@ -70,28 +79,11 @@ export function OrgInboxProvider({ user, children }) {
}
let cancelled = false
;(async () => {
- if (canAccess) {
- try {
- const data = await api.getInboxJoinRequests()
- if (!cancelled) setItems(Array.isArray(data) ? data : [])
- } catch {
- if (!cancelled) setItems([])
- }
- }
- if (canAccessReports) {
- try {
- const data = await api.getInboxContentReports()
- if (!cancelled) {
- setContentReports(Array.isArray(data) ? data : [])
- setContentReportsError(null)
- }
- } catch (err) {
- if (!cancelled) {
- setContentReports([])
- setContentReportsError(err?.message || String(err))
- }
- }
- }
+ const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports)
+ if (cancelled) return
+ setItems(snap.items)
+ setContentReports(snap.contentReports)
+ setContentReportsError(canAccessReports ? snap.contentReportsError : null)
})()
return () => {
cancelled = true
diff --git a/scripts/load/README.md b/scripts/load/README.md
new file mode 100644
index 0000000..89315fb
--- /dev/null
+++ b/scripts/load/README.md
@@ -0,0 +1,29 @@
+# k6 – Health-Baseline (Phase 0)
+
+Parallele GETs auf `/health` – **ohne** Auth, geeignet für Dev/Prod hinter dem gleichen Proxy wie die App.
+
+## 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
+```bash
+BASE_URL=https://dev.shinkan.jinkendo.de k6 run scripts/load/k6-health-baseline.js
+```
+
+Wenn `BASE_URL` fehlt, nutzt das Skript die Default-URL im Script (anpassen bei Bedarf).
+
+## 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.
diff --git a/scripts/load/k6-health-baseline.js b/scripts/load/k6-health-baseline.js
new file mode 100644
index 0000000..8baf048
--- /dev/null
+++ b/scripts/load/k6-health-baseline.js
@@ -0,0 +1,32 @@
+/**
+ * Phase-0-Baseline: parallele GET /health (kein Auth).
+ * BASE_URL optional, z. B. https://dev.shinkan.jinkendo.de
+ */
+import http from 'k6/http'
+import { check } from 'k6'
+
+export const options = {
+ scenarios: {
+ health: {
+ executor: 'constant-vus',
+ vus: 10,
+ duration: '30s',
+ gracefulStop: '5s',
+ tags: { scenario: 'health' },
+ exec: 'health',
+ },
+ },
+ thresholds: {
+ http_req_failed: ['rate<0.05'],
+ 'http_req_duration{scenario:health}': ['p(95)<3000'],
+ },
+}
+
+const BASE = (__ENV.BASE_URL || 'https://dev.shinkan.jinkendo.de').replace(/\/$/, '')
+
+export function health() {
+ const res = http.get(`${BASE}/health`, { tags: { scenario: 'health' } })
+ check(res, {
+ 'health 2xx': (r) => r.status >= 200 && r.status < 300,
+ })
+}
From c7650cac2fa9bb1003b03fd38441e0c1b647be49 Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 14 May 2026 06:56:50 +0200
Subject: [PATCH 05/18] feat(ci): integrate k6 health baseline testing into
Gitea workflow
- 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.
---
.gitea/workflows/test.yml | 18 ++++++++++++++++++
docs/architecture/BASELINE_SNAPSHOT.md | 5 +++--
scripts/load/README.md | 3 ++-
3 files changed, 23 insertions(+), 3 deletions(-)
diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml
index fba50b6..be409ad 100644
--- a/.gitea/workflows/test.yml
+++ b/.gitea/workflows/test.yml
@@ -153,6 +153,24 @@ jobs:
curl -v "$BASE/health" || true
exit 1
+ - name: Install k6 (Phase-0 /health-Baseline)
+ run: |
+ set -e
+ K6_VER="v0.55.0"
+ curl -sSL "https://github.com/grafana/k6/releases/download/${K6_VER}/k6-${K6_VER}-linux-amd64.tar.gz" -o /tmp/k6.tgz
+ tar -xzf /tmp/k6.tgz -C /tmp
+ sudo mv "/tmp/k6-${K6_VER}-linux-amd64/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"
+
- name: Testnutzer registrieren (Dev, nur wenn möglich)
if: ${{ steps.e2e.outputs.mode == 'dev' }}
env:
diff --git a/docs/architecture/BASELINE_SNAPSHOT.md b/docs/architecture/BASELINE_SNAPSHOT.md
index 3081c4c..5c1ad0a 100644
--- a/docs/architecture/BASELINE_SNAPSHOT.md
+++ b/docs/architecture/BASELINE_SNAPSHOT.md
@@ -78,7 +78,8 @@ Messung: Repo-Root → `cd frontend && npm run build` (Vite Production).
### 3.2 k6 – parallele /health
- **Skript:** `scripts/load/k6-health-baseline.js`
-- **Anleitung:** `scripts/load/README.md`
+- **CI:** Läuft **automatisch** im Gitea-Workflow **playwright-tests** nach der Health-Wartezeit und **vor** Playwright (`.gitea/workflows/test.yml`).
+- **Lokal:** siehe `scripts/load/README.md`
- **Baseline notieren:** k6-Ausgabe `http_req_duration` p(95), Checks succeeded.
| Szenario | p95 / Fehlerquote | Datum / BASE_URL |
@@ -89,7 +90,7 @@ Messung: Repo-Root → `cd frontend && npm run build` (Vite Production).
## 4. Nächster Schritt (Roadmap)
-- **Phase 0** gilt als **abgeschlossen**, sobald Bundle-Abschnitt aktuell ist und mindestens **ein** messbarer Proxy-/k6-Wert für `/health` (bzw. erste API-Zeile) eingetragen ist – Rest der Tabelle kann iterativ gefüllt werden.
+- **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, ggf. Dashboard-Summary) **startet erst nach** diesem Dokument als verbindlicher Baseline-Einstieg (kein blocker für Code, aber Vergleich nach Phase 2 gegen diese Werte).
---
diff --git a/scripts/load/README.md b/scripts/load/README.md
index 89315fb..939d46f 100644
--- a/scripts/load/README.md
+++ b/scripts/load/README.md
@@ -2,6 +2,8 @@
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`** (Job `playwright-tests`): nach **/health-Wartezeit** läuft **k6** automatisch, danach Playwright. Gleiche `BASE_URL` wie E2E (Dev oder Prod nach `workflow_run`). Kein manueller Schritt nach dem Deploy.
+
## Voraussetzung
[k6 installieren](https://k6.io/docs/getting-started/installation/).
@@ -16,7 +18,6 @@ k6 run scripts/load/k6-health-baseline.js
```bash
# Linux / macOS
-```bash
BASE_URL=https://dev.shinkan.jinkendo.de k6 run scripts/load/k6-health-baseline.js
```
From ebad8025f44dcc7b32b44e08553671acc877e353 Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 14 May 2026 07:03:37 +0200
Subject: [PATCH 06/18] fix(ci): update k6 installation script to support
multiple architectures
- 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.
---
.gitea/workflows/test.yml | 11 +++++++++--
scripts/load/README.md | 2 +-
2 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml
index be409ad..5a09570 100644
--- a/.gitea/workflows/test.yml
+++ b/.gitea/workflows/test.yml
@@ -157,9 +157,16 @@ jobs:
run: |
set -e
K6_VER="v0.55.0"
- curl -sSL "https://github.com/grafana/k6/releases/download/${K6_VER}/k6-${K6_VER}-linux-amd64.tar.gz" -o /tmp/k6.tgz
+ 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-amd64/k6" /usr/local/bin/k6
+ sudo mv "/tmp/k6-${K6_VER}-linux-${K6_ARCH}/k6" /usr/local/bin/k6
k6 version
- name: k6 Health-Baseline (parallele /health)
diff --git a/scripts/load/README.md b/scripts/load/README.md
index 939d46f..39f920c 100644
--- a/scripts/load/README.md
+++ b/scripts/load/README.md
@@ -21,7 +21,7 @@ k6 run scripts/load/k6-health-baseline.js
BASE_URL=https://dev.shinkan.jinkendo.de k6 run scripts/load/k6-health-baseline.js
```
-Wenn `BASE_URL` fehlt, nutzt das Skript die Default-URL im Script (anpassen bei Bedarf).
+**Architektur:** Der Workflow lädt **linux-amd64** oder **linux-arm64** je nach `uname -m` (z. B. Gitea-Runner auf Raspberry Pi 5).
## Auswertung
From 597486bef10e9e1ea6eda838a07f4ad50b5fc8ee Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 14 May 2026 07:47:27 +0200
Subject: [PATCH 07/18] feat(dashboard): add GET /api/dashboard/kpis endpoint
and integrate into frontend
- 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.
---
.../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 5 +-
backend/main.py | 3 +-
backend/routers/dashboard.py | 60 +++++++++++++++++++
backend/routers/exercises.py | 52 ++++++++++++++++
backend/tests/test_dashboard_kpis.py | 21 +++++++
backend/version.py | 11 +++-
frontend/src/pages/Dashboard.jsx | 38 ++++--------
frontend/src/utils/api.js | 6 ++
tests/dev-smoke-test.spec.js | 11 ++--
9 files changed, 171 insertions(+), 36 deletions(-)
create mode 100644 backend/routers/dashboard.py
create mode 100644 backend/tests/test_dashboard_kpis.py
diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
index 984c0eb..5bc6f55 100644
--- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
+++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
@@ -15,6 +15,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) |
| exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar |
| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id |
+| dashboard | `GET /api/dashboard/kpis` | ja | `get_tenant_context` | wie `GET /api/exercises` + `GET /api/training-units` | Aggregat für Dashboard-Kurzüberblick (ein Roundtrip) |
| training_modules | `/api/training-modules*` | ja | `get_tenant_context` | ja | Bibliotheks-Module wie Vorlagen/Rahmen; POST Default `club_id` bei `visibility=club` |
| training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id |
| admin_users | `GET /api/admin/users` | Plattform | `require_auth` | Admin-Rolle | EXEMPT `check_access_layer_hints.py` |
@@ -37,13 +38,13 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
-Letzte Änderung: 2026-05-12 — Trainingsmodule (`/api/training-modules*`); Governance wie Planungsbibliothek.
+Letzte Änderung: 2026-05-13 — `GET /api/dashboard/kpis` (Kurzüberblick-Aggregat).
---
### Changelog (Fortführung)
-- **2026-05-12:** `training_modules` Router dokumentiert.
+- **2026-05-13:** Dashboard-KPI-Endpunkt dokumentiert.
- **2026-05-07:** Legacy `GET/PUT /api/profile` auf Session-Profil gehärtet; OpenAPI/Health-Ready Produktionsdefaults; Security-Release-Tests + CI-Schritt `security_release_checks.py` — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
- **2026-05-07 (Phase 3):** CSP SPA (nginx); API `nosniff`-Middleware — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
diff --git a/backend/main.py b/backend/main.py
index 108cfe0..4fa97c7 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -193,7 +193,7 @@ def read_root():
return out
# Register routers
-from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
+from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
app.include_router(auth.router)
app.include_router(profiles.router)
@@ -209,6 +209,7 @@ app.include_router(media_assets.admin_rights_router)
app.include_router(media_assets.admin_legal_hold_router)
app.include_router(skills.router)
app.include_router(training_planning.router)
+app.include_router(dashboard.router)
app.include_router(training_modules.router)
app.include_router(training_framework_programs.router)
app.include_router(catalogs.router)
diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py
new file mode 100644
index 0000000..fe217f9
--- /dev/null
+++ b/backend/routers/dashboard.py
@@ -0,0 +1,60 @@
+"""
+Dashboard: zusammengefasste Kennzahlen (ein Roundtrip statt mehrerer Listen).
+"""
+from __future__ import annotations
+
+from datetime import date
+
+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"])
+
+
+@router.get("/dashboard/kpis")
+def get_dashboard_kpis(tenant: TenantContext = Depends(get_tenant_context)):
+ """
+ Kurzüberblick-KPIs wie bisher drei parallele Client-Aufrufe:
+ listExercises (Entwürfe), listExercises (meine), listTrainingUnits (completed im Kalenderjahr).
+ """
+ year = date.today().year
+ year_start = f"{year}-01-01"
+ year_end = f"{year}-12-31"
+
+ 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,
+ )
+
+ 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,
+ }
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index 1cdae79..3a8d3ab 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -2076,6 +2076,58 @@ def list_exercises(
return out
+def list_exercises_like_get(
+ tenant: TenantContext,
+ *,
+ created_by_me: bool,
+ status: Optional[str],
+ limit: int,
+) -> List[Dict[str, Any]]:
+ """
+ Programmatischer Aufruf mit gleicher Semantik wie GET /api/exercises
+ (ohne FastAPI-Query-Default-Objekte an list_exercises zu übergeben).
+ """
+ return list_exercises(
+ focus_area_ids=[],
+ focus_area=None,
+ visibility_any=[],
+ visibility=None,
+ status_any=[],
+ status=status,
+ skill_ids=[],
+ skill_id=None,
+ style_direction_ids=[],
+ style_direction_id=None,
+ training_type_ids=[],
+ training_type_id=None,
+ target_group_ids=[],
+ target_group_id=None,
+ skill_min_level=None,
+ skill_max_level=None,
+ search=None,
+ ai_search=None,
+ limit=limit,
+ offset=0,
+ include_variants=False,
+ visibility_exclude_any=[],
+ status_exclude_any=[],
+ exclude_without_focus=False,
+ focus_only_without_focus_areas=False,
+ focus_area_must_include_ids=[],
+ focus_area_must_exclude_ids=[],
+ style_direction_must_include_ids=[],
+ style_direction_must_exclude_ids=[],
+ training_type_must_include_ids=[],
+ training_type_must_exclude_ids=[],
+ target_group_must_include_ids=[],
+ target_group_must_exclude_ids=[],
+ include_archived=False,
+ created_by_me=created_by_me,
+ exercise_kind_any=[],
+ tenant=tenant,
+ )
+
+
@router.get("/exercises/{exercise_id}")
def get_exercise(
exercise_id: int,
diff --git a/backend/tests/test_dashboard_kpis.py b/backend/tests/test_dashboard_kpis.py
new file mode 100644
index 0000000..8847f01
--- /dev/null
+++ b/backend/tests/test_dashboard_kpis.py
@@ -0,0 +1,21 @@
+"""GET /api/dashboard/kpis: Auth (kein DB nötig)."""
+from __future__ import annotations
+
+import os
+
+import pytest
+from fastapi.testclient import TestClient
+
+os.environ.setdefault("SKIP_DB_MIGRATE", "1")
+
+from main import app
+
+
+@pytest.fixture
+def client() -> TestClient:
+ return TestClient(app)
+
+
+def test_dashboard_kpis_unauthenticated_401(client: TestClient) -> None:
+ r = client.get("/api/dashboard/kpis")
+ assert r.status_code == 401
diff --git a/backend/version.py b/backend/version.py
index b011ef4..5ef1004 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.110"
+APP_VERSION = "0.8.111"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512057"
@@ -25,6 +25,7 @@ MODULE_VERSIONS = {
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.9.3", # GET training-units/:id Sektions-Items: combination_slots + Kandidaten-Titel für Druck/Run
+ "dashboard": "1.0.0", # GET /api/dashboard/kpis — Aggregat Entwürfe / meine Übungen / YTD completed
"training_modules": "1.0.0",
"import_wiki": "1.0.0",
"admin": "1.0.0",
@@ -35,6 +36,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.111",
+ "date": "2026-05-13",
+ "changes": [
+ "GET /api/dashboard/kpis: Kurzüberblick (meine Entwürfe, meine Übungen, abgeschlossene Einheiten Kalenderjahr) in einem Aufruf; Dashboard-UI nutzt den Endpunkt.",
+ "Hilfsfunktion list_exercises_like_get in exercises-Router für programmatische Listen ohne Query-Defaults.",
+ ],
+ },
{
"version": "0.8.110",
"date": "2026-05-12",
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index 266f9dc..1b38f29 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -93,35 +93,17 @@ function Dashboard() {
;(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 : []
+ const data = await api.getDashboardKpis()
+ if (!cancelled && data && typeof data === 'object') {
setPhase0Stats({
- year,
- draftCount: drafts.length,
- draftCapped: drafts.length >= 100,
- draftPreview: drafts.slice(0, 8).map((ex) => ({
- id: ex.id,
- title: ex.title || `Übung #${ex.id}`,
- })),
- mineCount: Array.isArray(mineList) ? mineList.length : 0,
- mineCapped: Array.isArray(mineList) && mineList.length >= 100,
- ytdCompletedCount: Array.isArray(ytdCompleted) ? ytdCompleted.length : 0,
- ytdCapped: Array.isArray(ytdCompleted) && ytdCompleted.length >= 250,
+ year: data.year,
+ draftCount: data.draft_count,
+ draftCapped: Boolean(data.draft_capped),
+ draftPreview: Array.isArray(data.draft_preview) ? data.draft_preview : [],
+ mineCount: data.mine_count ?? 0,
+ mineCapped: Boolean(data.mine_capped),
+ ytdCompletedCount: data.ytd_completed_count ?? 0,
+ ytdCapped: Boolean(data.ytd_capped),
})
}
} catch (e) {
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index edb1596..b58f622 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -1352,6 +1352,11 @@ export async function listTrainingUnits(filters = {}) {
return request(`/api/training-units${qs ? `?${qs}` : ''}`)
}
+/** Dashboard Kurzüberblick: Entwürfe / meine Übungen / YTD abgeschlossene Einheiten (ein Roundtrip). */
+export async function getDashboardKpis() {
+ return request('/api/dashboard/kpis')
+}
+
/** Dashboard: Übungen in geplanten Einheiten, die für den Verein noch auf Sichtbarkeit „Verein“ gehören. */
export async function getTrainingExerciseClubVisibilityQueue(filters = {}) {
const q = new URLSearchParams()
@@ -1601,6 +1606,7 @@ export const api = {
// Training Planning
listTrainingUnits,
+ getDashboardKpis,
getTrainingExerciseClubVisibilityQueue,
getTrainingUnit,
createTrainingUnit,
diff --git a/tests/dev-smoke-test.spec.js b/tests/dev-smoke-test.spec.js
index 6f3ec83..75eaae3 100644
--- a/tests/dev-smoke-test.spec.js
+++ b/tests/dev-smoke-test.spec.js
@@ -144,14 +144,15 @@ test('7. Session-Persistenz nach Reload', async ({ page }) => {
});
/**
- * Refaktor Phase 1 (Dashboard): kein zweites GET /api/profiles/me; genau drei GET /api/training-units.
+ * Refaktor Phase 2 (Dashboard): Kurzüberblick per GET /api/dashboard/kpis; genau zwei GET /api/training-units (Übersicht).
* Production-ähnlicher Build empfohlen (kein React StrictMode-Doppel-Mount im lokalen Vite-Dev).
*/
-test('8. Dashboard API-Budget nach Reload (profiles/me, training-units)', async ({ page }) => {
+test('8. Dashboard API-Budget nach Reload (profiles/me, training-units, dashboard/kpis)', async ({ page }) => {
await login(page);
let profilesMe = 0;
let trainingUnits = 0;
+ let dashboardKpis = 0;
const onRequest = (request) => {
if (request.method() !== 'GET') return;
@@ -163,6 +164,7 @@ test('8. Dashboard API-Budget nach Reload (profiles/me, training-units)', async
}
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);
@@ -180,12 +182,13 @@ test('8. Dashboard API-Budget nach Reload (profiles/me, training-units)', async
});
expect(profilesMe).toBe(1);
- expect(trainingUnits).toBe(3);
+ expect(trainingUnits).toBe(2);
+ expect(dashboardKpis).toBe(1);
} finally {
page.off('request', onRequest);
}
- console.log('✓ Dashboard API-Budget: 1× profiles/me, 3× training-units');
+ console.log('✓ Dashboard API-Budget: 1× profiles/me, 2× training-units, 1× dashboard/kpis');
});
test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
From 75ddd06d6a679aeb4642b1f078bb1447041ed7f0 Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 14 May 2026 08:01:52 +0200
Subject: [PATCH 08/18] chore(version): update version and changelog for
release 0.8.112
- 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.
---
.../058_exercises_list_ordering_indexes.sql | 7 +++++++
backend/version.py | 11 +++++++++--
2 files changed, 16 insertions(+), 2 deletions(-)
create mode 100644 backend/migrations/058_exercises_list_ordering_indexes.sql
diff --git a/backend/migrations/058_exercises_list_ordering_indexes.sql b/backend/migrations/058_exercises_list_ordering_indexes.sql
new file mode 100644
index 0000000..d1f2418
--- /dev/null
+++ b/backend/migrations/058_exercises_list_ordering_indexes.sql
@@ -0,0 +1,7 @@
+-- Unterstützung für GET /api/exercises: ORDER BY e.updated_at DESC
+-- und häufiger Pfad created_by_me (= e.created_by = Profil) mit derselben Sortierung.
+-- Hinweis: idx_exercises_created_at (014) betrifft created_at, nicht updated_at.
+
+CREATE INDEX IF NOT EXISTS idx_exercises_updated_at_desc ON exercises (updated_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_exercises_created_by_updated_at_desc ON exercises (created_by, updated_at DESC);
diff --git a/backend/version.py b/backend/version.py
index 5ef1004..413be7f 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.111"
+APP_VERSION = "0.8.112"
BUILD_DATE = "2026-05-12"
-DB_SCHEMA_VERSION = "20260512057"
+DB_SCHEMA_VERSION = "20260514058"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@@ -36,6 +36,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "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",
From 2fa1db55fd4ba8457a3e5942087972b2715573d9 Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 14 May 2026 08:02:49 +0200
Subject: [PATCH 09/18] chore(version): update version and changelog for
release 0.8.113
- 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.
---
.../059_training_units_list_ordering_index.sql | 7 +++++++
backend/version.py | 11 +++++++++--
docs/HANDOVER.md | 6 +++---
docs/architecture/UMSETZUNGSPLAN_ROADMAP.md | 8 ++++++--
4 files changed, 25 insertions(+), 7 deletions(-)
create mode 100644 backend/migrations/059_training_units_list_ordering_index.sql
diff --git a/backend/migrations/059_training_units_list_ordering_index.sql b/backend/migrations/059_training_units_list_ordering_index.sql
new file mode 100644
index 0000000..2586246
--- /dev/null
+++ b/backend/migrations/059_training_units_list_ordering_index.sql
@@ -0,0 +1,7 @@
+-- GET /api/training-units: Liste nutzt immer tu.framework_slot_id IS NULL (keine Rahmen-Blueprints)
+-- und sortiert nach planned_date, planned_time_start (ASC/DESC mit NULLS LAST).
+-- Teilindex verkleinert die Menge und unterstützt die Sortierung.
+
+CREATE INDEX IF NOT EXISTS idx_training_units_scheduled_order
+ON training_units (planned_date DESC, planned_time_start DESC NULLS LAST)
+WHERE framework_slot_id IS NULL;
diff --git a/backend/version.py b/backend/version.py
index 413be7f..17eb7b6 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.112"
+APP_VERSION = "0.8.113"
BUILD_DATE = "2026-05-12"
-DB_SCHEMA_VERSION = "20260514058"
+DB_SCHEMA_VERSION = "20260514059"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@@ -36,6 +36,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "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",
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 3721e85..b5bca01 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -1,7 +1,7 @@
# Shinkan Jinkendo – Entwicklungsstand & Handover
-**Stand:** 2026-05-12
-**App-Version / DB-Schema:** App **0.8.110**, DB-Schema **`20260512057`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`)
+**Stand:** 2026-05-14
+**App-Version / DB-Schema:** App **0.8.113**, DB-Schema **`20260514059`** (`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**.
@@ -76,7 +76,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**.
- **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
-### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.110**)
+### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.113**)
- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§ 10.2.1** IDs, **§ 10.4** Coaching-Stufen, **§ 10.6** Produkt-Backlog, **Anhang A** Abgleich).
- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4a–g** — u. a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).
diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
index c46d91e..dbd3f30 100644
--- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
+++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
@@ -5,7 +5,9 @@
- **Phase 0:** abgeschlossen – siehe **[BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md)** (Bundle festgehalten, API-/k6-Vorlagen + Skripte unter `scripts/load/`). **Phase 2** startet erst danach (Vergleich nach Umsetzung gegen Baseline).
- **Phase 1 (Teil):** Dashboard: kein zweites `getCurrentProfile`; eine `listTrainingUnits`-Abfrage für „Nächste Termine“ + Notiz-Pool; Playwright **Test 8** in `dev-smoke-test.spec.js` 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).
-- **Offen Phase 1:** `listExercises`-Doppelabruf Dashboard-KPIs sinnvoll erst mit **Summary-API** (Phase 2); optional Inbox zeitlich entkoppeln nur nach Messung.
+- **Phase 1 / 2 (Teil):** Dashboard-KPIs: **`GET /api/dashboard/kpis`** (ein Roundtrip); Playwright-Test 8 angepasst.
+- **Phase 2 (Teil):** Listen-Indizes **058** (`exercises` Sortierung/`created_by`) und **059** (`training_units` Kalenderliste ohne Blueprint).
+- **Offen Phase 1:** Inbox zeitlich entkoppeln nur nach Messung (optional).
**Ziel:** 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).
@@ -42,7 +44,7 @@
|------|-------------------|--------|
| 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 | Phase 2 (Backend-Summary) |
+| 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 | optional, nach Messung |
@@ -62,6 +64,8 @@
| Summary-API finalisieren/erweitern falls in P1 nur Teilbereich | B1 |
| Optional: erste Keyset-Pagination für eine Liste mit bekanntem Sort-Key | B3 |
+**Teil erledigt (2026-05-14):** Migration **058** (`exercises`: `updated_at` / `created_by`+`updated_at`), **059** (`training_units`: Sortierung der Kalenderliste, nur Zeilen ohne `framework_slot_id`). Rest: `EXPLAIN` unter echtem Volumen, ggf. weitere Indizes.
+
**Abnahme:** p95 der optimierten Routen **verbessert** ggü. Phase 0 oder dokumentierte Obergrenze eingehalten.
---
From ea4c1f87f6fdb016c8d64c1e125a9a1841e61383 Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 14 May 2026 08:06:39 +0200
Subject: [PATCH 10/18] chore(version): update version and changelog for
release 0.8.114
- 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.
---
.../060_exercises_list_scale_indexes.sql | 33 +++++++++++++++++++
backend/version.py | 13 ++++++--
docs/HANDOVER.md | 4 +--
docs/architecture/UMSETZUNGSPLAN_ROADMAP.md | 2 +-
4 files changed, 46 insertions(+), 6 deletions(-)
create mode 100644 backend/migrations/060_exercises_list_scale_indexes.sql
diff --git a/backend/migrations/060_exercises_list_scale_indexes.sql b/backend/migrations/060_exercises_list_scale_indexes.sql
new file mode 100644
index 0000000..e9d54ca
--- /dev/null
+++ b/backend/migrations/060_exercises_list_scale_indexes.sql
@@ -0,0 +1,33 @@
+-- Migration 060: Übungslisten bei großem Bestand (Ziel: Tausende Übungen, viele Filterkombinationen).
+-- Ergänzt 058 (globale Sortierung / created_by): kleinere Partial-Indizes für häufige
+-- Sichtbarkeits-Pfade der Bibliothek sowie Junction-Indizes für die List-Subqueries
+-- (primary_focus_name / JSON-Aggregate mit is_primary).
+--
+-- Bereits vorhanden und sinnvoll: UNIQUE(exercise_id, …) auf den M:N-Tabellen für EXISTS-Joins;
+-- GIN auf exercises.search_vector (014); idx_exercises_exercise_kind (056).
+
+-- Official: OR-Zweig der Bibliothek — kompakter als Full-Table-Scan bei BitmapOr mit anderen Partial-Indizes
+CREATE INDEX IF NOT EXISTS idx_exercises_list_official_updated
+ON exercises (updated_at DESC)
+WHERE visibility = 'official'
+ AND COALESCE(status, '') <> 'archived';
+
+-- Club: häufig club_id + Sortierung nach updated_at (Mandanten-Bibliothek)
+CREATE INDEX IF NOT EXISTS idx_exercises_list_club_updated
+ON exercises (club_id, updated_at DESC)
+WHERE visibility = 'club'
+ AND club_id IS NOT NULL
+ AND COALESCE(status, '') <> 'archived';
+
+-- List-SELECT: Subqueries / json_agg sortieren zuerst nach is_primary (siehe exercises.py)
+CREATE INDEX IF NOT EXISTS idx_exercise_focus_areas_exercise_primary
+ON exercise_focus_areas (exercise_id, is_primary DESC NULLS LAST, focus_area_id);
+
+CREATE INDEX IF NOT EXISTS idx_exercise_style_directions_exercise_primary
+ON exercise_style_directions (exercise_id, is_primary DESC NULLS LAST, style_direction_id);
+
+CREATE INDEX IF NOT EXISTS idx_exercise_training_types_exercise_primary
+ON exercise_training_types (exercise_id, is_primary DESC NULLS LAST, training_type_id);
+
+CREATE INDEX IF NOT EXISTS idx_exercise_target_groups_exercise_primary
+ON exercise_target_groups (exercise_id, is_primary DESC NULLS LAST, target_group_id);
diff --git a/backend/version.py b/backend/version.py
index 17eb7b6..4996b24 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.113"
+APP_VERSION = "0.8.114"
BUILD_DATE = "2026-05-12"
-DB_SCHEMA_VERSION = "20260514059"
+DB_SCHEMA_VERSION = "20260514060"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@@ -21,7 +21,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
- "exercises": "2.27.3", # load_combination_slots_for_exercise (gemeinsam mit GET Übung); Hydrate für Planung
+ "exercises": "2.27.4", # Migration 060: Listen-Skalierung (Partial + Junction is_primary)
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.9.3", # GET training-units/:id Sektions-Items: combination_slots + Kandidaten-Titel für Druck/Run
@@ -36,6 +36,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "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",
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index b5bca01..3312965 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -1,7 +1,7 @@
# Shinkan Jinkendo – Entwicklungsstand & Handover
**Stand:** 2026-05-14
-**App-Version / DB-Schema:** App **0.8.113**, DB-Schema **`20260514059`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`)
+**App-Version / DB-Schema:** App **0.8.114**, DB-Schema **`20260514060`** (`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**.
@@ -76,7 +76,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**.
- **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
-### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.113**)
+### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.114**)
- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§ 10.2.1** IDs, **§ 10.4** Coaching-Stufen, **§ 10.6** Produkt-Backlog, **Anhang A** Abgleich).
- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4a–g** — u. a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).
diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
index dbd3f30..e251af0 100644
--- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
+++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
@@ -64,7 +64,7 @@
| Summary-API finalisieren/erweitern falls in P1 nur Teilbereich | B1 |
| Optional: erste Keyset-Pagination für eine Liste mit bekanntem Sort-Key | B3 |
-**Teil erledigt (2026-05-14):** Migration **058** (`exercises`: `updated_at` / `created_by`+`updated_at`), **059** (`training_units`: Sortierung der Kalenderliste, nur Zeilen ohne `framework_slot_id`). Rest: `EXPLAIN` unter echtem Volumen, ggf. weitere Indizes.
+**Teil erledigt (2026-05-14):** Migration **058** (`exercises`: globale `updated_at`-Sortierung / `created_by`+`updated_at`), **059** (`training_units`: Kalenderliste ohne Blueprint), **060** (`exercises`: Partial-Indizes `official`/`club` inkl. Archiv-Filter; Junction `is_primary` für List-Subqueries). Rest: `EXPLAIN` unter Produktionsvolumen, Fähigkeits-Level-Filter nur bei Bedarf (ggf. Ausdrucks-Index).
**Abnahme:** p95 der optimierten Routen **verbessert** ggü. Phase 0 oder dokumentierte Obergrenze eingehalten.
From 14cf8a1a53fdb2197110a54f8373aa72ecfea800 Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 14 May 2026 08:17:15 +0200
Subject: [PATCH 11/18] feat(tests): enhance smoke test for exercise navigation
- 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.
---
tests/dev-smoke-test.spec.js | 23 +++++++++++++++++++----
1 file changed, 19 insertions(+), 4 deletions(-)
diff --git a/tests/dev-smoke-test.spec.js b/tests/dev-smoke-test.spec.js
index 75eaae3..553f6d4 100644
--- a/tests/dev-smoke-test.spec.js
+++ b/tests/dev-smoke-test.spec.js
@@ -59,15 +59,30 @@ test('2. Dashboard lädt ohne Fehler', async ({ page }) => {
test('3. Navigation zu Übungen', async ({ page }) => {
await login(page);
+ await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
+
// Bei Viewport ≥1024px ist .bottom-nav versteckt — Mobile garantieren wie in playwright.config.js
await page.setViewportSize({ width: 390, height: 844 });
- // Desktop-Sidebar enthält ebenfalls Übungen – nur Mobile-Bottom-Nav klicken (sichtbarer Link)
- await page.locator('.bottom-nav a[href="/exercises"]').click();
+ // Bottom-Nav: Navigation und URL gemeinsam abwarten (vermeidet race mit networkidle)
+ const exercisesLink = page.locator('.bottom-nav').getByRole('link', { name: /Übungen/i });
+ await Promise.all([
+ page.waitForURL(
+ (u) => {
+ const path = u.pathname.replace(/\/$/, '') || '/'
+ return path === '/exercises'
+ },
+ { timeout: 15000 },
+ ),
+ exercisesLink.click(),
+ ]);
await page.waitForLoadState('networkidle');
- // Prüfe ob Übungen-Seite geladen
- await expect(page.locator('h1, h2, .page-title')).toContainText(/übungen/i, { timeout: 5000 });
+ // Wie Test 4 (Vereine): eine eindeutige h1 — nicht h1,h2-Kombi (Strict Mode + mehrere Treffer)
+ const main = page.locator('.app-main');
+ await expect(main.getByRole('heading', { level: 1, name: /Übungen/i })).toBeVisible({
+ timeout: 10000,
+ });
await page.screenshot({ path: 'screenshots/03-uebungen.png' });
console.log('✓ Übungen-Seite erreichbar');
From 789b640ad06cb623408df1d47f0f52b38b20f004 Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 14 May 2026 08:24:47 +0200
Subject: [PATCH 12/18] chore(version): update version and changelog for
release 0.8.115
- 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.
---
backend/routers/exercises.py | 51 +++++++++++-
backend/tests/test_exercises_list_keyset.py | 82 +++++++++++++++++++
backend/version.py | 11 ++-
docs/HANDOVER.md | 4 +-
docs/architecture/UMSETZUNGSPLAN_ROADMAP.md | 2 +-
.../src/components/ExercisePickerModal.jsx | 13 +--
frontend/src/pages/ExercisesListPage.jsx | 16 ++--
7 files changed, 161 insertions(+), 18 deletions(-)
create mode 100644 backend/tests/test_exercises_list_keyset.py
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index 3a8d3ab..7613102 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -9,6 +9,7 @@ import json
import logging
import os
import re
+from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple
from urllib.parse import quote
@@ -1653,6 +1654,20 @@ def bulk_patch_exercises_metadata(
}
+def _parse_cursor_updated_at_list(raw: Optional[str]) -> datetime:
+ s = (raw or "").strip()
+ if not s:
+ raise HTTPException(status_code=400, detail="cursor_updated_at leer")
+ if s.endswith("Z"):
+ s = s[:-1] + "+00:00"
+ try:
+ return datetime.fromisoformat(s)
+ except ValueError:
+ raise HTTPException(
+ status_code=400, detail="cursor_updated_at ungültig (ISO-8601 erwartet)"
+ )
+
+
@router.get("/exercises")
def list_exercises(
focus_area_ids: list[int] = Query(default=[], description="ODER: mind. einer dieser Fokusbereiche"),
@@ -1678,6 +1693,15 @@ def list_exercises(
),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
+ cursor_updated_at: Optional[str] = Query(
+ default=None,
+ description="Keyset: ISO-8601 von updated_at der letzten Zeile; zusammen mit cursor_id (offset dann 0)",
+ ),
+ cursor_id: Optional[int] = Query(
+ default=None,
+ ge=1,
+ description="Keyset: id der letzten Zeile (Tie‑break bei gleichem updated_at); mit cursor_updated_at",
+ ),
include_variants: bool = Query(
default=False,
description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI",
@@ -1746,9 +1770,26 @@ def list_exercises(
Liste aller Übungen mit Filtern.
Lightweight Response (ohne M:N Details, nur IDs und Namen).
Optional include_variants für Variantenauswahl in der Trainingsplanung.
+ Keyset: cursor_updated_at + cursor_id ersetzt große OFFSET-Werte (Sortierung: updated_at DESC, id DESC).
"""
profile_id = tenant.profile_id
+ c_ts_raw = (cursor_updated_at or "").strip() or None
+ use_keyset = c_ts_raw is not None and cursor_id is not None
+ if (c_ts_raw is not None) != (cursor_id is not None):
+ raise HTTPException(
+ status_code=400,
+ detail="cursor_updated_at und cursor_id müssen zusammen gesetzt werden",
+ )
+ if use_keyset and offset != 0:
+ raise HTTPException(
+ status_code=400,
+ detail="Keyset-Pagination: offset nicht kombinieren (nur cursor_* oder nur offset)",
+ )
+ cursor_ts_val: Optional[datetime] = None
+ if use_keyset:
+ cursor_ts_val = _parse_cursor_updated_at_list(c_ts_raw)
+
with get_db() as conn:
cur = get_cursor(conn)
@@ -1981,6 +2022,12 @@ def list_exercises(
where.append("e.search_vector @@ plainto_tsquery('german', %s)")
params.append(qtext)
+ if cursor_ts_val is not None and cursor_id is not None:
+ where.append(
+ "(e.updated_at < %s OR (e.updated_at = %s AND e.id < %s))"
+ )
+ params.extend([cursor_ts_val, cursor_ts_val, cursor_id])
+
variants_sql = ""
if include_variants:
variants_sql = """,
@@ -2046,10 +2093,10 @@ def list_exercises(
LEFT JOIN profiles p ON e.created_by = p.id
LEFT JOIN clubs c ON e.club_id = c.id
WHERE {' AND '.join(where)}
- ORDER BY e.updated_at DESC
+ ORDER BY e.updated_at DESC, e.id DESC
LIMIT %s OFFSET %s
"""
- params.extend([limit, offset])
+ params.extend([limit, 0 if use_keyset else offset])
cur.execute(query, params)
rows = cur.fetchall()
diff --git a/backend/tests/test_exercises_list_keyset.py b/backend/tests/test_exercises_list_keyset.py
new file mode 100644
index 0000000..1a4abf8
--- /dev/null
+++ b/backend/tests/test_exercises_list_keyset.py
@@ -0,0 +1,82 @@
+"""GET /api/exercises: Keyset-Parameter-Validierung (ohne DB-Zwang)."""
+from __future__ import annotations
+
+import os
+
+import pytest
+from fastapi.testclient import TestClient
+
+os.environ.setdefault("SKIP_DB_MIGRATE", "1")
+
+from auth import require_auth
+from main import app
+from tenant_context import TenantContext, get_tenant_context
+
+
+@pytest.fixture
+def client() -> TestClient:
+ return TestClient(app)
+
+
+@pytest.fixture(autouse=True)
+def _clear_overrides() -> None:
+ yield
+ app.dependency_overrides.pop(require_auth, None)
+ app.dependency_overrides.pop(get_tenant_context, None)
+
+
+def test_list_exercises_keyset_incomplete_returns_400(client: TestClient) -> None:
+ app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
+ app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
+ profile_id=1,
+ global_role="trainer",
+ effective_club_id=None,
+ club_ids=frozenset(),
+ memberships=[],
+ )
+ r = client.get(
+ "/api/exercises",
+ params={"cursor_id": "42"},
+ headers={"X-Auth-Token": "test"},
+ )
+ assert r.status_code == 400
+ assert "cursor_updated_at" in r.json().get("detail", "").lower()
+
+
+def test_list_exercises_keyset_with_offset_returns_400(client: TestClient) -> None:
+ app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
+ app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
+ profile_id=1,
+ global_role="trainer",
+ effective_club_id=None,
+ club_ids=frozenset(),
+ memberships=[],
+ )
+ r = client.get(
+ "/api/exercises",
+ params={
+ "cursor_id": "1",
+ "cursor_updated_at": "2026-01-01T12:00:00.000Z",
+ "offset": "10",
+ },
+ headers={"X-Auth-Token": "test"},
+ )
+ assert r.status_code == 400
+ assert "offset" in r.json().get("detail", "").lower()
+
+
+def test_list_exercises_keyset_bad_timestamp_returns_400(client: TestClient) -> None:
+ app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
+ app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
+ profile_id=1,
+ global_role="trainer",
+ effective_club_id=None,
+ club_ids=frozenset(),
+ memberships=[],
+ )
+ r = client.get(
+ "/api/exercises",
+ params={"cursor_id": "1", "cursor_updated_at": "not-a-date"},
+ headers={"X-Auth-Token": "test"},
+ )
+ assert r.status_code == 400
diff --git a/backend/version.py b/backend/version.py
index 4996b24..c3e9e88 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.114"
+APP_VERSION = "0.8.115"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260514060"
@@ -21,7 +21,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
- "exercises": "2.27.4", # Migration 060: Listen-Skalierung (Partial + Junction is_primary)
+ "exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.9.3", # GET training-units/:id Sektions-Items: combination_slots + Kandidaten-Titel für Druck/Run
@@ -36,6 +36,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "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",
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 3312965..93da39b 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -1,7 +1,7 @@
# Shinkan Jinkendo – Entwicklungsstand & Handover
**Stand:** 2026-05-14
-**App-Version / DB-Schema:** App **0.8.114**, DB-Schema **`20260514060`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`)
+**App-Version / DB-Schema:** App **0.8.115**, DB-Schema **`20260514060`** (`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**.
@@ -76,7 +76,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**.
- **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
-### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.114**)
+### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.115**)
- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§ 10.2.1** IDs, **§ 10.4** Coaching-Stufen, **§ 10.6** Produkt-Backlog, **Anhang A** Abgleich).
- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4a–g** — u. a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).
diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
index e251af0..44fe8ad 100644
--- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
+++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
@@ -64,7 +64,7 @@
| Summary-API finalisieren/erweitern falls in P1 nur Teilbereich | B1 |
| Optional: erste Keyset-Pagination für eine Liste mit bekanntem Sort-Key | B3 |
-**Teil erledigt (2026-05-14):** Migration **058** (`exercises`: globale `updated_at`-Sortierung / `created_by`+`updated_at`), **059** (`training_units`: Kalenderliste ohne Blueprint), **060** (`exercises`: Partial-Indizes `official`/`club` inkl. Archiv-Filter; Junction `is_primary` für List-Subqueries). Rest: `EXPLAIN` unter Produktionsvolumen, Fähigkeits-Level-Filter nur bei Bedarf (ggf. Ausdrucks-Index).
+**Teil erledigt (2026-05-14):** Migration **058** (`exercises`: globale `updated_at`-Sortierung / `created_by`+`updated_at`), **059** (`training_units`: Kalenderliste ohne Blueprint), **060** (`exercises`: Partial-Indizes `official`/`club` inkl. Archiv-Filter; Junction `is_primary` für List-Subqueries); **Keyset** für `GET /api/exercises` (`cursor_updated_at` + `cursor_id`, UI „Mehr laden“ in Liste + Picker). Rest: `EXPLAIN` unter Produktionsvolumen, Fähigkeits-Level-Filter nur bei Bedarf (ggf. Ausdrucks-Index).
**Abnahme:** p95 der optimierten Routen **verbessert** ggü. Phase 0 oder dokumentierte Obergrenze eingehalten.
diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx
index e93a896..952c232 100644
--- a/frontend/src/components/ExercisePickerModal.jsx
+++ b/frontend/src/components/ExercisePickerModal.jsx
@@ -53,7 +53,6 @@ export default function ExercisePickerModal({
const [list, setList] = useState([])
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
- const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [multiPicked, setMultiPicked] = useState([])
const [quickOpen, setQuickOpen] = useState(false)
@@ -118,7 +117,6 @@ export default function ExercisePickerModal({
setFilters({ ...INITIAL_FILTERS })
setFilterOpen(false)
setList([])
- setOffset(0)
setHasMore(false)
setMultiPicked([])
setQuickOpen(false)
@@ -227,7 +225,6 @@ export default function ExercisePickerModal({
const reload = useCallback(async () => {
if (!open || !catalogsReady) return
setLoading(true)
- setOffset(0)
try {
const batch = await api.listExercises({
...queryBase,
@@ -238,7 +235,6 @@ export default function ExercisePickerModal({
})
setList(Array.isArray(batch) ? batch : [])
setHasMore(batch?.length === PAGE_SIZE)
- setOffset(batch?.length ?? 0)
} catch (e) {
console.error(e)
alert(e.message || 'Laden fehlgeschlagen')
@@ -255,6 +251,8 @@ export default function ExercisePickerModal({
const loadMore = async () => {
if (!hasMore || loadingMore || loading) return
+ const last = list[list.length - 1]
+ if (!last?.id || last.updated_at == null) return
setLoadingMore(true)
try {
const batch = await api.listExercises({
@@ -262,11 +260,14 @@ export default function ExercisePickerModal({
include_archived: true,
include_variants: true,
limit: PAGE_SIZE,
- offset,
+ cursor_updated_at:
+ typeof last.updated_at === 'string'
+ ? last.updated_at
+ : new Date(last.updated_at).toISOString(),
+ cursor_id: last.id,
})
setList((prev) => [...prev, ...(Array.isArray(batch) ? batch : [])])
setHasMore(batch?.length === PAGE_SIZE)
- setOffset((o) => o + (batch?.length ?? 0))
} catch (e) {
console.error(e)
alert(e.message || 'Mehr laden fehlgeschlagen')
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx
index de91e4d..456a955 100644
--- a/frontend/src/pages/ExercisesListPage.jsx
+++ b/frontend/src/pages/ExercisesListPage.jsx
@@ -177,7 +177,6 @@ function ExercisesListPage() {
const [catalogsReady, setCatalogsReady] = useState(false)
const [listFetching, setListFetching] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
- const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [searchInput, setSearchInput] = useState('')
const [aiSearchInput, setAiSearchInput] = useState('')
@@ -604,13 +603,11 @@ function ExercisesListPage() {
let cancelled = false
const run = async () => {
setListFetching(true)
- setOffset(0)
try {
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 })
if (cancelled) return
setExercises(batch)
setHasMore(batch.length === PAGE_SIZE)
- setOffset(batch.length)
} catch (err) {
if (!cancelled) {
console.error('Failed to load data:', err)
@@ -628,12 +625,21 @@ function ExercisesListPage() {
const loadMore = async () => {
if (loadingMore || !hasMore) return
+ const last = exercises[exercises.length - 1]
+ if (!last?.id || last.updated_at == null) return
setLoadingMore(true)
try {
- const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset })
+ const batch = await api.listExercises({
+ ...queryBase,
+ limit: PAGE_SIZE,
+ cursor_updated_at:
+ typeof last.updated_at === 'string'
+ ? last.updated_at
+ : new Date(last.updated_at).toISOString(),
+ cursor_id: last.id,
+ })
setExercises((prev) => [...prev, ...batch])
setHasMore(batch.length === PAGE_SIZE)
- setOffset((o) => o + batch.length)
} catch (err) {
alert('Fehler: ' + err.message)
} finally {
From c69edc69524bf64e3659a59ef136c647d0497bdd Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 14 May 2026 08:32:26 +0200
Subject: [PATCH 13/18] feat(ci): refactor Gitea workflow to separate k6 health
baseline tests
- 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.
---
.gitea/workflows/test.yml | 109 +++++++++++++++++++------
docs/architecture/BASELINE_SNAPSHOT.md | 2 +-
docs/architecture/README.md | 2 +-
scripts/load/README.md | 2 +-
4 files changed, 87 insertions(+), 28 deletions(-)
diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml
index 5a09570..dd1bd90 100644
--- a/.gitea/workflows/test.yml
+++ b/.gitea/workflows/test.yml
@@ -88,6 +88,90 @@ jobs:
npm run build
echo "✓ Frontend build OK"
+ # Phase-0 Lastsmoke: nur k6 — eigener Job (kein Node/Playwright), klare CI-Zuordnung.
+ k6-health-baseline:
+ name: k6 /health Baseline
+ if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
+ runs-on: ubuntu-latest
+ env:
+ E2E_TARGET_URL: https://dev.shinkan.jinkendo.de
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: E2E-Ziel wählen (Dev über Proxy vs. Production)
+ id: e2e
+ run: |
+ EVENT="${{ github.event_name }}"
+ WF_NAME="${{ github.event.workflow_run.name }}"
+ DEV_BASE="${{ env.E2E_TARGET_URL }}"
+ if [ "$EVENT" = "workflow_run" ] && [ "$WF_NAME" = "Deploy Production" ]; then
+ echo "mode=prod" >> $GITHUB_OUTPUT
+ echo "base_url=https://shinkan.jinkendo.de" >> $GITHUB_OUTPUT
+ echo "→ k6 gegen Prod-Basis."
+ else
+ echo "mode=dev" >> $GITHUB_OUTPUT
+ echo "base_url=${DEV_BASE}" >> $GITHUB_OUTPUT
+ echo "→ k6 gegen Dev (${DEV_BASE})."
+ fi
+
+ - name: Dev /health abwarten
+ if: ${{ steps.e2e.outputs.mode == 'dev' }}
+ run: |
+ BASE="${{ steps.e2e.outputs.base_url }}"
+ echo "Warte auf $BASE/health …"
+ for i in $(seq 1 90); do
+ if curl -sf "$BASE/health" >/dev/null 2>&1; then
+ echo "Health OK (Versuch $i)"
+ exit 0
+ fi
+ sleep 2
+ done
+ echo "Timeout: Dev /health nicht erreichbar — Deploy / DNS / Firewall prüfen."
+ curl -v "$BASE/health" || true
+ exit 1
+
+ - name: Prod /health abwarten
+ if: ${{ steps.e2e.outputs.mode == 'prod' }}
+ run: |
+ BASE="${{ steps.e2e.outputs.base_url }}"
+ echo "Warte auf $BASE/health …"
+ for i in $(seq 1 60); do
+ if curl -sf "$BASE/health" >/dev/null 2>&1; then
+ echo "Health OK (Versuch $i)"
+ exit 0
+ fi
+ sleep 5
+ done
+ echo "Timeout: Prod /health nicht erreichbar"
+ curl -v "$BASE/health" || true
+ exit 1
+
+ - name: Install k6
+ run: |
+ set -e
+ K6_VER="v0.55.0"
+ ARCH=$(uname -m)
+ case "$ARCH" in
+ x86_64) K6_ARCH=amd64 ;;
+ aarch64|arm64) K6_ARCH=arm64 ;;
+ *) echo "k6: unbekannte Architektur: $ARCH"; exit 1 ;;
+ esac
+ echo "Installing k6 ${K6_VER} linux-${K6_ARCH}"
+ curl -sSL "https://github.com/grafana/k6/releases/download/${K6_VER}/k6-${K6_VER}-linux-${K6_ARCH}.tar.gz" -o /tmp/k6.tgz
+ tar -xzf /tmp/k6.tgz -C /tmp
+ sudo mv "/tmp/k6-${K6_VER}-linux-${K6_ARCH}/k6" /usr/local/bin/k6
+ k6 version
+
+ - name: k6 Health-Baseline (parallele /health)
+ env:
+ BASE_URL: ${{ steps.e2e.outputs.base_url }}
+ run: |
+ set -e
+ echo "k6 gegen BASE_URL=$BASE_URL"
+ k6 run scripts/load/k6-health-baseline.js
+ echo "✓ k6 Health-Baseline passed"
+
playwright-tests:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
@@ -153,31 +237,6 @@ jobs:
curl -v "$BASE/health" || true
exit 1
- - name: Install k6 (Phase-0 /health-Baseline)
- 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"
-
- name: Testnutzer registrieren (Dev, nur wenn möglich)
if: ${{ steps.e2e.outputs.mode == 'dev' }}
env:
diff --git a/docs/architecture/BASELINE_SNAPSHOT.md b/docs/architecture/BASELINE_SNAPSHOT.md
index 5c1ad0a..924cd78 100644
--- a/docs/architecture/BASELINE_SNAPSHOT.md
+++ b/docs/architecture/BASELINE_SNAPSHOT.md
@@ -78,7 +78,7 @@ Messung: Repo-Root → `cd frontend && npm run build` (Vite Production).
### 3.2 k6 – parallele /health
- **Skript:** `scripts/load/k6-health-baseline.js`
-- **CI:** Läuft **automatisch** im Gitea-Workflow **playwright-tests** nach der Health-Wartezeit und **vor** Playwright (`.gitea/workflows/test.yml`).
+- **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.
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index a362ea2..ac5fe6c 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -14,7 +14,7 @@ Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP
## 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**.
+- **`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
diff --git a/scripts/load/README.md b/scripts/load/README.md
index 39f920c..623954b 100644
--- a/scripts/load/README.md
+++ b/scripts/load/README.md
@@ -2,7 +2,7 @@
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`** (Job `playwright-tests`): nach **/health-Wartezeit** läuft **k6** automatisch, danach Playwright. Gleiche `BASE_URL` wie E2E (Dev oder Prod nach `workflow_run`). Kein manueller Schritt nach dem Deploy.
+**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
From 657fcc241a384ceae267657af815c027b203ec51 Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 14 May 2026 08:36:31 +0200
Subject: [PATCH 14/18] chore(version): update version and changelog for
release 0.8.116
- 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.
---
backend/version.py | 9 +++++-
docs/HANDOVER.md | 4 +--
docs/architecture/UMSETZUNGSPLAN_ROADMAP.md | 4 +--
frontend/src/context/OrgInboxContext.jsx | 32 +++++++++++++++++++--
4 files changed, 42 insertions(+), 7 deletions(-)
diff --git a/backend/version.py b/backend/version.py
index c3e9e88..084702b 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.115"
+APP_VERSION = "0.8.116"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260514060"
@@ -36,6 +36,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "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",
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 93da39b..91a18df 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -1,7 +1,7 @@
# Shinkan Jinkendo – Entwicklungsstand & Handover
**Stand:** 2026-05-14
-**App-Version / DB-Schema:** App **0.8.115**, DB-Schema **`20260514060`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`)
+**App-Version / DB-Schema:** App **0.8.116**, DB-Schema **`20260514060`** (`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**.
@@ -76,7 +76,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**.
- **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
-### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.115**)
+### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.116**)
- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§ 10.2.1** IDs, **§ 10.4** Coaching-Stufen, **§ 10.6** Produkt-Backlog, **Anhang A** Abgleich).
- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4a–g** — u. a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).
diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
index 44fe8ad..1be7704 100644
--- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
+++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
@@ -7,7 +7,7 @@
- **Phase 1 (Teil):** Org-Inbox: **ein** gemeinsamer Ladepfad `fetchOrgInboxSnapshot` für Mount-`useEffect` und `refreshOrgInbox` (gleiche Requests, weniger Drift-Risiko; Verhalten unverändert).
- **Phase 1 / 2 (Teil):** Dashboard-KPIs: **`GET /api/dashboard/kpis`** (ein Roundtrip); Playwright-Test 8 angepasst.
- **Phase 2 (Teil):** Listen-Indizes **058** (`exercises` Sortierung/`created_by`) und **059** (`training_units` Kalenderliste ohne Blueprint).
-- **Offen Phase 1:** Inbox zeitlich entkoppeln nur nach Messung (optional).
+- **Offen Phase 1:** Inbox nur noch Feinschliff (TTL); **verzögertes Erstlade** per Idle (weniger parallele Requests beim Dashboard-Start).
**Ziel:** 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).
@@ -46,7 +46,7 @@
| 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 | optional, nach Messung |
+| 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.
diff --git a/frontend/src/context/OrgInboxContext.jsx b/frontend/src/context/OrgInboxContext.jsx
index a565204..5c6b6db 100644
--- a/frontend/src/context/OrgInboxContext.jsx
+++ b/frontend/src/context/OrgInboxContext.jsx
@@ -78,15 +78,43 @@ export function OrgInboxProvider({ user, children }) {
return undefined
}
let cancelled = false
- ;(async () => {
+ let idleId = null
+ let timeoutId = null
+
+ const load = async () => {
const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports)
if (cancelled) return
setItems(snap.items)
setContentReports(snap.contentReports)
setContentReportsError(canAccessReports ? snap.contentReportsError : null)
- })()
+ }
+
+ const schedule = () => {
+ if (cancelled) return
+ if (typeof window.requestIdleCallback === 'function') {
+ idleId = window.requestIdleCallback(
+ () => {
+ idleId = null
+ void load()
+ },
+ { timeout: 1500 },
+ )
+ } else {
+ timeoutId = window.setTimeout(() => {
+ timeoutId = null
+ void load()
+ }, 0)
+ }
+ }
+
+ schedule()
+
return () => {
cancelled = true
+ if (idleId != null && typeof window.cancelIdleCallback === 'function') {
+ window.cancelIdleCallback(idleId)
+ }
+ if (timeoutId != null) window.clearTimeout(timeoutId)
}
}, [canAccess, canAccessReports, user?.id])
From 32ba0086600b50579f0528ff61c21755dff012c0 Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 14 May 2026 08:44:59 +0200
Subject: [PATCH 15/18] chore(version): update version and changelog for
release 0.8.117
- 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.
---
.../061_training_units_keyset_indexes.sql | 22 +++
backend/routers/training_planning.py | 151 ++++++++++++++++--
.../tests/test_training_units_list_keyset.py | 108 +++++++++++++
backend/version.py | 17 +-
docs/HANDOVER.md | 4 +-
docs/architecture/UMSETZUNGSPLAN_ROADMAP.md | 5 +-
frontend/src/utils/api.js | 5 +
7 files changed, 290 insertions(+), 22 deletions(-)
create mode 100644 backend/migrations/061_training_units_keyset_indexes.sql
create mode 100644 backend/tests/test_training_units_list_keyset.py
diff --git a/backend/migrations/061_training_units_keyset_indexes.sql b/backend/migrations/061_training_units_keyset_indexes.sql
new file mode 100644
index 0000000..98dba11
--- /dev/null
+++ b/backend/migrations/061_training_units_keyset_indexes.sql
@@ -0,0 +1,22 @@
+-- GET /api/training-units: Keyset über (planned_date, planned_time_start NULLS LAST per Sort, id)
+-- Ersetzt den reinen Datum/Uhrzeit-Teilindex 059 durch zwei Richtungen mit Tie-Break id.
+
+DROP INDEX IF EXISTS idx_training_units_scheduled_order;
+
+CREATE INDEX IF NOT EXISTS idx_training_units_list_keyset_desc
+ON training_units (
+ planned_date DESC,
+ (planned_time_start IS NULL) ASC,
+ planned_time_start DESC NULLS LAST,
+ id DESC
+)
+WHERE framework_slot_id IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_training_units_list_keyset_asc
+ON training_units (
+ planned_date ASC,
+ (planned_time_start IS NULL) ASC,
+ planned_time_start ASC NULLS LAST,
+ id ASC
+)
+WHERE framework_slot_id IS NULL;
diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py
index 49e492f..d2c635b 100644
--- a/backend/routers/training_planning.py
+++ b/backend/routers/training_planning.py
@@ -4,8 +4,8 @@ und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung).
Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin.
"""
-from datetime import date, timedelta
-from typing import Any, Dict, List, Optional
+from datetime import date, datetime, time as dt_time, timedelta
+from typing import Any, Dict, List, Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException, Query
from psycopg2.extras import Json as PsycopgJson
@@ -42,6 +42,78 @@ def _optional_positive_int(val, field_name: str) -> Optional[int]:
return i
+def _parse_cursor_planned_date(raw: Optional[str]) -> date:
+ s = (raw or "").strip()
+ if not s:
+ raise HTTPException(status_code=400, detail="cursor_planned_date ungültig (YYYY-MM-DD)")
+ try:
+ return date.fromisoformat(s[:10])
+ except ValueError:
+ raise HTTPException(status_code=400, detail="cursor_planned_date ungültig (YYYY-MM-DD)")
+
+
+def _parse_cursor_planned_time_optional(raw: Optional[str]) -> Optional[dt_time]:
+ s = (raw or "").strip()
+ if not s:
+ return None
+ for fmt in ("%H:%M:%S", "%H:%M"):
+ try:
+ return datetime.strptime(s, fmt).time()
+ except ValueError:
+ continue
+ raise HTTPException(
+ status_code=400,
+ detail="cursor_planned_time ungültig (HH:MM oder HH:MM:SS)",
+ )
+
+
+def _training_units_keyset_sql(
+ order_dir: str,
+ cursor_date: date,
+ cursor_time_null: bool,
+ cursor_time: Optional[dt_time],
+ cursor_id: int,
+) -> Tuple[str, List[Any]]:
+ """WHERE-Zusatz für Keyset; sort=asc|desc muss zu order_dir passen."""
+ d = cursor_date
+ cid = cursor_id
+ if order_dir == "ASC":
+ if cursor_time_null:
+ frag = (
+ "(tu.planned_date > %s OR (tu.planned_date = %s AND "
+ "tu.planned_time_start IS NULL AND tu.id > %s))"
+ )
+ return frag, [d, d, cid]
+ assert cursor_time is not None
+ ct = cursor_time
+ frag = (
+ "(tu.planned_date > %s OR (tu.planned_date = %s AND ("
+ "(tu.planned_time_start IS NOT NULL AND (tu.planned_time_start > %s OR "
+ "(tu.planned_time_start = %s AND tu.id > %s))) OR "
+ "(tu.planned_time_start IS NULL)"
+ ")))"
+ )
+ return frag, [d, d, ct, ct, cid]
+ if order_dir == "DESC":
+ if cursor_time_null:
+ frag = (
+ "(tu.planned_date < %s OR (tu.planned_date = %s AND "
+ "tu.planned_time_start IS NULL AND tu.id < %s))"
+ )
+ return frag, [d, d, cid]
+ assert cursor_time is not None
+ ct = cursor_time
+ frag = (
+ "(tu.planned_date < %s OR (tu.planned_date = %s AND ("
+ "(tu.planned_time_start IS NOT NULL AND (tu.planned_time_start < %s OR "
+ "(tu.planned_time_start = %s AND tu.id < %s))) OR "
+ "(tu.planned_time_start IS NULL)"
+ ")))"
+ )
+ return frag, [d, d, ct, ct, cid]
+ raise HTTPException(status_code=400, detail="sort: nur asc oder desc")
+
+
def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id: Optional[int]):
if not exercise_id:
if variant_id:
@@ -1254,6 +1326,19 @@ def list_training_units(
),
sort: str = Query(default="desc"),
limit: Optional[int] = Query(default=None),
+ cursor_planned_date: Optional[str] = Query(
+ default=None,
+ description="Keyset: YYYY-MM-DD der letzten Zeile (mit cursor_id)",
+ ),
+ cursor_planned_time: Optional[str] = Query(
+ default=None,
+ description="Keyset: HH:MM oder HH:MM:SS; weglassen/leer wenn planned_time_start NULL",
+ ),
+ cursor_id: Optional[int] = Query(
+ default=None,
+ ge=1,
+ description="Keyset: id der letzten Zeile (mit cursor_planned_date)",
+ ),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = tenant.profile_id
@@ -1264,6 +1349,40 @@ def list_training_units(
if gid and cid:
raise HTTPException(status_code=400, detail="Nur eines der Parameter group_id oder club_id angeben")
+ order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC"
+ lim: Optional[int] = None
+ if limit is not None:
+ try:
+ lim = int(limit)
+ except (TypeError, ValueError):
+ raise HTTPException(status_code=400, detail="limit ungültig")
+ if lim < 1:
+ raise HTTPException(status_code=400, detail="limit ungültig")
+ lim = min(lim, 250)
+
+ c_id_q = cursor_id
+ c_date_raw = (cursor_planned_date or "").strip() or None
+ time_nonempty = (cursor_planned_time or "").strip() != ""
+ has_cursor_partial = (
+ (c_id_q is not None) != (c_date_raw is not None) or (time_nonempty and c_id_q is None)
+ )
+ if has_cursor_partial:
+ raise HTTPException(
+ status_code=400,
+ detail="cursor_planned_date und cursor_id müssen zusammen gesetzt werden",
+ )
+ use_keyset = c_id_q is not None
+ if use_keyset and lim is None:
+ raise HTTPException(status_code=400, detail="Keyset: Parameter limit ist erforderlich")
+ cursor_d: Optional[date] = None
+ cursor_t: Optional[dt_time] = None
+ cursor_t_null = False
+ if use_keyset:
+ assert c_id_q is not None and c_date_raw is not None
+ cursor_d = _parse_cursor_planned_date(c_date_raw)
+ cursor_t = _parse_cursor_planned_time_optional(cursor_planned_time)
+ cursor_t_null = cursor_t is None
+
with get_db() as conn:
cur = get_cursor(conn)
@@ -1286,17 +1405,6 @@ def list_training_units(
if not (ok_staff or ok_org or ok_member):
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe")
- order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC"
- lim: Optional[int] = None
- if limit is not None:
- try:
- lim = int(limit)
- except (TypeError, ValueError):
- raise HTTPException(status_code=400, detail="limit ungültig")
- if lim < 1:
- raise HTTPException(status_code=400, detail="limit ungültig")
- lim = min(lim, 250)
-
query = """
SELECT tu.*,
tg.name as group_name,
@@ -1379,10 +1487,25 @@ def list_training_units(
where.append("tu.status = %s")
params.append(status)
+ if use_keyset:
+ assert cursor_d is not None and c_id_q is not None
+ ks_sql, ks_params = _training_units_keyset_sql(
+ order_dir,
+ cursor_d,
+ cursor_t_null,
+ cursor_t,
+ int(c_id_q),
+ )
+ where.append(ks_sql)
+ params.extend(ks_params)
+
if where:
query += " WHERE " + " AND ".join(where)
- query += f" ORDER BY tu.planned_date {order_dir}, tu.planned_time_start {order_dir} NULLS LAST"
+ query += (
+ f" ORDER BY tu.planned_date {order_dir}, (tu.planned_time_start IS NULL) ASC, "
+ f"tu.planned_time_start {order_dir} NULLS LAST, tu.id {order_dir}"
+ )
if lim is not None:
query += " LIMIT %s"
params.append(lim)
diff --git a/backend/tests/test_training_units_list_keyset.py b/backend/tests/test_training_units_list_keyset.py
new file mode 100644
index 0000000..0a5257a
--- /dev/null
+++ b/backend/tests/test_training_units_list_keyset.py
@@ -0,0 +1,108 @@
+"""GET /api/training-units: Keyset-Parameter-Validierung (ohne DB-Zwang)."""
+from __future__ import annotations
+
+import os
+
+import pytest
+from fastapi.testclient import TestClient
+
+os.environ.setdefault("SKIP_DB_MIGRATE", "1")
+
+from auth import require_auth
+from main import app
+from tenant_context import TenantContext, get_tenant_context
+
+
+@pytest.fixture
+def client() -> TestClient:
+ return TestClient(app)
+
+
+@pytest.fixture(autouse=True)
+def _clear_overrides() -> None:
+ yield
+ app.dependency_overrides.pop(require_auth, None)
+ app.dependency_overrides.pop(get_tenant_context, None)
+
+
+def _tenant() -> TenantContext:
+ return TenantContext(
+ profile_id=1,
+ global_role="trainer",
+ effective_club_id=None,
+ club_ids=frozenset(),
+ memberships=[],
+ )
+
+
+def test_list_training_units_keyset_incomplete_returns_400(client: TestClient) -> None:
+ app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
+ app.dependency_overrides[get_tenant_context] = _tenant
+ r = client.get(
+ "/api/training-units",
+ params={"cursor_id": "42"},
+ headers={"X-Auth-Token": "test"},
+ )
+ assert r.status_code == 400
+ assert "cursor_planned_date" in r.json().get("detail", "").lower()
+
+
+def test_list_training_units_keyset_without_limit_returns_400(client: TestClient) -> None:
+ app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
+ app.dependency_overrides[get_tenant_context] = _tenant
+ r = client.get(
+ "/api/training-units",
+ params={
+ "cursor_id": "1",
+ "cursor_planned_date": "2026-05-10",
+ },
+ headers={"X-Auth-Token": "test"},
+ )
+ assert r.status_code == 400
+ assert "limit" in r.json().get("detail", "").lower()
+
+
+def test_list_training_units_keyset_bad_date_returns_400(client: TestClient) -> None:
+ app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
+ app.dependency_overrides[get_tenant_context] = _tenant
+ r = client.get(
+ "/api/training-units",
+ params={
+ "cursor_id": "1",
+ "cursor_planned_date": "not-a-date",
+ "limit": "10",
+ },
+ headers={"X-Auth-Token": "test"},
+ )
+ assert r.status_code == 400
+
+
+def test_list_training_units_keyset_bad_time_returns_400(client: TestClient) -> None:
+ app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
+ app.dependency_overrides[get_tenant_context] = _tenant
+ r = client.get(
+ "/api/training-units",
+ params={
+ "cursor_id": "1",
+ "cursor_planned_date": "2026-05-10",
+ "cursor_planned_time": "25:99",
+ "limit": "10",
+ },
+ headers={"X-Auth-Token": "test"},
+ )
+ assert r.status_code == 400
+ assert "cursor_planned_time" in r.json().get("detail", "").lower()
+
+
+def test_list_training_units_keyset_time_without_id_returns_400(client: TestClient) -> None:
+ app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
+ app.dependency_overrides[get_tenant_context] = _tenant
+ r = client.get(
+ "/api/training-units",
+ params={
+ "cursor_planned_time": "18:00",
+ "limit": "10",
+ },
+ headers={"X-Auth-Token": "test"},
+ )
+ assert r.status_code == 400
diff --git a/backend/version.py b/backend/version.py
index 084702b..f383ffb 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.116"
+APP_VERSION = "0.8.117"
BUILD_DATE = "2026-05-12"
-DB_SCHEMA_VERSION = "20260514060"
+DB_SCHEMA_VERSION = "20260514061"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@@ -22,9 +22,9 @@ MODULE_VERSIONS = {
"skills": "0.1.0",
"methods": "0.1.0",
"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",
- "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.0.0", # GET /api/dashboard/kpis — Aggregat Entwürfe / meine Übungen / YTD completed
"training_modules": "1.0.0",
"import_wiki": "1.0.0",
@@ -36,6 +36,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "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",
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 91a18df..47f0b0d 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -1,7 +1,7 @@
# Shinkan Jinkendo – Entwicklungsstand & Handover
**Stand:** 2026-05-14
-**App-Version / DB-Schema:** App **0.8.116**, DB-Schema **`20260514060`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`)
+**App-Version / DB-Schema:** App **0.8.117**, DB-Schema **`20260514061`** (`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**.
@@ -76,7 +76,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**.
- **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
-### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.116**)
+### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.117**)
- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§ 10.2.1** IDs, **§ 10.4** Coaching-Stufen, **§ 10.6** Produkt-Backlog, **Anhang A** Abgleich).
- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4a–g** — u. a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).
diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
index 1be7704..c69844a 100644
--- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
+++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
@@ -7,7 +7,8 @@
- **Phase 1 (Teil):** Org-Inbox: **ein** gemeinsamer Ladepfad `fetchOrgInboxSnapshot` für Mount-`useEffect` und `refreshOrgInbox` (gleiche Requests, weniger Drift-Risiko; Verhalten unverändert).
- **Phase 1 / 2 (Teil):** Dashboard-KPIs: **`GET /api/dashboard/kpis`** (ein Roundtrip); Playwright-Test 8 angepasst.
- **Phase 2 (Teil):** Listen-Indizes **058** (`exercises` Sortierung/`created_by`) und **059** (`training_units` Kalenderliste ohne Blueprint).
-- **Offen Phase 1:** Inbox nur noch Feinschliff (TTL); **verzögertes Erstlade** per Idle (weniger parallele Requests beim Dashboard-Start).
+- **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget.
+- **Phase 1:** **verzögertes Erstlade** Org-Inbox per Idle ist umgesetzt.
**Ziel:** 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).
@@ -64,7 +65,7 @@
| Summary-API finalisieren/erweitern falls in P1 nur Teilbereich | B1 |
| Optional: erste Keyset-Pagination für eine Liste mit bekanntem Sort-Key | B3 |
-**Teil erledigt (2026-05-14):** Migration **058** (`exercises`: globale `updated_at`-Sortierung / `created_by`+`updated_at`), **059** (`training_units`: Kalenderliste ohne Blueprint), **060** (`exercises`: Partial-Indizes `official`/`club` inkl. Archiv-Filter; Junction `is_primary` für List-Subqueries); **Keyset** für `GET /api/exercises` (`cursor_updated_at` + `cursor_id`, UI „Mehr laden“ in Liste + Picker). Rest: `EXPLAIN` unter Produktionsvolumen, Fähigkeits-Level-Filter nur bei Bedarf (ggf. Ausdrucks-Index).
+**Teil erledigt (2026-05-14):** Migration **058** (`exercises`: globale `updated_at`-Sortierung / `created_by`+`updated_at`), **059** (Teilindex Kalenderliste; wird durch **061** ersetzt/erweitert), **060** (`exercises`: Partial-Indizes `official`/`club` inkl. Archiv-Filter; Junction `is_primary` für List-Subqueries); **061** (`training_units`: zwei Teilindizes ASC/DESC inkl. `id` für Keyset); **Keyset** für `GET /api/exercises` (`cursor_updated_at` + `cursor_id`, UI „Mehr laden“ in Liste + Picker) und **Keyset** für `GET /api/training-units` (`cursor_planned_date` + `cursor_id`, optional `cursor_planned_time`; bei Keyset ist `limit` Pflicht). Rest: `EXPLAIN` unter Produktionsvolumen, Fähigkeits-Level-Filter nur bei Bedarf (ggf. Ausdrucks-Index); Frontend-„Mehr laden“ für lange Trainingslisten (Planung) optional.
**Abnahme:** p95 der optimierten Routen **verbessert** ggü. Phase 0 oder dokumentierte Obergrenze eingehalten.
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index b58f622..5a46420 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -1348,6 +1348,11 @@ export async function listTrainingUnits(filters = {}) {
if (filters.assigned_to_me === true) q.set('assigned_to_me', 'true')
if (filters.sort) q.set('sort', String(filters.sort))
if (filters.limit != null && filters.limit !== '') q.set('limit', String(filters.limit))
+ if (filters.cursor_planned_date) q.set('cursor_planned_date', String(filters.cursor_planned_date))
+ if (filters.cursor_planned_time != null && filters.cursor_planned_time !== '') {
+ q.set('cursor_planned_time', String(filters.cursor_planned_time))
+ }
+ if (filters.cursor_id != null && filters.cursor_id !== '') q.set('cursor_id', String(filters.cursor_id))
const qs = q.toString()
return request(`/api/training-units${qs ? `?${qs}` : ''}`)
}
From b06d026dd0238efc57203232de2039f5106d629f Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 14 May 2026 08:53:09 +0200
Subject: [PATCH 16/18] chore(version): update version and changelog for
release 0.8.118
- 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.
---
.../062_exercise_skills_level_rank_index.sql | 41 ++++++
backend/routers/dashboard.py | 47 ++++++-
backend/routers/exercises.py | 1 +
backend/version.py | 15 ++-
docs/HANDOVER.md | 4 +-
docs/architecture/BASELINE_SNAPSHOT.md | 8 +-
docs/architecture/UMSETZUNGSPLAN_ROADMAP.md | 27 ++--
frontend/src/pages/Dashboard.jsx | 117 +++++-------------
scripts/load/README.md | 4 +
scripts/load/explain-readpaths.sql | 56 +++++++++
tests/dev-smoke-test.spec.js | 8 +-
11 files changed, 219 insertions(+), 109 deletions(-)
create mode 100644 backend/migrations/062_exercise_skills_level_rank_index.sql
create mode 100644 scripts/load/explain-readpaths.sql
diff --git a/backend/migrations/062_exercise_skills_level_rank_index.sql b/backend/migrations/062_exercise_skills_level_rank_index.sql
new file mode 100644
index 0000000..e1a8c06
--- /dev/null
+++ b/backend/migrations/062_exercise_skills_level_rank_index.sql
@@ -0,0 +1,41 @@
+-- list_exercises mit skill_min_level / skill_max_level: EXISTS auf exercise_skills mit numerischem Stufen-Rang.
+-- Ausdruck muss mit backend/routers/exercises.py _EXERCISE_SKILL_LEVEL_RANK_SQL (Alias „es“) übereinstimmen.
+
+CREATE INDEX IF NOT EXISTS idx_exercise_skills_exercise_level_rank
+ON exercise_skills (
+ exercise_id,
+ (CASE COALESCE(
+ NULLIF(TRIM(LOWER(target_level::text)), ''),
+ NULLIF(TRIM(LOWER(required_level::text)), '')
+ )
+ WHEN 'basis' THEN 1
+ WHEN 'grundlagen' THEN 2
+ WHEN 'aufbau' THEN 3
+ WHEN 'fortgeschritten' THEN 4
+ WHEN 'optimierung' THEN 5
+ WHEN 'einsteiger' THEN 1
+ WHEN 'experte' THEN 5
+ WHEN '1' THEN 1
+ WHEN '2' THEN 2
+ WHEN '3' THEN 3
+ WHEN '4' THEN 4
+ WHEN '5' THEN 5
+ ELSE NULL END)
+)
+WHERE (CASE COALESCE(
+ NULLIF(TRIM(LOWER(target_level::text)), ''),
+ NULLIF(TRIM(LOWER(required_level::text)), '')
+ )
+ WHEN 'basis' THEN 1
+ WHEN 'grundlagen' THEN 2
+ WHEN 'aufbau' THEN 3
+ WHEN 'fortgeschritten' THEN 4
+ WHEN 'optimierung' THEN 5
+ WHEN 'einsteiger' THEN 1
+ WHEN 'experte' THEN 5
+ WHEN '1' THEN 1
+ WHEN '2' THEN 2
+ WHEN '3' THEN 3
+ WHEN '4' THEN 4
+ WHEN '5' THEN 5
+ ELSE NULL END) IS NOT NULL;
diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py
index fe217f9..016f706 100644
--- a/backend/routers/dashboard.py
+++ b/backend/routers/dashboard.py
@@ -4,6 +4,7 @@ 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
@@ -14,15 +15,28 @@ 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-KPIs wie bisher drei parallele Client-Aufrufe:
- listExercises (Entwürfe), listExercises (meine), listTrainingUnits (completed im Kalenderjahr).
+ 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
@@ -42,6 +56,30 @@ def get_dashboard_kpis(tenant: TenantContext = Depends(get_tenant_context)):
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']}"}
@@ -57,4 +95,9 @@ def get_dashboard_kpis(tenant: TenantContext = Depends(get_tenant_context)):
"mine_capped": len(mine_list) >= 100,
"ytd_completed_count": len(ytd_completed),
"ytd_capped": len(ytd_completed) >= 250,
+ "training_home": {
+ "upcoming": planned_pool[:8],
+ "planned_with_notes": _slice_training_home_notes(planned_pool),
+ "review_pending": review_pending,
+ },
}
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index 7613102..c85840b 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -97,6 +97,7 @@ CASE COALESCE(
WHEN '5' THEN 5
ELSE NULL END
""".strip()
+# Bei Änderung: Migration 062 idx_exercise_skills_exercise_level_rank (SQL-Ausdruck) synchron halten.
def normalize_exercise_skill_level(value) -> Optional[str]:
diff --git a/backend/version.py b/backend/version.py
index f383ffb..06935f4 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.117"
+APP_VERSION = "0.8.118"
BUILD_DATE = "2026-05-12"
-DB_SCHEMA_VERSION = "20260514061"
+DB_SCHEMA_VERSION = "20260514062"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@@ -25,7 +25,7 @@ MODULE_VERSIONS = {
"training_units": "0.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak
"training_programs": "0.1.0",
"planning": "0.9.4", # list_training_units: Keyset-Pagination + stabile Sortierung (NULLS LAST + id)
- "dashboard": "1.0.0", # GET /api/dashboard/kpis — Aggregat Entwürfe / meine Übungen / YTD completed
+ "dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine)
"training_modules": "1.0.0",
"import_wiki": "1.0.0",
"admin": "1.0.0",
@@ -36,6 +36,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "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",
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 47f0b0d..a4e4b32 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -1,7 +1,7 @@
# Shinkan Jinkendo – Entwicklungsstand & Handover
**Stand:** 2026-05-14
-**App-Version / DB-Schema:** App **0.8.117**, DB-Schema **`20260514061`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`)
+**App-Version / DB-Schema:** App **0.8.118**, 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**.
@@ -76,7 +76,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**.
- **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
-### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.117**)
+### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.118**)
- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§ 10.2.1** IDs, **§ 10.4** Coaching-Stufen, **§ 10.6** Produkt-Backlog, **Anhang A** Abgleich).
- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4a–g** — u. a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).
diff --git a/docs/architecture/BASELINE_SNAPSHOT.md b/docs/architecture/BASELINE_SNAPSHOT.md
index 924cd78..0018eca 100644
--- a/docs/architecture/BASELINE_SNAPSHOT.md
+++ b/docs/architecture/BASELINE_SNAPSHOT.md
@@ -75,7 +75,11 @@ Messung: Repo-Root → `cd frontend && npm run build` (Vite Production).
| Playwright Gesamtlauf (lokal/CI) | *—* | *nach Messung* |
| passed / total | 26 / 26 (Ziel) | |
-### 3.2 k6 – parallele /health
+### 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`**.
@@ -91,7 +95,7 @@ Messung: Repo-Root → `cd frontend && npm run build` (Vite Production).
## 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, ggf. Dashboard-Summary) **startet erst nach** diesem Dokument als verbindlicher Baseline-Einstieg (kein blocker für Code, aber Vergleich nach Phase 2 gegen diese Werte).
+- **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**).
---
diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
index c69844a..530bfbf 100644
--- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
+++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
@@ -2,11 +2,10 @@
**Aktueller Stand (laufend):**
-- **Phase 0:** abgeschlossen – siehe **[BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md)** (Bundle festgehalten, API-/k6-Vorlagen + Skripte unter `scripts/load/`). **Phase 2** startet erst danach (Vergleich nach Umsetzung gegen Baseline).
-- **Phase 1 (Teil):** Dashboard: kein zweites `getCurrentProfile`; eine `listTrainingUnits`-Abfrage für „Nächste Termine“ + Notiz-Pool; Playwright **Test 8** in `dev-smoke-test.spec.js` sichert API-Budget ab.
+- **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 1 / 2 (Teil):** Dashboard-KPIs: **`GET /api/dashboard/kpis`** (ein Roundtrip); Playwright-Test 8 angepasst.
-- **Phase 2 (Teil):** Listen-Indizes **058** (`exercises` Sortierung/`created_by`) und **059** (`training_units` Kalenderliste ohne Blueprint).
+- **Phase 2:** **abgeschlossen** (2026-05-14) — Indizes 058–062, Keyset `/api/exercises` + `/api/training-units`, **`/api/dashboard/kpis`** inkl. `training_home`, EXPLAIN-Vorlagen **`scripts/load/explain-readpaths.sql`**.
- **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget.
- **Phase 1:** **verzögertes Erstlade** Org-Inbox per Idle ist umgesetzt.
@@ -55,19 +54,21 @@
## Phase 2 – Backend Lesepfade (Skalierung „viele Nutzer“)
-**Voraussetzung:** Phase 0 abgeschlossen (**[BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md)**). Nach Umsetzung Phase 2 p95 / Bundle mit Baseline vergleichen.
+**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 |
-|------|--------|
-| `EXPLAIN` + Index-Tuning für `list_exercises` und nächste schwere Listen | B2 |
-| Summary-API finalisieren/erweitern falls in P1 nur Teilbereich | B1 |
-| Optional: erste Keyset-Pagination für eine Liste mit bekanntem Sort-Key | B3 |
+| Task | Bezug | Status |
+|------|-------|--------|
+| `EXPLAIN` + Index-Tuning für `list_exercises` und nächste schwere Listen | B2 | erledigt (Indizes 058–060, 062; Vorlagen **[explain-readpaths.sql](../../scripts/load/explain-readpaths.sql)**; Messung Team) |
+| Summary-API finalisieren/erweitern falls in P1 nur Teilbereich | B1 | erledigt (`GET /api/dashboard/kpis` + **`training_home`**) |
+| Keyset-Pagination für Listen mit Sort-Key | B3 | erledigt (`/api/exercises`, `/api/training-units`) |
-**Teil erledigt (2026-05-14):** Migration **058** (`exercises`: globale `updated_at`-Sortierung / `created_by`+`updated_at`), **059** (Teilindex Kalenderliste; wird durch **061** ersetzt/erweitert), **060** (`exercises`: Partial-Indizes `official`/`club` inkl. Archiv-Filter; Junction `is_primary` für List-Subqueries); **061** (`training_units`: zwei Teilindizes ASC/DESC inkl. `id` für Keyset); **Keyset** für `GET /api/exercises` (`cursor_updated_at` + `cursor_id`, UI „Mehr laden“ in Liste + Picker) und **Keyset** für `GET /api/training-units` (`cursor_planned_date` + `cursor_id`, optional `cursor_planned_time`; bei Keyset ist `limit` Pflicht). Rest: `EXPLAIN` unter Produktionsvolumen, Fähigkeits-Level-Filter nur bei Bedarf (ggf. Ausdrucks-Index); Frontend-„Mehr laden“ für lange Trainingslisten (Planung) optional.
+**Lieferung:** Migrationen **058–062**; Keyset-Parameter wie dokumentiert in OpenAPI/Router; Dashboard nutzt **ein** KPI-Request für Kennzahlen und Trainings-Vorschau.
-**Abnahme:** p95 der optimierten Routen **verbessert** ggü. Phase 0 oder dokumentierte Obergrenze eingehalten.
+**Abnahme:** p95 der optimierten Routen nach Messung dokumentiert verbessert ggü. Phase 0 oder Obergrenze notiert (siehe Baseline-Tabelle).
---
@@ -117,7 +118,7 @@
| Meilenstein | Inhalt |
|-------------|--------|
| **M1** | Phase 0 + 1 abgeschlossen, HANDOVER aktualisiert |
-| **M2** | Phase 2 abgeschlossen, Lasttest wiederholt |
+| **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 |
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index 1b38f29..5361236 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -22,94 +22,45 @@ function formatCappedCount(n, capped) {
function Dashboard() {
const [trainingHome, setTrainingHome] = useState(null)
- const [trainingHomeErr, setTrainingHomeErr] = useState(null)
const [phase0Stats, setPhase0Stats] = useState(null)
- const [phase0Err, setPhase0Err] = useState(null)
+ const [dashboardKpisErr, setDashboardKpisErr] = useState(null)
const { user, loading: authLoading } = useAuth()
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
useEffect(() => {
if (!user?.id) {
setTrainingHome(null)
- setTrainingHomeErr(null)
- return undefined
- }
- let cancelled = false
- ;(async () => {
- setTrainingHomeErr(null)
- try {
- const today = new Date().toISOString().slice(0, 10)
- const [plannedPoolRaw, reviewPendingRaw] = await Promise.all([
- api.listTrainingUnits({
- assigned_to_me: true,
- status: 'planned',
- start_date: today,
- sort: 'asc',
- limit: 40,
- }),
- api.listTrainingUnits({
- assigned_to_me: true,
- debrief_pending: true,
- sort: 'desc',
- limit: 8,
- }),
- ])
- const plannedPool = Array.isArray(plannedPoolRaw) ? plannedPoolRaw : []
- const upcoming = plannedPool.slice(0, 8)
- const noteHits = plannedPool
- .filter((u) => {
- const tn = (u.trainer_notes || '').trim()
- const n = (u.notes || '').trim()
- return Boolean(tn || n)
- })
- .slice(0, 5)
- if (!cancelled) {
- setTrainingHome({
- upcoming,
- reviewPending: Array.isArray(reviewPendingRaw) ? reviewPendingRaw : [],
- plannedWithNotes: noteHits,
- })
- }
- } catch (e) {
- if (!cancelled) {
- console.error('Dashboard Trainingsübersicht:', e)
- setTrainingHomeErr(e.message || 'Konnte Trainingsdaten nicht laden')
- setTrainingHome(null)
- }
- }
- })()
- return () => {
- cancelled = true
- }
- }, [user?.id, tenantClubDepKey])
-
- useEffect(() => {
- if (!user?.id) {
setPhase0Stats(null)
- setPhase0Err(null)
+ setDashboardKpisErr(null)
return undefined
}
let cancelled = false
;(async () => {
- setPhase0Err(null)
+ setDashboardKpisErr(null)
try {
const data = await api.getDashboardKpis()
- if (!cancelled && data && typeof data === 'object') {
- setPhase0Stats({
- year: data.year,
- draftCount: data.draft_count,
- draftCapped: Boolean(data.draft_capped),
- draftPreview: Array.isArray(data.draft_preview) ? data.draft_preview : [],
- mineCount: data.mine_count ?? 0,
- mineCapped: Boolean(data.mine_capped),
- ytdCompletedCount: data.ytd_completed_count ?? 0,
- ytdCapped: Boolean(data.ytd_capped),
- })
- }
+ if (cancelled || !data || typeof data !== 'object') return
+ const th = data.training_home && typeof data.training_home === 'object' ? data.training_home : {}
+ setTrainingHome({
+ upcoming: Array.isArray(th.upcoming) ? th.upcoming : [],
+ reviewPending: Array.isArray(th.review_pending) ? th.review_pending : [],
+ plannedWithNotes: Array.isArray(th.planned_with_notes) ? th.planned_with_notes : [],
+ })
+ setPhase0Stats({
+ year: data.year,
+ draftCount: data.draft_count,
+ draftCapped: Boolean(data.draft_capped),
+ draftPreview: Array.isArray(data.draft_preview) ? data.draft_preview : [],
+ mineCount: data.mine_count ?? 0,
+ mineCapped: Boolean(data.mine_capped),
+ ytdCompletedCount: data.ytd_completed_count ?? 0,
+ ytdCapped: Boolean(data.ytd_capped),
+ })
} catch (e) {
if (!cancelled) {
- console.error('Dashboard Übungs-Kennzahlen:', e)
- setPhase0Err(e.message || 'Konnte Übungs-Kennzahlen nicht laden')
+ console.error('Dashboard KPIs / Trainingsübersicht:', e)
+ setDashboardKpisErr(e.message || 'Konnte Dashboard-Daten nicht laden')
+ setTrainingHome(null)
setPhase0Stats(null)
}
}
@@ -161,15 +112,15 @@ function Dashboard() {
- {phase0Err ? (
+ {dashboardKpisErr ? (
- {phase0Err}
+ {dashboardKpisErr}
) : null}
- {!phase0Err && !phase0Stats ? (
+ {!dashboardKpisErr && !phase0Stats ? (
Zahlen werden geladen…
) : null}
- {!phase0Err && phase0Stats ? (
+ {!dashboardKpisErr && phase0Stats ? (
@@ -203,7 +154,7 @@ function Dashboard() {
) : null}
- {!phase0Err && phase0Stats?.draftPreview?.length ? (
+ {!dashboardKpisErr && phase0Stats?.draftPreview?.length ? (
Entwürfe fertigstellen
@@ -248,8 +199,8 @@ function Dashboard() {
Nächste Termine
- {trainingHomeErr ? (
-
{trainingHomeErr}
+ {dashboardKpisErr ? (
+
{dashboardKpisErr}
) : trainingHome?.upcoming?.length ? (