From 9da29a22310578f7c124bb73df5bef700cac52ce Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 14 May 2026 08:59:06 +0200 Subject: [PATCH] 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);