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 ? (
    {trainingHome.upcoming.map((u) => ( @@ -278,8 +229,8 @@ function Dashboard() {

    Hinweise (anstehend)

    - {trainingHomeErr ? ( -

    {trainingHomeErr}

    + {dashboardKpisErr ? ( +

    {dashboardKpisErr}

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

      Offene Rückschau

      - {trainingHomeErr ? ( -

      {trainingHomeErr}

      + {dashboardKpisErr ? ( +

      {dashboardKpisErr}

      ) : trainingHome?.reviewPending?.length ? (
        {trainingHome.reviewPending.map((u) => ( diff --git a/scripts/load/README.md b/scripts/load/README.md index 623954b..4833161 100644 --- a/scripts/load/README.md +++ b/scripts/load/README.md @@ -28,3 +28,7 @@ BASE_URL=https://dev.shinkan.jinkendo.de k6 run scripts/load/k6-health-baseline. In der k6-Zusammenfassung `http_req_duration` → **p(95)** in [BASELINE_SNAPSHOT.md](../../docs/architecture/BASELINE_SNAPSHOT.md) eintragen. Schwellwerte sind bewusst locker (`p95 < 3s`); bei Fehlschlag Proxy, Netz oder Backend prüfen. + +## EXPLAIN (Phase 2) + +Datei **`explain-readpaths.sql`**: Vorlagen für `EXPLAIN (ANALYZE, BUFFERS)` auf der Ziel-DB (manuell, nicht CI). diff --git a/scripts/load/explain-readpaths.sql b/scripts/load/explain-readpaths.sql new file mode 100644 index 0000000..e1c7efc --- /dev/null +++ b/scripts/load/explain-readpaths.sql @@ -0,0 +1,56 @@ +-- Phase 2: Vorlagen für EXPLAIN (ANALYZE, BUFFERS) auf Ziel-DB mit realistischem Datenbestand. +-- Ersetzen: :token (Session), ggf. :club_id / :group_id nach Tenant; in psql: \set token '...' +-- Hinweis: Routen sind auth-geschützt — sinnvoll mit Rolle ausführen, die der API entspricht, +-- oder SQL aus Postgres-Logs normalisieren. + +-- GET /api/exercises — typische Liste (Filter anpassen) +EXPLAIN (ANALYZE, BUFFERS) +SELECT e.id, e.title +FROM exercises e +WHERE e.status <> 'archived' + AND e.visibility IN ('private', 'club', 'official') +ORDER BY e.updated_at DESC, e.id DESC +LIMIT 50; + +-- GET /api/exercises — mit Stufenfilter (nutzt idx_exercise_skills_exercise_level_rank) +EXPLAIN (ANALYZE, BUFFERS) +SELECT e.id, e.title +FROM exercises e +WHERE e.status <> 'archived' + AND EXISTS ( + SELECT 1 FROM exercise_skills es + WHERE es.exercise_id = e.id + AND ( + CASE COALESCE( + NULLIF(TRIM(LOWER(es.target_level::text)), ''), + NULLIF(TRIM(LOWER(es.required_level::text)), '') + ) + WHEN 'basis' THEN 1 + WHEN 'grundlagen' THEN 2 + WHEN 'aufbau' THEN 3 + WHEN 'fortgeschritten' THEN 4 + WHEN 'optimierung' THEN 5 + WHEN 'einsteiger' THEN 1 + WHEN 'experte' THEN 5 + WHEN '1' THEN 1 + WHEN '2' THEN 2 + WHEN '3' THEN 3 + WHEN '4' THEN 4 + WHEN '5' THEN 5 + ELSE NULL END + ) BETWEEN 2 AND 4 + ) +ORDER BY e.updated_at DESC, e.id DESC +LIMIT 50; + +-- GET /api/training-units — Kalenderliste (ohne Blueprint) +EXPLAIN (ANALYZE, BUFFERS) +SELECT tu.id, tu.planned_date, tu.planned_time_start +FROM training_units tu +LEFT JOIN training_groups tg ON tu.group_id = tg.id +WHERE tu.framework_slot_id IS NULL +ORDER BY tu.planned_date ASC, + (tu.planned_time_start IS NULL) ASC, + tu.planned_time_start ASC NULLS LAST, + tu.id ASC +LIMIT 40; diff --git a/tests/dev-smoke-test.spec.js b/tests/dev-smoke-test.spec.js index 553f6d4..6bfc457 100644 --- a/tests/dev-smoke-test.spec.js +++ b/tests/dev-smoke-test.spec.js @@ -159,10 +159,10 @@ test('7. Session-Persistenz nach Reload', async ({ page }) => { }); /** - * Refaktor Phase 2 (Dashboard): Kurzüberblick per GET /api/dashboard/kpis; genau zwei GET /api/training-units (Übersicht). + * Phase 2 (Dashboard): ein GET /api/dashboard/kpis (KPIs + Trainings-Home); keine direkten GET /api/training-units vom Dashboard. * Production-ähnlicher Build empfohlen (kein React StrictMode-Doppel-Mount im lokalen Vite-Dev). */ -test('8. Dashboard API-Budget nach Reload (profiles/me, training-units, dashboard/kpis)', async ({ page }) => { +test('8. Dashboard API-Budget nach Reload (profiles/me, dashboard/kpis)', async ({ page }) => { await login(page); let profilesMe = 0; @@ -197,13 +197,13 @@ test('8. Dashboard API-Budget nach Reload (profiles/me, training-units, dashboar }); expect(profilesMe).toBe(1); - expect(trainingUnits).toBe(2); + expect(trainingUnits).toBe(0); expect(dashboardKpis).toBe(1); } finally { page.off('request', onRequest); } - console.log('✓ Dashboard API-Budget: 1× profiles/me, 2× training-units, 1× dashboard/kpis'); + console.log('✓ Dashboard API-Budget: 1× profiles/me, 0× training-units, 1× dashboard/kpis'); }); test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => { From 9da29a22310578f7c124bb73df5bef700cac52ce Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 14 May 2026 08:59:06 +0200 Subject: [PATCH 17/18] chore(version): update version and changelog for release 0.8.119 - Bumped APP_VERSION to 0.8.119 and updated the changelog to reflect new features. - Introduced the ExerciseListCard component and implemented lazy loading for the Progression Tab using React's Suspense. - Enhanced the ExercisePickerModal with virtualization for improved performance using @tanstack/react-virtual. - Updated documentation to reflect the new app version and its corresponding changes. --- backend/version.py | 9 +- docs/HANDOVER.md | 6 +- docs/architecture/UMSETZUNGSPLAN_ROADMAP.md | 6 +- frontend/package.json | 1 + frontend/src/app.css | 2 + .../src/components/ExercisePickerModal.jsx | 100 +++++---- .../components/exercises/ExerciseListCard.jsx | 174 ++++++++++++++++ frontend/src/pages/ExercisesListPage.jsx | 192 +++--------------- tests/dev-smoke-test.spec.js | 14 ++ 9 files changed, 298 insertions(+), 206 deletions(-) create mode 100644 frontend/src/components/exercises/ExerciseListCard.jsx diff --git a/backend/version.py b/backend/version.py index 06935f4..be158f1 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.118" +APP_VERSION = "0.8.119" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260514062" @@ -36,6 +36,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.119", + "date": "2026-05-13", + "changes": [ + "Frontend Phase 3 (Teil): Übungsliste — ExerciseListCard-Komponente, Progressions-Tab lazy (Suspense); Übungspicker-Modal mit @tanstack/react-virtual; content-visibility auf Karten im Übungs-Gitter; Playwright-Test 9 Übungsliste.", + ], + }, { "version": "0.8.118", "date": "2026-05-14", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index a4e4b32..7879f36 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.118**, DB-Schema **`20260514062`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`) +**Stand:** 2026-05-13 +**App-Version / DB-Schema:** App **0.8.119**, DB-Schema **`20260514062`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`) Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**. @@ -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.118**) +### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.119**) - **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§ 10.2.1** IDs, **§ 10.4** Coaching-Stufen, **§ 10.6** Produkt-Backlog, **Anhang A** Abgleich). - **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4a–g** — u. a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung). diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md index 530bfbf..ffc57fd 100644 --- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md +++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md @@ -7,9 +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 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. - -**Ziel:** Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**. +- **Phase 3 (gestartet 2026-05-13):** Übungsliste — extrahierte Karte, **virtualisierter** Picker, **lazy** Progressions-Panel; Playwright **Test 9**; Grid `data-testid`. Weiter: God-Pages (Planung/Formular) zerteilen. Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**. **Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md). --- @@ -82,6 +80,8 @@ | Virtualisierung für die längste produktive Liste | A1, S2 | | Schwere Imports auf `import()` umziehen (gezielt) | A4 | +**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten in `components/exercises/ExerciseListCard.jsx`; Tab „Progressionsgraphen“ lädt **`ExerciseProgressionGraphPanel`** per `React.lazy` + `Suspense`; **`ExercisePickerModal`** virtualisiert (`@tanstack/react-virtual`, Scroll-Container `data-testid="exercise-picker-scroll"`); Gitter `data-testid="exercises-list-grid"` + `content-visibility` in `app.css`; Playwright **Test 9**. Offen: Seite unter Soft-Limit (~500 Zeilen), vollständige Zerteilung `TrainingPlanningPage` / `ExerciseFormPage`. + **Abnahme:** Referenz-Page unter Soft-Limit; Regel S1 für neue Änderungen durchsetzbar. --- diff --git a/frontend/package.json b/frontend/package.json index 9ad335a..876f479 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-virtual": "^3.13.24", "jspdf": "^4.2.1", "lucide-react": "^0.344.0", "marked": "^18.0.3", diff --git a/frontend/src/app.css b/frontend/src/app.css index 2503d53..a5ea375 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2578,6 +2578,8 @@ a.analysis-split__nav-item { .exercises-list-grid > .exercise-card { height: 100%; min-height: 0; + content-visibility: auto; + contain-intrinsic-size: auto 240px; } .exercise-card-layout { display: flex; diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 952c232..5d04d66 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -2,7 +2,8 @@ * Übungssuche mit Volltext-, KI-/Semantikfeld (aktuell gleiche Engine wie Suche) und erweiterten Filtern. * Paginierung bis max. 100 Treffer pro Request (API-Limit). */ -import React, { useState, useEffect, useMemo, useCallback } from 'react' +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' import api from '../utils/api' import { useAuth } from '../context/AuthContext' import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' @@ -59,6 +60,7 @@ export default function ExercisePickerModal({ const [quickTitle, setQuickTitle] = useState('') const [quickSummary, setQuickSummary] = useState('') const [quickSaving, setQuickSaving] = useState(false) + const pickerScrollRef = useRef(null) const toggleMultiPick = (ex) => { setMultiPicked((prev) => @@ -276,6 +278,14 @@ export default function ExercisePickerModal({ } } + const rowVirtualizer = useVirtualizer({ + count: list.length, + getScrollElement: () => pickerScrollRef.current, + estimateSize: () => 88, + overscan: 8, + getItemKey: (index) => String(list[index]?.id ?? index), + }) + const resetFilters = () => setFilters({ ...INITIAL_FILTERS }) const submitQuickCreate = async () => { @@ -585,7 +595,11 @@ export default function ExercisePickerModal({
    -
    +
    {!catalogsReady || (loading && list.length === 0) ? (
    @@ -597,8 +611,18 @@ export default function ExercisePickerModal({

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

    -
      - {list.map((ex) => { +
      + {rowVirtualizer.getVirtualItems().map((vi) => { + const ex = list[vi.index] + if (!ex) return null const picked = multiPicked.some((p) => p.id === ex.id) const rowInner = ( <> @@ -630,9 +654,22 @@ export default function ExercisePickerModal({ ) : null} ) - if (multiSelect) { - return ( -
    • + return ( +
      + {multiSelect ? ( -
    • - ) - } - return ( -
    • - -
    • + ) : ( + + )} +
      ) })} -
    +
    {hasMore && (
    + ) : null} +
    +
    +
    + ) +} diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index 456a955..1cd50ad 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -1,17 +1,5 @@ -import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react' +import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react' import { Link } from 'react-router-dom' -import { - Eye, - Pencil, - Trash2, - Globe, - Users, - Lock, - CheckCircle2, - Archive, - CircleDot, - FilePenLine, -} from 'lucide-react' import api from '../utils/api' import { useAuth } from '../context/AuthContext' import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub' @@ -19,9 +7,8 @@ import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' import MultiSelectCombo from '../components/MultiSelectCombo' import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker' import CatalogRulePicker from '../components/CatalogRulePicker' -import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel' -import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock' import PageSectionNav from '../components/PageSectionNav' +import ExerciseListCard from '../components/exercises/ExerciseListCard' import { INITIAL_EXERCISE_LIST_FILTERS, mergeExerciseListPrefsFromApi, @@ -29,8 +16,8 @@ import { splitMnCatalogRules, splitScalarCatalogRules, } from '../constants/exerciseListFilters' -import { coerceApiNameList } from '../utils/sanitizeHtml' -import { canUserRequestExerciseDelete } from '../utils/exercisePermissions' + +const ExerciseProgressionGraphPanel = lazy(() => import('../components/ExerciseProgressionGraphPanel')) const PAGE_SIZE = 100 const BULK_MAX_IDS = 500 @@ -40,22 +27,6 @@ const EXERCISES_PAGE_TABS = [ ] const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) -const VIS_LABELS = { official: 'Global', club: 'Verein', private: 'Privat' } -const STATUS_LABELS = { - draft: 'Entwurf', - in_review: 'In Prüfung', - approved: 'Freigegeben', - archived: 'Archiv', -} - -function visibilityLabel(v) { - return VIS_LABELS[v] || v || '—' -} - -function statusLabel(s) { - return STATUS_LABELS[s] || s || '—' -} - function pushCatalogRuleFilterChips(chips, field, rules, options, topicLabel, setFilters) { ;(rules || []).forEach((r) => { const rid = String(r.id ?? r.focus_area_id ?? '') @@ -72,54 +43,6 @@ function pushCatalogRuleFilterChips(chips, field, rules, options, topicLabel, se }) } -function exerciseFocusNames(ex) { - const fromApi = coerceApiNameList(ex.focus_area_names) - if (fromApi.length) return fromApi - if (ex.focus_area) return [ex.focus_area] - return [] -} - -function exerciseCardClassName(exercise, userId) { - const vis = exercise.visibility || 'private' - const visKey = vis === 'official' || vis === 'club' || vis === 'private' ? vis : 'private' - const mine = userId != null && Number(exercise.created_by) === Number(userId) - return ['card', 'exercise-card', `exercise-card--scope-${visKey}`, mine ? 'exercise-card--mine' : ''] - .filter(Boolean) - .join(' ') -} - -function ExerciseCardScopeStatus({ exercise }) { - const v = exercise.visibility || 'private' - const s = exercise.status || 'draft' - const visLabel = visibilityLabel(v) - const stLabel = statusLabel(s) - const tip = `${visLabel} · ${stLabel}` - let VisIcon = Lock - if (v === 'official') VisIcon = Globe - else if (v === 'club') VisIcon = Users - let StatIcon = FilePenLine - if (s === 'approved') StatIcon = CheckCircle2 - else if (s === 'archived') StatIcon = Archive - else if (s === 'in_review') StatIcon = CircleDot - return ( -
    - - - - - · - - - - -
    - ) -} - function levelOptionShort(levelStr) { const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr)) return o ? String(o.level) : String(levelStr) @@ -835,7 +758,18 @@ function ExercisesListPage() { /> {pageTab === 'progression' ? ( - + +
    +

    + Lade Progressionsgraphen… +

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

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

    - - {exercise.title} - -

    -
    - {focusNames.map((name) => ( - {name} - ))} - {styleNames.map((name) => ( - {name} - ))} - {typeNames.map((name) => ( - {name} - ))} - {(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' ? ( - - Kombination - - ) : null} -
    - {exercise.summary && String(exercise.summary).trim() ? ( -
    - -
    - ) : null} -
    -
    -
    - -
    - - - - - - - {canUserRequestExerciseDelete(user, exercise) ? ( - - ) : null} -
    -
    -
    - ) - })} +
    + {exercises.map((exercise) => ( + + ))}
    {hasMore && (
    diff --git a/tests/dev-smoke-test.spec.js b/tests/dev-smoke-test.spec.js index 6bfc457..e498118 100644 --- a/tests/dev-smoke-test.spec.js +++ b/tests/dev-smoke-test.spec.js @@ -206,6 +206,20 @@ test('8. Dashboard API-Budget nach Reload (profiles/me, dashboard/kpis)', async console.log('✓ Dashboard API-Budget: 1× profiles/me, 0× training-units, 1× dashboard/kpis'); }); +test('9. Übungsliste: nach Laden entweder Treffer-Gitter oder Leerhinweis', async ({ page }) => { + await login(page); + await page.goto('/exercises', { waitUntil: 'networkidle' }); + const main = page.locator('.app-main'); + await expect(main.getByRole('heading', { level: 1, name: /Übungen/i })).toBeVisible({ + timeout: 15000, + }); + await expect(main.locator('.spinner')).toHaveCount(0, { timeout: 20000 }); + const grid = main.getByTestId('exercises-list-grid'); + const empty = main.locator('.exercises-empty-text'); + await expect(grid.or(empty).first()).toBeVisible({ timeout: 15000 }); + console.log('✓ Übungsliste: Endzustand sichtbar (Gitter oder leer)'); +}); + test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); await login(page); From 930a7863155020448beed0edcd6ada7a189e08cf Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 14 May 2026 09:05:15 +0200 Subject: [PATCH 18/18] refactor(ui): enhance styling and structure of training unit sections and combination plan bracket - Updated CSS for training unit sections to improve layout and responsiveness, ensuring combo planning strips are displayed correctly. - Refactored CombinationPlanBracket component to accept additional class names for better customization. - Removed unused functions and streamlined imports in TrainingUnitSectionsEditor for cleaner code. - Reintroduced ExercisePickerModal with improved placement in ExerciseFormPage for better user experience. --- frontend/src/app.css | 70 ++++++- .../src/components/CombinationPlanBracket.jsx | 3 +- .../components/TrainingUnitSectionsEditor.jsx | 182 +++++------------- frontend/src/pages/ExerciseFormPage.jsx | 25 +-- 4 files changed, 126 insertions(+), 154 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index a5ea375..64c3d7a 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -5414,22 +5414,80 @@ a.analysis-split__nav-item { 0 2px 12px rgba(15, 23, 42, 0.05); } -/* Kombinations‑Strip: volle Breite unter der Zeile, begrenzte Textbreite — Hauptzeile (Name/Min.) nicht verdrängen */ +/* Kombinationszeile: immer unter Hauptzeile (Titel / Minuten / Aktionen), nicht daneben */ +.training-unit-sections-editor .tu-item-row--exercise.tu-item-row--combo { + flex-direction: column; + align-items: stretch; + flex-wrap: nowrap; + gap: 0; +} + +.training-unit-sections-editor .tu-item-row--exercise.tu-item-row--combo .tu-item-row__mainline { + flex: none; + width: 100%; +} + +/* Kombinations‑Strip: volle Breite; oben „Ablauf bearbeiten“, darunter Klammer‑Vorschau */ .training-unit-sections-editor .tu-combo-planning-strip { display: flex; flex-direction: column; align-items: stretch; gap: 10px; + padding: 10px 12px 12px; + border-top: 1px solid color-mix(in srgb, var(--border2) 85%, var(--accent) 12%); + background: color-mix(in srgb, var(--surface2) 65%, var(--surface)); + margin-top: 2px; } -.training-unit-sections-editor .tu-combo-planning-strip__meta { - width: 100%; - max-width: min(100%, 42rem); +.training-unit-sections-editor--item-drag .tu-item-row--combo .tu-combo-planning-strip { + padding-left: 44px; +} + +.training-unit-sections-editor .tu-combo-planning-strip__toolbar { + display: flex; + justify-content: flex-end; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.training-unit-sections-editor .tu-combo-planning-strip__meta--fallback { + font-size: 0.78rem; + color: var(--text2); + line-height: 1.45; +} + +.training-unit-sections-editor .tu-combo-planning-strip__bracket-wrap { min-width: 0; + overflow-x: auto; } -.training-unit-sections-editor .tu-combo-planning-strip > .btn { - align-self: flex-start; +.training-unit-sections-editor .combo-plan-bracket--planning-embed { + font-size: 0.93rem; +} + +.training-unit-sections-editor .combo-plan-bracket--planning-embed .combo-plan-bracket__station { + padding: 8px 9px; +} + +.training-unit-sections-editor .combo-plan-bracket--planning-embed .combo-plan-bracket__chip { + padding: 5px 8px; +} + +.training-unit-sections-editor .combo-plan-bracket--planning-embed .combo-plan-bracket__globals-title { + font-size: 0.72rem; +} + +.training-unit-sections-editor .combo-plan-bracket--planning-embed .combo-plan-bracket__head-main { + flex-wrap: wrap; +} + +.training-unit-sections-editor .combo-plan-bracket--planning-embed .combo-plan-bracket__kicker { + font-size: 0.62rem; +} + +.training-unit-sections-editor .combo-plan-bracket--planning-embed .combo-plan-bracket__archetype { + font-size: 0.88rem; } .tu-planning-mod-tag { diff --git a/frontend/src/components/CombinationPlanBracket.jsx b/frontend/src/components/CombinationPlanBracket.jsx index 28438ee..cffe8bd 100644 --- a/frontend/src/components/CombinationPlanBracket.jsx +++ b/frontend/src/components/CombinationPlanBracket.jsx @@ -41,6 +41,7 @@ export default function CombinationPlanBracket({ /** 'none' | 'link' (Router) | 'button' (z. B. ExercisePeekModal / PWA-sicher) */ candidateInteraction = 'none', onCandidatePeek, + className, }) { const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : '' const archLabel = arch ? combinationArchetypeLabel(arch) : null @@ -59,7 +60,7 @@ export default function CombinationPlanBracket({ const coachHint = arch ? archetypeCoachHint(arch) : '' return ( -
    +
    diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx index 995802d..7ac7580 100644 --- a/frontend/src/components/TrainingUnitSectionsEditor.jsx +++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx @@ -3,7 +3,7 @@ import { GripVertical, Pencil } from 'lucide-react' import CombinationMethodProfileEditor from './CombinationMethodProfileEditor' import CombinationPlanBracket from './CombinationPlanBracket' import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile' -import { combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes' +import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes' import { cloneJsonSerializablePlanningProfile, comboSlotsOutlineForProfileEditor, @@ -13,7 +13,6 @@ import { sectionPlannedMinutes, } from '../utils/trainingUnitSectionsForm' import api from '../utils/api' -import { effectiveStationTimingSummary, readSlotProfilesV1 } from '../utils/combinationMethodProfileUi' import { isCompactTagLegendMode } from '../config/planningModuleUx' import { useAuth } from '../context/AuthContext' @@ -74,60 +73,6 @@ function compactComboPlanningCaption(it) { return overridden ? 'Planung angepasst' : 'wie Katalog' } -/** Globale Eckdaten aus effective profile (optional unter Stationenliste). */ -function comboRoughGlobalTimingHint(profileObj, archetypeKey) { - if (!profileObj || typeof profileObj !== 'object' || Array.isArray(profileObj)) return null - const bits = [] - const rounds = profileObj.rounds - const ws = profileObj.work_seconds - const rb = profileObj.rest_between_rounds_sec - const hint = profileObj.hint_step_duration_sec - const globRest = profileObj.rest_between_sets_sec - if (rounds != null && rounds !== '') bits.push(`${rounds} Runden`) - if (ws != null && ws !== '') bits.push(`${ws}s Arbeit`) - if (rb != null && rb !== '') bits.push(`Pause ${rb}s`) - if (globRest != null && globRest !== '') bits.push(`Sets-Pause ${globRest}s`) - if (hint != null && hint !== '') bits.push(`Orientierung ~${hint}s`) - const arch = (archetypeKey || '').trim() - if (arch === 'time_domain_interval') { - const iw = profileObj.interval_work_sec - const ir = profileObj.interval_rest_sec - const ig = profileObj.interval_groups - if (iw != null && iw !== '') bits.push(`${iw}s Intervall`) - if (ir != null && ir !== '') bits.push(`${ir}s Erholung`) - if (ig != null && ig !== '') bits.push(`${ig} Gruppen`) - } - return bits.length ? bits.join(' · ') : null -} - -/** Pro Station eine kompakte Textzeile für die Planungsliste. */ -function comboPlanningStripBulletTexts(it) { - const slots = sortCombinationSlotsForDisplay(it.combination_slots || []) - if (!slots.length) return [] - const mp = effectiveComboMethodProfile(it.catalog_method_profile || {}, it.planning_method_profile) - const archRaw = String(it.catalog_method_archetype || '').trim() - const byIx = new Map(readSlotProfilesV1(mp).map((r) => [Number(r.slot_index), r])) - const titles = it.combo_member_title_by_id || {} - return slots.map((slot, idx) => { - const siRaw = slot.slot_index - const siParsed = - siRaw === '' || siRaw == null ? idx : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10) - const ix = Number.isFinite(siParsed) ? siParsed : idx - const stationLbl = ((slot.title || '').trim() || `Station ${idx + 1}`) - const candIds = (slot.candidate_exercise_ids || []) - .map((raw) => (typeof raw === 'number' ? raw : parseInt(String(raw), 10))) - .filter((n) => Number.isFinite(n)) - const namesJoined = - candIds.length === 0 - ? '(keine Übung)' - : candIds.map((id) => titles[String(id)] || `Übung ${id}`).join(' ↔ ') - const timing = effectiveStationTimingSummary(archRaw, mp, byIx.get(ix)) - let line = `${stationLbl}: ${namesJoined}` - if (timing) line += ` · ${timing}` - return line - }) -} - /** Stabile Farbzurodnung aus Modul-ID (nur Darstellung). */ function planningModulePalette(moduleId) { const id = normalizedPlanningModuleChainId(moduleId) @@ -703,7 +648,8 @@ export default function TrainingUnitSectionsEditor({
    {(!hideHeading || headingAccessory) ? ( @@ -1017,10 +963,6 @@ export default function TrainingUnitSectionsEditor({ const stripArchRaw = isCombination && it.exercise_id ? String(it.catalog_method_archetype || '').trim() : '' - const stripArchLbl = - stripArchRaw && isCombination ? combinationArchetypeLabel(stripArchRaw) : null - const stripBullets = - isCombination && it.exercise_id ? comboPlanningStripBulletTexts(it) : [] const stripMpEff = isCombination && it.exercise_id ? effectiveComboMethodProfile( @@ -1028,17 +970,15 @@ export default function TrainingUnitSectionsEditor({ it.planning_method_profile, ) : null - const stripGlobalRough = - isCombination && it.exercise_id && stripMpEff - ? comboRoughGlobalTimingHint(stripMpEff, stripArchRaw) - : null return ( {!planningCompactLegend && renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
    @@ -1215,76 +1155,48 @@ export default function TrainingUnitSectionsEditor({
    {isCombination && it.exercise_id ? ( -
    -
    -
    - Archetyp:  - - {stripArchLbl || stripArchRaw || '—'} - - - {compactComboPlanningCaption(it)} - -
    - {stripGlobalRough ? ( -
    - Block:  - {stripGlobalRough} -
    - ) : null} - {stripBullets.length > 0 ? ( -
      - {stripBullets.map((line, bi) => ( -
    • - {line} -
    • - ))} -
    - ) : ( -
    - Stationen laden oder noch keine Kombi-Stationen im Katalog … -
    - )} +
    +
    +
    - + {(it.combination_slots || []).length > 0 ? ( +
    + onPeekExercise(Number(exId), null, undefined) + : undefined + } + /> +
    + ) : ( +
    +
    + Stationen werden geladen oder die Kombination hat im Katalog noch keine Stationsliste … +
    +
    + )}
    ) : null} diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx index 975fa07..b82904a 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -2403,18 +2403,6 @@ function ExerciseFormPage() { } /> )} - setComboStationPickerIx(null)} - exerciseKindAny={['simple']} - multiSelect - enableQuickCreateDraft - onSelectExercises={(picked) => { - if (comboStationPickerIx === null) return - mergePickedExercisesIntoSlot(comboStationPickerIx, picked) - setComboStationPickerIx(null) - }} - /> {reportTarget && ( )} + setComboStationPickerIx(null)} + exerciseKindAny={['simple']} + multiSelect + enableQuickCreateDraft + onSelectExercises={(picked) => { + if (comboStationPickerIx === null) return + mergePickedExercisesIntoSlot(comboStationPickerIx, picked) + setComboStationPickerIx(null) + }} + /> +

    KI-Ausbaustufe: Backend laut Spec{' '} POST /api/exercises/ai/suggest und{' '}