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 ? (
{rowInner}
-
- )
- }
- return (
-
-
-
-
+ ) : (
+
+ )}
+
)
})}
-
+
{hasMore && (
+ }
+ >
+
+
) : (
<>
@@ -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) ? (
-
handleDelete(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);