From 57a8957c93d7a873c72ebe3b7a72198c33f66858 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 14 May 2026 10:58:41 +0200 Subject: [PATCH 1/3] chore(version): update version and changelog for release 0.8.121 - Bumped APP_VERSION to 0.8.121 and updated the changelog to reflect new features. - Introduced the ExerciseListFilterModal and ExerciseListBulkModal components, enhancing the exercise list functionality. - Modularized the ExerciseListPage to improve code organization and maintainability. - Added Playwright tests for the filter dialog functionality, ensuring proper user interaction and visibility. --- backend/version.py | 16 +- docs/HANDOVER.md | 4 +- docs/architecture/UMSETZUNGSPLAN_ROADMAP.md | 6 +- .../exercises/ExerciseListBulkModal.jsx | 240 +++++ .../exercises/ExerciseListFilterModal.jsx | 236 +++++ .../exercises/ExerciseListSearchBar.jsx | 107 +++ frontend/src/pages/ExercisesListPage.jsx | 871 +++--------------- frontend/src/utils/exerciseListFilterChips.js | 188 ++++ frontend/src/utils/exerciseListQuery.js | 83 ++ tests/dev-smoke-test.spec.js | 17 + 10 files changed, 995 insertions(+), 773 deletions(-) create mode 100644 frontend/src/components/exercises/ExerciseListBulkModal.jsx create mode 100644 frontend/src/components/exercises/ExerciseListFilterModal.jsx create mode 100644 frontend/src/components/exercises/ExerciseListSearchBar.jsx create mode 100644 frontend/src/utils/exerciseListFilterChips.js create mode 100644 frontend/src/utils/exerciseListQuery.js diff --git a/backend/version.py b/backend/version.py index be158f1..5cda9fe 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.119" +APP_VERSION = "0.8.121" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260514062" @@ -36,6 +36,20 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.121", + "date": "2026-05-13", + "changes": [ + "Frontend Phase 3 (Teil): Übungsliste — Suchleiste/Chips in ExerciseListSearchBar; API-Query-Bau und Filter-Chips in utils/exerciseListQuery.js bzw. exerciseListFilterChips.js.", + ], + }, + { + "version": "0.8.120", + "date": "2026-05-13", + "changes": [ + "Frontend: Übungsliste — Filter- und Massenänderungs-Dialoge in ExerciseListFilterModal / ExerciseListBulkModal ausgelagert; Playwright-Test 10 (Filter-Dialog).", + ], + }, { "version": "0.8.119", "date": "2026-05-13", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 7879f36..e52a4ef 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,7 +1,7 @@ # Shinkan Jinkendo – Entwicklungsstand & Handover **Stand:** 2026-05-13 -**App-Version / DB-Schema:** App **0.8.119**, DB-Schema **`20260514062`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`) +**App-Version / DB-Schema:** App **0.8.120**, 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.119**) +### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.120**) - **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 ffc57fd..ba2f011 100644 --- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md +++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md @@ -7,7 +7,9 @@ - **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 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**. +- **Phase 3 (gestartet 2026-05-13):** Übungsliste modularisiert (Karte, Filter-/Bulk-Modals, virtualisierter Picker, lazy Progression); Playwright **Tests 9–10**. Weiter: God-Pages (Planung/Formular). + +**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). --- @@ -80,7 +82,7 @@ | 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`. +**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten `ExerciseListCard.jsx`; Filter/Massenänderung `ExerciseListFilterModal.jsx` / `ExerciseListBulkModal.jsx`; Tab „Progressionsgraphen“ **lazy**; **Picker** virtualisiert; Gitter `data-testid` + `content-visibility`; Playwright **Tests 9–10**. Offen: Seite unter Soft-Limit (~500 Zeilen, derzeit ~918 LOC), Zerteilung Planung/Übungsformular. **Abnahme:** Referenz-Page unter Soft-Limit; Regel S1 für neue Änderungen durchsetzbar. diff --git a/frontend/src/components/exercises/ExerciseListBulkModal.jsx b/frontend/src/components/exercises/ExerciseListBulkModal.jsx new file mode 100644 index 0000000..0d1913a --- /dev/null +++ b/frontend/src/components/exercises/ExerciseListBulkModal.jsx @@ -0,0 +1,240 @@ +import React from 'react' +import { activeClubMemberships } from '../../utils/activeClub' +import MultiSelectCombo from '../MultiSelectCombo' + +/** + * Massenänderung für ausgewählte Übungen in der Liste. + */ +export default function ExerciseListBulkModal({ + open, + onClose, + onSubmit, + bulkSubmitting, + selectedCount, + bulkMaxIds, + user, + isPlatformAdmin, + statusOptions, + bulkVisibilityOptions, + focusOptions, + styleOptions, + trainingTypeOptions, + targetGroupOptions, + bulkVisibility, + setBulkVisibility, + bulkStatus, + setBulkStatus, + bulkClubSelect, + setBulkClubSelect, + bulkClubManual, + setBulkClubManual, + bulkPatchFocusAreas, + setBulkPatchFocusAreas, + bulkFocusAreaIds, + setBulkFocusAreaIds, + bulkPatchStyleDirections, + setBulkPatchStyleDirections, + bulkStyleDirectionIds, + setBulkStyleDirectionIds, + bulkPatchTrainingTypes, + setBulkPatchTrainingTypes, + bulkTrainingTypeIds, + setBulkTrainingTypeIds, + bulkPatchTargetGroups, + setBulkPatchTargetGroups, + bulkTargetGroupIds, + setBulkTargetGroupIds, +}) { + if (!open) return null + + return ( +
{ + if (e.target === e.currentTarget) onClose() + }} + > +
e.stopPropagation()} + > +
+

+ Massenänderung +

+ +
+
+

+ Es werden {selectedCount} Übung(en) aus der aktuellen Auswahl bearbeitet. Pro Durchlauf + höchstens {bulkMaxIds}. Ohne Berechtigung bleiben Einzelübungen unverändert (siehe Hinweis nach dem + Speichern). +

+

+ Unter „Zuordnung ersetzen“: die gewählte Liste ersetzt die bisherige Zuordnung bei allen betroffenen Übungen + vollständig (leere Auswahl = alle Zuordnungen dieser Kategorie entfernen). Die erste Auswahl gilt als + Primärzuordnung. +

+
+ + +
+ {bulkVisibility === 'club' ? ( +
+ + + {isPlatformAdmin ? ( + <> + + setBulkClubManual(e.target.value)} + /> + + ) : null} +
+ ) : null} +
+ + +
+ +
+

Zuordnung (optional)

+
+
+ + {bulkPatchFocusAreas ? ( + + ) : null} +
+
+ + {bulkPatchStyleDirections ? ( + + ) : null} +
+
+ + {bulkPatchTrainingTypes ? ( + + ) : null} +
+
+ + {bulkPatchTargetGroups ? ( + + ) : null} +
+
+
+
+
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/exercises/ExerciseListFilterModal.jsx b/frontend/src/components/exercises/ExerciseListFilterModal.jsx new file mode 100644 index 0000000..e9f0583 --- /dev/null +++ b/frontend/src/components/exercises/ExerciseListFilterModal.jsx @@ -0,0 +1,236 @@ +import React from 'react' +import { SKILL_LEVEL_OPTIONS } from '../../constants/skillLevels' +import MultiSelectCombo from '../MultiSelectCombo' +import ExerciseFocusRulePicker from '../ExerciseFocusRulePicker' +import CatalogRulePicker from '../CatalogRulePicker' + +const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) + +/** + * Filter-Dialog für die Übungsliste (gleiche Logik wie zuvor inline in ExercisesListPage). + */ +export default function ExerciseListFilterModal({ + open, + onClose, + filters, + setFilters, + focusOptions, + styleOptions, + trainingTypeOptions, + targetGroupOptions, + skillOptions, + visibilityOptions, + statusOptions, + savingExercisePrefs, + onSaveStandard, + onResetAll, +}) { + if (!open) return null + + return ( +
{ + if (e.target === e.currentTarget) onClose() + }} + > +
e.stopPropagation()} + > +
+

+ Übungen filtern +

+ +
+
+

+ Zwischen den Bereichen gilt UND. Fokusbereiche: mehrere „+ mit“ bedeuten alle müssen + gesetzt sein; „− ohne“ schließt Übungen aus, die diesen Fokus zusätzlich haben. Stilrichtung / + Trainingsstil / Zielgruppe: mehrere „+“ = alle zutreffend (UND); „−“ verbietet die Zuordnung. Unter + „Freigabe“: Sichtbarkeit / Status mit „+“ = eine davon (ODER); „−“ blendet aus. +

+ +
+

Zuordnung

+ setFilters((prev) => ({ ...prev, ...patch }))} + /> +
+ setFilters((prev) => ({ ...prev, ...patch }))} + /> + setFilters((prev) => ({ ...prev, ...patch }))} + /> + setFilters((prev) => ({ ...prev, ...patch }))} + /> +
+
+ +
+

Fähigkeit und zugehörige Stufe

+
+ + setFilters({ ...filters, skill_ids: v })} + options={skillOptions} + placeholder="Fähigkeit suchen …" + /> +

+ Die Stufen filtern nach dem Niveau der Zuordnung Übung ↔ Fähigkeit (von–bis). +

+
+ + + – + + +
+
+
+ +
+

Ausblenden / Liste

+

+ Sichtbarkeit und Status steuern Sie unter „Freigabe“ mit + und −. Hier nur globale Listen-Optionen. +

+
+ + +
+
+ +
+

Freigabe

+

+ Pro Übung nur ein Wert: mehrere „+“ bedeuten „eine davon“ (ODER). „−“ blendet Werte aus. +

+
+ setFilters((prev) => ({ ...prev, ...patch }))} + /> + setFilters((prev) => ({ ...prev, ...patch }))} + /> +
+
+
+
+ + + +
+
+
+ ) +} diff --git a/frontend/src/components/exercises/ExerciseListSearchBar.jsx b/frontend/src/components/exercises/ExerciseListSearchBar.jsx new file mode 100644 index 0000000..eaad9b9 --- /dev/null +++ b/frontend/src/components/exercises/ExerciseListSearchBar.jsx @@ -0,0 +1,107 @@ +import React from 'react' + +export default function ExerciseListSearchBar({ + searchTitleSuggestions, + searchInput, + onSearchInputChange, + aiSearchInput, + onAiSearchInputChange, + mineOnly, + onToggleMineOnly, + onOpenFilter, + filterChips, + onResetAllFilters, + exerciseCount, + allOnPageSelected, + onToggleSelectAllPage, +}) { + return ( +
+ + + {searchTitleSuggestions.map((t) => ( + + onSearchInputChange(e.target.value)} + autoComplete="on" + name="exercise-fulltext-search" + list="exercise-search-titles" + enterKeyHint="search" + /> + + onAiSearchInputChange(e.target.value)} + autoComplete="on" + name="exercise-ai-search" + list="exercise-search-titles" + enterKeyHint="search" + /> +
+
+ + + {filterChips.length > 0 ? ( + + ) : null} +
+
+ {filterChips.length > 0 ? ( +
+ {filterChips.map((c) => ( + + ))} +
+ ) : null} +

+ Trefferliste aktualisiert sich kurz nach Eingabe. Titel der aktuellen Liste erscheinen als Vorschläge (Pfeil im + Feld). Fachliche Filter über „Filter“ – zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER. + {exerciseCount > 0 ? ( + <> + {' '} + + + ) : null} +

+
+ ) +} diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index 1cd50ad..f58ae55 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -3,18 +3,17 @@ import { Link } from 'react-router-dom' import api from '../utils/api' import { useAuth } from '../context/AuthContext' import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub' -import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' -import MultiSelectCombo from '../components/MultiSelectCombo' -import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker' -import CatalogRulePicker from '../components/CatalogRulePicker' import PageSectionNav from '../components/PageSectionNav' import ExerciseListCard from '../components/exercises/ExerciseListCard' +import ExerciseListFilterModal from '../components/exercises/ExerciseListFilterModal' +import ExerciseListBulkModal from '../components/exercises/ExerciseListBulkModal' +import ExerciseListSearchBar from '../components/exercises/ExerciseListSearchBar' +import { buildExerciseListFilterChips } from '../utils/exerciseListFilterChips' +import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../utils/exerciseListQuery' import { INITIAL_EXERCISE_LIST_FILTERS, mergeExerciseListPrefsFromApi, compactExerciseListPrefsPayload, - splitMnCatalogRules, - splitScalarCatalogRules, } from '../constants/exerciseListFilters' const ExerciseProgressionGraphPanel = lazy(() => import('../components/ExerciseProgressionGraphPanel')) @@ -25,54 +24,6 @@ const EXERCISES_PAGE_TABS = [ { id: 'list', label: 'Liste' }, { id: 'progression', label: 'Progressionsgraphen' }, ] -const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) - -function pushCatalogRuleFilterChips(chips, field, rules, options, topicLabel, setFilters) { - ;(rules || []).forEach((r) => { - const rid = String(r.id ?? r.focus_area_id ?? '') - const opt = options.find((o) => String(o.id) === rid) - chips.push({ - key: `${field}-${r.key}`, - label: `${topicLabel}: ${r.mode === 'forbid' ? '−' : '+'} ${opt?.label ?? rid}`, - onRemove: () => - setFilters((prev) => ({ - ...prev, - [field]: (prev[field] || []).filter((x) => x.key !== r.key), - })), - }) - }) -} - -function levelOptionShort(levelStr) { - const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr)) - return o ? String(o.level) : String(levelStr) -} - -function applyDashboardExerciseListUrl(mergedFromPrefs) { - try { - const sp = new URLSearchParams(window.location.search) - const mine = sp.get('mine') === '1' || sp.get('created_by_me') === '1' - const statusDraft = sp.get('status') === 'draft' - - if (mine) { - const next = { ...INITIAL_EXERCISE_LIST_FILTERS } - if (statusDraft) { - next.status_rules = [{ key: 'url-dashboard-draft', id: 'draft', mode: 'require' }] - } - return next - } - - if (statusDraft) { - return { - ...mergedFromPrefs, - status_rules: [{ key: 'url-dashboard-draft', id: 'draft', mode: 'require' }], - } - } - return mergedFromPrefs - } catch { - return mergedFromPrefs - } -} function ExercisesListPage() { const { user, checkAuth } = useAuth() @@ -206,168 +157,33 @@ function ExercisesListPage() { [] ) - const filterChips = useMemo(() => { - const chips = [] - - if (mineOnly) { - chips.push({ - key: 'mine-only', - label: 'Nur von mir erstellt', - onRemove: () => setMineOnly(false), - }) - } - - pushCatalogRuleFilterChips(chips, 'focus_rules', filters.focus_rules, focusOptions, 'Fokus', setFilters) - - if (filters.focus_only_without) { - chips.push({ - key: 'focus-only-none', - label: 'Nur ohne Fokusbereich', - onRemove: () => setFilters((prev) => ({ ...prev, focus_only_without: false })), - }) - } - - ;(filters.focus_area_ids || []).forEach((id) => { - const opt = focusOptions.find((o) => String(o.id) === String(id)) - chips.push({ - key: `fa-${id}`, - label: `Fokus (ODER, älter): ${opt?.label ?? id}`, - onRemove: () => - setFilters((prev) => ({ - ...prev, - focus_area_ids: prev.focus_area_ids.filter((x) => String(x) !== String(id)), - })), - }) - }) - - pushCatalogRuleFilterChips( - chips, - 'style_direction_rules', - filters.style_direction_rules, + const filterChips = useMemo( + () => + buildExerciseListFilterChips({ + mineOnly, + setMineOnly, + filters, + setFilters, + focusOptions, + styleOptions, + trainingTypeOptions, + targetGroupOptions, + skillOptions, + visibilityOptions, + statusOptions, + }), + [ + mineOnly, + filters, + focusOptions, styleOptions, - 'Stil', - setFilters - ) - pushCatalogRuleFilterChips( - chips, - 'training_type_rules', - filters.training_type_rules, trainingTypeOptions, - 'Trainingsstil', - setFilters - ) - pushCatalogRuleFilterChips( - chips, - 'target_group_rules', - filters.target_group_rules, targetGroupOptions, - 'Zielgruppe', - setFilters - ) - - ;(filters.style_direction_ids || []).forEach((id) => { - const opt = styleOptions.find((o) => String(o.id) === String(id)) - chips.push({ - key: `sd-${id}`, - label: `Stil (ODER, älter): ${opt?.label ?? id}`, - onRemove: () => - setFilters((prev) => ({ - ...prev, - style_direction_ids: prev.style_direction_ids.filter((x) => String(x) !== String(id)), - })), - }) - }) - ;(filters.training_type_ids || []).forEach((id) => { - const opt = trainingTypeOptions.find((o) => String(o.id) === String(id)) - chips.push({ - key: `tt-${id}`, - label: `Trainingsstil (ODER, älter): ${opt?.label ?? id}`, - onRemove: () => - setFilters((prev) => ({ - ...prev, - training_type_ids: prev.training_type_ids.filter((x) => String(x) !== String(id)), - })), - }) - }) - ;(filters.target_group_ids || []).forEach((id) => { - const opt = targetGroupOptions.find((o) => String(o.id) === String(id)) - chips.push({ - key: `tg-${id}`, - label: `Zielgruppe (ODER, älter): ${opt?.label ?? id}`, - onRemove: () => - setFilters((prev) => ({ - ...prev, - target_group_ids: prev.target_group_ids.filter((x) => String(x) !== String(id)), - })), - }) - }) - - ;(filters.skill_ids || []).forEach((id) => { - const opt = skillOptions.find((o) => String(o.id) === String(id)) - chips.push({ - key: `sk-${id}`, - label: `Fähigkeit: ${opt?.label ?? id}`, - onRemove: () => - setFilters((prev) => ({ - ...prev, - skill_ids: prev.skill_ids.filter((x) => String(x) !== String(id)), - })), - }) - }) - - if (filters.skill_min_level || filters.skill_max_level) { - const a = filters.skill_min_level ? levelOptionShort(filters.skill_min_level) : '…' - const b = filters.skill_max_level ? levelOptionShort(filters.skill_max_level) : '…' - chips.push({ - key: 'skill-levels', - label: `Stufe ${a}–${b}`, - onRemove: () => - setFilters((prev) => ({ - ...prev, - skill_min_level: '', - skill_max_level: '', - })), - }) - } - - pushCatalogRuleFilterChips( - chips, - 'visibility_rules', - filters.visibility_rules, + skillOptions, visibilityOptions, - 'Sichtbarkeit', - setFilters - ) - pushCatalogRuleFilterChips(chips, 'status_rules', filters.status_rules, statusOptions, 'Status', setFilters) - - if (filters.exclude_without_focus) { - chips.push({ - key: 'ex-no-focus', - label: 'Ohne Fokus ausblenden', - onRemove: () => setFilters((prev) => ({ ...prev, exclude_without_focus: false })), - }) - } - if (filters.include_archived) { - chips.push({ - key: 'inc-arch', - label: 'Archivierte anzeigen', - onRemove: () => setFilters((prev) => ({ ...prev, include_archived: false })), - }) - } - - return chips - }, [ - mineOnly, - filters, - focusOptions, - styleOptions, - trainingTypeOptions, - targetGroupOptions, - skillOptions, - visibilityOptions, - statusOptions, - setFilters, - ]) + statusOptions, + ] + ) /** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */ const searchTitleSuggestions = useMemo(() => { @@ -375,56 +191,10 @@ function ExercisesListPage() { return [...new Set(titles)].slice(0, 80) }, [exercises]) - const queryBase = useMemo(() => { - const q = {} - const n = (v) => (v === '' || v == null ? undefined : Number(v)) - const ids = (arr) => - Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined - const fMn = splitMnCatalogRules(filters.focus_rules) - if (fMn.includeIds.length) q.focus_area_must_include_ids = fMn.includeIds - if (fMn.excludeIds.length) q.focus_area_must_exclude_ids = fMn.excludeIds - if (filters.focus_only_without) q.focus_only_without_focus_areas = true - - const fa = ids(filters.focus_area_ids) - if (fa?.length) q.focus_area_ids = fa - - const sdMn = splitMnCatalogRules(filters.style_direction_rules) - if (sdMn.includeIds.length) q.style_direction_must_include_ids = sdMn.includeIds - if (sdMn.excludeIds.length) q.style_direction_must_exclude_ids = sdMn.excludeIds - const sdLegacy = ids(filters.style_direction_ids) - if (sdLegacy?.length) q.style_direction_ids = sdLegacy - - const ttMn = splitMnCatalogRules(filters.training_type_rules) - if (ttMn.includeIds.length) q.training_type_must_include_ids = ttMn.includeIds - if (ttMn.excludeIds.length) q.training_type_must_exclude_ids = ttMn.excludeIds - const ttLegacy = ids(filters.training_type_ids) - if (ttLegacy?.length) q.training_type_ids = ttLegacy - - const tgMn = splitMnCatalogRules(filters.target_group_rules) - if (tgMn.includeIds.length) q.target_group_must_include_ids = tgMn.includeIds - if (tgMn.excludeIds.length) q.target_group_must_exclude_ids = tgMn.excludeIds - const tgLegacy = ids(filters.target_group_ids) - if (tgLegacy?.length) q.target_group_ids = tgLegacy - - const visMn = splitScalarCatalogRules(filters.visibility_rules) - if (visMn.includeVals.length) q.visibility_any = visMn.includeVals - if (visMn.excludeVals.length) q.visibility_exclude_any = visMn.excludeVals - - const stMn = splitScalarCatalogRules(filters.status_rules) - if (stMn.includeVals.length) q.status_any = stMn.includeVals - if (stMn.excludeVals.length) q.status_exclude_any = stMn.excludeVals - - const sk = ids(filters.skill_ids) - if (sk?.length) q.skill_ids = sk - if (filters.skill_min_level) q.skill_min_level = n(filters.skill_min_level) - if (filters.skill_max_level) q.skill_max_level = n(filters.skill_max_level) - if (filters.exclude_without_focus) q.exclude_without_focus = true - if (filters.include_archived) q.include_archived = true - if (debouncedSearch) q.search = debouncedSearch - if (debouncedAiSearch) q.ai_search = debouncedAiSearch - if (mineOnly) q.created_by_me = true - return q - }, [filters, debouncedSearch, debouncedAiSearch, mineOnly]) + const queryBase = useMemo( + () => buildExerciseListQueryBase(filters, debouncedSearch, debouncedAiSearch, mineOnly), + [filters, debouncedSearch, debouncedAiSearch, mineOnly] + ) useEffect(() => { setSelectedIds(new Set()) @@ -772,93 +542,21 @@ function ExercisesListPage() { ) : ( <> -
- - - {searchTitleSuggestions.map((t) => ( - - setSearchInput(e.target.value)} - autoComplete="on" - name="exercise-fulltext-search" - list="exercise-search-titles" - enterKeyHint="search" - /> - - setAiSearchInput(e.target.value)} - autoComplete="on" - name="exercise-ai-search" - list="exercise-search-titles" - enterKeyHint="search" - /> -
-
- - - {filterChips.length > 0 ? ( - - ) : null} -
-
- {filterChips.length > 0 ? ( -
- {filterChips.map((c) => ( - - ))} -
- ) : null} -

- Trefferliste aktualisiert sich kurz nach Eingabe. Titel der aktuellen Liste erscheinen als Vorschläge (Pfeil im - Feld). Fachliche Filter über „Filter“ – zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER. - {exercises.length > 0 ? ( - <> - {' '} - - - ) : null} -

-
+ setMineOnly((v) => !v)} + onOpenFilter={() => setFilterModalOpen(true)} + filterChips={filterChips} + onResetAllFilters={resetAllFilters} + exerciseCount={exercises.length} + allOnPageSelected={allOnPageSelected} + onToggleSelectAllPage={toggleSelectAllPage} + /> {selectedIds.size > 0 ? (
@@ -877,426 +575,63 @@ function ExercisesListPage() {
) : null} - {filterModalOpen && ( -
{ - if (e.target === e.currentTarget) setFilterModalOpen(false) - }} - > -
e.stopPropagation()} - > -
-

- Übungen filtern -

- -
-
-

- Zwischen den Bereichen gilt UND. Fokusbereiche: mehrere „+ mit“ bedeuten alle müssen - gesetzt sein; „− ohne“ schließt Übungen aus, die diesen Fokus zusätzlich haben. Stilrichtung / - Trainingsstil / Zielgruppe: mehrere „+“ = alle zutreffend (UND); „−“ verbietet die Zuordnung. Unter - „Freigabe“: Sichtbarkeit / Status mit „+“ = eine davon (ODER); „−“ blendet aus. -

+ setFilterModalOpen(false)} + filters={filters} + setFilters={setFilters} + focusOptions={focusOptions} + styleOptions={styleOptions} + trainingTypeOptions={trainingTypeOptions} + targetGroupOptions={targetGroupOptions} + skillOptions={skillOptions} + visibilityOptions={visibilityOptions} + statusOptions={statusOptions} + savingExercisePrefs={savingExercisePrefs} + onSaveStandard={handleSaveExerciseFilterPrefs} + onResetAll={resetAllFilters} + /> -
-

Zuordnung

- setFilters((prev) => ({ ...prev, ...patch }))} - /> -
- setFilters((prev) => ({ ...prev, ...patch }))} - /> - setFilters((prev) => ({ ...prev, ...patch }))} - /> - setFilters((prev) => ({ ...prev, ...patch }))} - /> -
-
- -
-

Fähigkeit und zugehörige Stufe

-
- - setFilters({ ...filters, skill_ids: v })} - options={skillOptions} - placeholder="Fähigkeit suchen …" - /> -

- Die Stufen filtern nach dem Niveau der Zuordnung Übung ↔ Fähigkeit (von–bis). -

-
- - - – - - -
-
-
- -
-

Ausblenden / Liste

-

- Sichtbarkeit und Status steuern Sie unter „Freigabe“ mit + und −. Hier nur globale Listen-Optionen. -

-
- - -
-
- -
-

Freigabe

-

- Pro Übung nur ein Wert: mehrere „+“ bedeuten „eine davon“ (ODER). „−“ blendet Werte aus. -

-
- setFilters((prev) => ({ ...prev, ...patch }))} - /> - setFilters((prev) => ({ ...prev, ...patch }))} - /> -
-
-
-
- - - -
-
-
- )} - - {bulkModalOpen ? ( -
{ - if (e.target === e.currentTarget) setBulkModalOpen(false) - }} - > -
e.stopPropagation()} - > -
-

- Massenänderung -

- -
-
-

- Es werden {selectedIds.size} Übung(en) aus der aktuellen Auswahl bearbeitet. Pro Durchlauf - höchstens {BULK_MAX_IDS}. Ohne Berechtigung bleiben Einzelübungen unverändert (siehe Hinweis nach dem - Speichern). -

-

- Unter „Zuordnung ersetzen“: die gewählte Liste ersetzt die bisherige Zuordnung bei allen betroffenen - Übungen vollständig (leere Auswahl = alle Zuordnungen dieser Kategorie entfernen). Die erste Auswahl gilt - als Primärzuordnung. -

-
- - -
- {bulkVisibility === 'club' ? ( -
- - - {isPlatformAdmin ? ( - <> - - setBulkClubManual(e.target.value)} - /> - - ) : null} -
- ) : null} -
- - -
- -
-

Zuordnung (optional)

-
-
- - {bulkPatchFocusAreas ? ( - - ) : null} -
-
- - {bulkPatchStyleDirections ? ( - - ) : null} -
-
- - {bulkPatchTrainingTypes ? ( - - ) : null} -
-
- - {bulkPatchTargetGroups ? ( - - ) : null} -
-
-
-
-
- - -
-
-
- ) : null} + setBulkModalOpen(false)} + onSubmit={handleBulkSubmit} + bulkSubmitting={bulkSubmitting} + selectedCount={selectedIds.size} + bulkMaxIds={BULK_MAX_IDS} + user={user} + isPlatformAdmin={isPlatformAdmin} + statusOptions={statusOptions} + bulkVisibilityOptions={bulkVisibilityOptions} + focusOptions={focusOptions} + styleOptions={styleOptions} + trainingTypeOptions={trainingTypeOptions} + targetGroupOptions={targetGroupOptions} + bulkVisibility={bulkVisibility} + setBulkVisibility={setBulkVisibility} + bulkStatus={bulkStatus} + setBulkStatus={setBulkStatus} + bulkClubSelect={bulkClubSelect} + setBulkClubSelect={setBulkClubSelect} + bulkClubManual={bulkClubManual} + setBulkClubManual={setBulkClubManual} + bulkPatchFocusAreas={bulkPatchFocusAreas} + setBulkPatchFocusAreas={setBulkPatchFocusAreas} + bulkFocusAreaIds={bulkFocusAreaIds} + setBulkFocusAreaIds={setBulkFocusAreaIds} + bulkPatchStyleDirections={bulkPatchStyleDirections} + setBulkPatchStyleDirections={setBulkPatchStyleDirections} + bulkStyleDirectionIds={bulkStyleDirectionIds} + setBulkStyleDirectionIds={setBulkStyleDirectionIds} + bulkPatchTrainingTypes={bulkPatchTrainingTypes} + setBulkPatchTrainingTypes={setBulkPatchTrainingTypes} + bulkTrainingTypeIds={bulkTrainingTypeIds} + setBulkTrainingTypeIds={setBulkTrainingTypeIds} + bulkPatchTargetGroups={bulkPatchTargetGroups} + setBulkPatchTargetGroups={setBulkPatchTargetGroups} + bulkTargetGroupIds={bulkTargetGroupIds} + setBulkTargetGroupIds={setBulkTargetGroupIds} + /> {listFetching && exercises.length === 0 ? (
diff --git a/frontend/src/utils/exerciseListFilterChips.js b/frontend/src/utils/exerciseListFilterChips.js new file mode 100644 index 0000000..a4293ac --- /dev/null +++ b/frontend/src/utils/exerciseListFilterChips.js @@ -0,0 +1,188 @@ +import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' + +const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) + +function pushCatalogRuleFilterChips(chips, field, rules, options, topicLabel, setFilters) { + ;(rules || []).forEach((r) => { + const rid = String(r.id ?? r.focus_area_id ?? '') + const opt = options.find((o) => String(o.id) === rid) + chips.push({ + key: `${field}-${r.key}`, + label: `${topicLabel}: ${r.mode === 'forbid' ? '−' : '+'} ${opt?.label ?? rid}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + [field]: (prev[field] || []).filter((x) => x.key !== r.key), + })), + }) + }) +} + +function levelOptionShort(levelStr) { + const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr)) + return o ? String(o.level) : String(levelStr) +} + +export function buildExerciseListFilterChips({ + mineOnly, + setMineOnly, + filters, + setFilters, + focusOptions, + styleOptions, + trainingTypeOptions, + targetGroupOptions, + skillOptions, + visibilityOptions, + statusOptions, +}) { + const chips = [] + + if (mineOnly) { + chips.push({ + key: 'mine-only', + label: 'Nur von mir erstellt', + onRemove: () => setMineOnly(false), + }) + } + + pushCatalogRuleFilterChips(chips, 'focus_rules', filters.focus_rules, focusOptions, 'Fokus', setFilters) + + if (filters.focus_only_without) { + chips.push({ + key: 'focus-only-none', + label: 'Nur ohne Fokusbereich', + onRemove: () => setFilters((prev) => ({ ...prev, focus_only_without: false })), + }) + } + + ;(filters.focus_area_ids || []).forEach((id) => { + const opt = focusOptions.find((o) => String(o.id) === String(id)) + chips.push({ + key: `fa-${id}`, + label: `Fokus (ODER, älter): ${opt?.label ?? id}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + focus_area_ids: prev.focus_area_ids.filter((x) => String(x) !== String(id)), + })), + }) + }) + + pushCatalogRuleFilterChips( + chips, + 'style_direction_rules', + filters.style_direction_rules, + styleOptions, + 'Stil', + setFilters + ) + pushCatalogRuleFilterChips( + chips, + 'training_type_rules', + filters.training_type_rules, + trainingTypeOptions, + 'Trainingsstil', + setFilters + ) + pushCatalogRuleFilterChips( + chips, + 'target_group_rules', + filters.target_group_rules, + targetGroupOptions, + 'Zielgruppe', + setFilters + ) + + ;(filters.style_direction_ids || []).forEach((id) => { + const opt = styleOptions.find((o) => String(o.id) === String(id)) + chips.push({ + key: `sd-${id}`, + label: `Stil (ODER, älter): ${opt?.label ?? id}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + style_direction_ids: prev.style_direction_ids.filter((x) => String(x) !== String(id)), + })), + }) + }) + ;(filters.training_type_ids || []).forEach((id) => { + const opt = trainingTypeOptions.find((o) => String(o.id) === String(id)) + chips.push({ + key: `tt-${id}`, + label: `Trainingsstil (ODER, älter): ${opt?.label ?? id}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + training_type_ids: prev.training_type_ids.filter((x) => String(x) !== String(id)), + })), + }) + }) + ;(filters.target_group_ids || []).forEach((id) => { + const opt = targetGroupOptions.find((o) => String(o.id) === String(id)) + chips.push({ + key: `tg-${id}`, + label: `Zielgruppe (ODER, älter): ${opt?.label ?? id}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + target_group_ids: prev.target_group_ids.filter((x) => String(x) !== String(id)), + })), + }) + }) + + ;(filters.skill_ids || []).forEach((id) => { + const opt = skillOptions.find((o) => String(o.id) === String(id)) + chips.push({ + key: `sk-${id}`, + label: `Fähigkeit: ${opt?.label ?? id}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + skill_ids: prev.skill_ids.filter((x) => String(x) !== String(id)), + })), + }) + }) + + if (filters.skill_min_level || filters.skill_max_level) { + const a = filters.skill_min_level ? levelOptionShort(filters.skill_min_level) : '…' + const b = filters.skill_max_level ? levelOptionShort(filters.skill_max_level) : '…' + chips.push({ + key: 'skill-levels', + label: `Stufe ${a}–${b}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + skill_min_level: '', + skill_max_level: '', + })), + }) + } + + pushCatalogRuleFilterChips( + chips, + 'visibility_rules', + filters.visibility_rules, + visibilityOptions, + 'Sichtbarkeit', + setFilters + ) + pushCatalogRuleFilterChips(chips, 'status_rules', filters.status_rules, statusOptions, 'Status', setFilters) + + if (filters.exclude_without_focus) { + chips.push({ + key: 'ex-no-focus', + label: 'Ohne Fokus ausblenden', + onRemove: () => setFilters((prev) => ({ ...prev, exclude_without_focus: false })), + }) + } + if (filters.include_archived) { + chips.push({ + key: 'inc-arch', + label: 'Archivierte anzeigen', + onRemove: () => setFilters((prev) => ({ ...prev, include_archived: false })), + }) + } + + return chips +} diff --git a/frontend/src/utils/exerciseListQuery.js b/frontend/src/utils/exerciseListQuery.js new file mode 100644 index 0000000..3564db6 --- /dev/null +++ b/frontend/src/utils/exerciseListQuery.js @@ -0,0 +1,83 @@ +import { + INITIAL_EXERCISE_LIST_FILTERS, + splitMnCatalogRules, + splitScalarCatalogRules, +} from '../constants/exerciseListFilters' + +/** Dashboard-Deep-Link: ?mine=1, optional ?status=draft — überschreibt gespeicherte Prefs-Kombination. */ +export function applyDashboardExerciseListUrl(mergedFromPrefs) { + try { + const sp = new URLSearchParams(window.location.search) + const mine = sp.get('mine') === '1' || sp.get('created_by_me') === '1' + const statusDraft = sp.get('status') === 'draft' + + if (mine) { + const next = { ...INITIAL_EXERCISE_LIST_FILTERS } + if (statusDraft) { + next.status_rules = [{ key: 'url-dashboard-draft', id: 'draft', mode: 'require' }] + } + return next + } + + if (statusDraft) { + return { + ...mergedFromPrefs, + status_rules: [{ key: 'url-dashboard-draft', id: 'draft', mode: 'require' }], + } + } + return mergedFromPrefs + } catch { + return mergedFromPrefs + } +} + +export function buildExerciseListQueryBase(filters, debouncedSearch, debouncedAiSearch, mineOnly) { + const q = {} + const n = (v) => (v === '' || v == null ? undefined : Number(v)) + const ids = (arr) => + Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined + const fMn = splitMnCatalogRules(filters.focus_rules) + if (fMn.includeIds.length) q.focus_area_must_include_ids = fMn.includeIds + if (fMn.excludeIds.length) q.focus_area_must_exclude_ids = fMn.excludeIds + if (filters.focus_only_without) q.focus_only_without_focus_areas = true + + const fa = ids(filters.focus_area_ids) + if (fa?.length) q.focus_area_ids = fa + + const sdMn = splitMnCatalogRules(filters.style_direction_rules) + if (sdMn.includeIds.length) q.style_direction_must_include_ids = sdMn.includeIds + if (sdMn.excludeIds.length) q.style_direction_must_exclude_ids = sdMn.excludeIds + const sdLegacy = ids(filters.style_direction_ids) + if (sdLegacy?.length) q.style_direction_ids = sdLegacy + + const ttMn = splitMnCatalogRules(filters.training_type_rules) + if (ttMn.includeIds.length) q.training_type_must_include_ids = ttMn.includeIds + if (ttMn.excludeIds.length) q.training_type_must_exclude_ids = ttMn.excludeIds + const ttLegacy = ids(filters.training_type_ids) + if (ttLegacy?.length) q.training_type_ids = ttLegacy + + const tgMn = splitMnCatalogRules(filters.target_group_rules) + if (tgMn.includeIds.length) q.target_group_must_include_ids = tgMn.includeIds + if (tgMn.excludeIds.length) q.target_group_must_exclude_ids = tgMn.excludeIds + const tgLegacy = ids(filters.target_group_ids) + if (tgLegacy?.length) q.target_group_ids = tgLegacy + + const visMn = splitScalarCatalogRules(filters.visibility_rules) + if (visMn.includeVals.length) q.visibility_any = visMn.includeVals + if (visMn.excludeVals.length) q.visibility_exclude_any = visMn.excludeVals + + const stMn = splitScalarCatalogRules(filters.status_rules) + if (stMn.includeVals.length) q.status_any = stMn.includeVals + if (stMn.excludeVals.length) q.status_exclude_any = stMn.excludeVals + + const sk = ids(filters.skill_ids) + if (sk?.length) q.skill_ids = sk + if (filters.skill_min_level) q.skill_min_level = n(filters.skill_min_level) + if (filters.skill_max_level) q.skill_max_level = n(filters.skill_max_level) + if (filters.exclude_without_focus) q.exclude_without_focus = true + if (filters.include_archived) q.include_archived = true + if (debouncedSearch) q.search = debouncedSearch + if (debouncedAiSearch) q.ai_search = debouncedAiSearch + if (mineOnly) q.created_by_me = true + return q +} diff --git a/tests/dev-smoke-test.spec.js b/tests/dev-smoke-test.spec.js index e498118..58a61b9 100644 --- a/tests/dev-smoke-test.spec.js +++ b/tests/dev-smoke-test.spec.js @@ -220,6 +220,23 @@ test('9. Übungsliste: nach Laden entweder Treffer-Gitter oder Leerhinweis', asy console.log('✓ Übungsliste: Endzustand sichtbar (Gitter oder leer)'); }); +test('10. Übungsliste: Filter-Dialog öffnet und schließt', 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 }); + await main.getByRole('button', { name: /^Filter$/i }).click(); + const dlg = page.getByTestId('exercise-list-filter-modal'); + await expect(dlg).toBeVisible({ timeout: 10000 }); + await expect(dlg.getByRole('heading', { name: 'Übungen filtern' })).toBeVisible(); + await dlg.getByRole('button', { name: 'Schließen' }).click(); + await expect(dlg).toHaveCount(0); + console.log('✓ Übungsliste: Filter-Dialog Smoke'); +}); + test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); await login(page); -- 2.43.0 From 4235246cd715aaf87b278b93bf52589214b6a998 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 14 May 2026 11:21:09 +0200 Subject: [PATCH 2/3] chore(version): update version and changelog for release 0.8.122 - Bumped APP_VERSION to 0.8.122 and updated the changelog to reflect new features. - Integrated useExerciseListCatalogsAndQuery hook in ExercisesListPage for improved exercise list management and data fetching. - Enhanced documentation to include new concepts for parallel training streams and their technical specifications. - Updated DOMAIN_MODEL and related technical specs to clarify the structure and functionality of training streams within units. --- .claude/docs/functional/DOMAIN_MODEL.md | 12 +- .../PARALLEL_TRAINING_STREAMS_CONCEPT.md | 106 ++++++++++++++ .../PARALLEL_TRAINING_STREAMS_SPEC.md | 130 ++++++++++++++++++ .../docs/technical/TRAINING_FRAMEWORK_SPEC.md | 1 + ..._MODULES_AND_COMBINATION_EXERCISES_SPEC.md | 1 + backend/version.py | 9 +- .../hooks/useExerciseListCatalogsAndQuery.js | 120 ++++++++++++++++ frontend/src/pages/ExercisesListPage.jsx | 126 +++-------------- 8 files changed, 397 insertions(+), 108 deletions(-) create mode 100644 .claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md create mode 100644 .claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md create mode 100644 frontend/src/hooks/useExerciseListCatalogsAndQuery.js diff --git a/.claude/docs/functional/DOMAIN_MODEL.md b/.claude/docs/functional/DOMAIN_MODEL.md index 8de4ca0..f951050 100644 --- a/.claude/docs/functional/DOMAIN_MODEL.md +++ b/.claude/docs/functional/DOMAIN_MODEL.md @@ -1,7 +1,7 @@ # Shinkan Jinkendo - Fachliches Domänenmodell -**Version:** 0.4.5 -**Stand:** 2026-05-12 (Fachlicher Nutzerüberblick: `docs/FACHLICHE_NUTZERFUNKTIONEN.md`) +**Version:** 0.4.6 +**Stand:** 2026-05-14 (Fachlicher Nutzerüberblick: `docs/FACHLICHE_NUTZERFUNKTIONEN.md`) **Basis:** `shinkan_anforderungsdokument_entwurf.md` + Fähigkeitsmatrix --- @@ -474,6 +474,14 @@ skill_level_definitions ( **Konkretisierung (037/API):** `POST /api/training-units/from-framework-slot` legt eine geplante Einheit aus dem Slot‑Blueprint an; **`origin_framework_slot_id`** dient als Herkunftsreferenz (**Lineage light**; weiteres Feedback/Lineage‑Konzept: Konzeptpapier Schritt **E**). +### Parallele Trainingsstreams (Breakout, Entwurf) + +**Fachlich:** Eine Kalender‑**Einheit** kann aus **Phasen** bestehen — z. B. gemeinsamer Block, dann **beliebig viele parallele** „Teilstrecken“ (**Streams**) mit je eigenem Miniplan (Abschnitte/Übungen), erneut gemeinsamer Block. Das ist **nicht** dasselbe wie ein **Rahmenprogramm‑Slot** (Serien‑Session über Wochen): Slots strukturieren **mehrere Einheiten** in einem Programm; **Streams** strukturieren **gleichzeitige** Abläufe **innerhalb einer** Einheit. + +**Sonderfall Stationen:** Rotation kann **innerhalb** einer Stream‑Planung über **Kombinationsübungen** (Methodenprofil/Archetyp) abgebildet werden; hallenweit **synchron** getaktete Rotation ist eine **erweiterte** Ausbaustufe (siehe Fachkonzept). + +**Dokumentation:** `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, Umsetzung `technical/PARALLEL_TRAINING_STREAMS_SPEC.md`. + --- ## Medien-Archiv & Übungs-Anhänge (Stand 2026-05-07) diff --git a/.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md b/.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md new file mode 100644 index 0000000..0023cf8 --- /dev/null +++ b/.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md @@ -0,0 +1,106 @@ +# Parallele Trainingsstreams (Breakout) — Fachkonzept + +**Status:** Entwurf zur Abstimmung · **Stand:** 2026-05-14 +**Ziel:** Planung und Durchführung von Training mit **phasenweise gemeinsamem** Ablauf und **beliebig vielen parallelen Teilstrecken** (Breakout-Sessions), inkl. Sonderfall **rotierende Stationen**. + +**Technische Ausarbeitung:** `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md` +**Domänenbegriffe (Überblick):** `.claude/docs/functional/DOMAIN_MODEL.md` (Abschnitt Parallele Streams) + +--- + +## 1. Ausgangslage und Problem + +In Kinder- und Breitensport-Training ist ein typischer Ablauf: + +1. **Gemeinsam:** Aufwärmen, Koordination, Ansagen. +2. **Getrennt:** Kinder in mehrere Gruppen teilen; **Co-Trainer** leiten jeweils eigene Inhalte **gleichzeitig**. +3. **Gemeinsam:** Abschluss, gemeinsame Übungen, Verabschiedung. + +Die aktuelle Shinkan-Planung modelliert pro Termin **eine lineare Folge von Abschnitten und Übungen** pro Einheit. Das genügt nicht, wenn **mehrere gleichzeitige „Unter-Sessions“** mit unterschiedlichen Plänen dokumentiert und auf der Matte geführt werden sollen. + +--- + +## 2. Ziele (fachlich) + +| ID | Ziel | +|----|------| +| PT‑01 | Eine **Kalender-Einheit** bleibt **ein** Termin (eine Halle, eine Gruppe, ein Datum) — kein Splitten in künstlich mehrere Kalendereinträge nur für Parallelität. | +| PT‑02 | **Unbegrenzte** Anzahl paralleler **Streams** (Teilstrecken) in einer oder mehreren **Parallelphasen**. | +| PT‑03 | **Phasenmodell:** klar erkennbar **Gemeinsam** vs. **Parallel** vs. wieder **Gemeinsam** (auch mehrfach hintereinander möglich). | +| PT‑04 | **Rollen:** Leitung (Haupttrainer) und Co-Trainer; Zuordnung der Co-Trainer **soll** an konkrete Streams anschließbar sein (heute: nur flache Liste pro Einheit — siehe technische Spec). | +| PT‑05 | **Sonderfall Stationen:** rotierender Ablauf (z. B. Wechsel alle 20 Min.) **inhaltlich** unterscheiden zwischen (a) Rotation **innerhalb** einer Teilstrecke und (b) **synchron** getakteter Hallen-Rotation — siehe §5. | +| PT‑06 | **Durchführung:** Trainer können „ihre“ Spur auf dem Gerät abarbeiten; Fortschritt pro Spur nachvollziehbar. | + +**Nicht-Ziel (frühe Stufen):** Echtzeit-Synchronisation mehrerer Geräte; individuelles Athleten-Tracking; automatische Raumbelegung. + +--- + +## 3. Begriffe + +| Begriff | Definition | +|---------|------------| +| **Einheit / Termin** | Geplante `training_unit` für Gruppe und Datum — übergeordneter Rahmen des Abends. | +| **Phase** | Organisatorischer Block innerhalb der Einheit: entweder **ganze Gruppe** oder **parallel**. | +| **Stream / Teilstrecke** | Innerhalb einer Parallelphase: eine von N **gleichzeitig** stattfindenden Unter-Abläufen mit **eigenem** Miniplan (Abschnitte, Übungen, Notizen — analog heutiger Planung). | +| **Synchronisationspunkt** | Fachlich: alle treffen sich wieder (Beginn einer **Gemeinschaftsphase** nach Parallelität). | +| **Station (Rotation)** | Inhaltlicher Fokus oder Platz, den Teilnehmer **wechselnd** anlaufen; kann als Kombinations-/Zirkellogik oder als koordinierter Hallenrhythmus modelliert werden (§5). | + +**Abgrenzung „Rahmenprogramm-Slot“:** Ein Slot im **Rahmenprogramm** ist eine **Session in einer Serie** (z. B. Woche 1 vs. Woche 2), **nicht** „Teilgruppe A gleichzeitig mit Teilgruppe B in derselben Stunde“. Parallele Streams sind **innerhalb einer Einheit**, orthogonal zum Rahmen-Slot. + +**Abgrenzung **Kombinationsübung**:** Eine Kombi-Übung bündelt **mehrere Einzelübungen** mit Methodenprofil (Archetyp, ggf. Rotation) **in einem Plan-Item**. Sie ersetzt **nicht** mehrere Trainer mit **jeweils eigenem Gesamtablauf**, kann aber **pro Stream** für Stationslogik genutzt werden. + +--- + +## 4. Szenarien + +### 4.1 Klassischer Breakout + +30 Min. gemeinsam → 25 Min. drei parallele Streams (Gruppe an Matte / an Schlagsack / Fußarbeit) → 15 Min. gemeinsam. + +### 4.2 Viele Kinder, mehrere Co-Trainer + +Haupttrainer plant die Gesamtstruktur; jeder Co-Trainer sieht in der Durchführung primär die zugewiesene Teilstrecke. + +### 4.3 Rollierendes Stationssystem + +Alle Gruppen arbeiten an **verschiedenen Schwerpunkten** und **wechseln** nach festem Intervall die Station — entweder **nur innerhalb einer Spur** oder **hallenweit synchron** (offene fachliche Präzisierung in MVP vs. später, §5). + +--- + +## 5. Sonderfall: Stationen und Kombinationsübungen + +### 5.1 Variante A — Rotation innerhalb einer Teilstrecke + +Eine Teilgruppe rotiert durch mehrere Übungen (Zeit oder Runden). Das liegt nah an einer **Kombinationsübung** mit Archetyp z. B. „Zirkel / zeitgesteuerte Rotation“ und Parametern (Wechselintervall). **Empfehlung:** Diese Variante über **bestehendes** Kombinationsübungs-Konzept in der jeweiligen **Stream-Planung** abbilden (`planning_method_profile`). + +### 5.2 Variante B — Synchron getaktete Hallen-Rotation + +Alle Streams (oder alle Kinder insgesamt) **wechseln gleichzeitig** zur nächsten Station; Startstation kann pro Teilgruppe **versetzt** sein. Das ist **organisatorisch** schwerer: es braucht entweder **Phasen-Metadaten** (globaler Takt) oder eine explizite **Rot/Matrix**. **Empfehlung:** In einer **zweiten Ausbaustufe** abbilden; MVP kann bei Variante A starten, sofern fachlich ausreichend. + +--- + +## 6. Rollen und Verantwortlichkeiten + +- **Leitungstrainer:** Hält den Faden, startet Gemeinschaftsphasen, koordiniert Parallelbeginn/-ende (fachlich; ggf. später UI-Hinweise). +- **Co-Trainer:** Verantwortlich für **zugeteilte** Streams; Zuordnung soll **pro Stream** möglich werden (Erweiterung gegenüber reiner Einheits-Co-Trainer-Liste). + +--- + +## 7. Offene fachliche Entscheidungen + +1. **MVP Umfang:** Reicht **freie Parallelität** ohne **synchronen** Hallenwechsel (Variante B)? +2. **Dauer:** Sollen Phasen oder Streams **Soll-Minuten** tragen (nur Anzeige vs. später Timer)? +3. **Vorlagen:** Müssen `training_plan_templates` parallel-fähig werden **vor** oder **mit** der ersten Implementierung? +4. **Sichtbarkeit:** Dürfen alle Co-Trainer alle Streams sehen, oder „nur meine Spur“? + +--- + +## 8. Verwandte Dokumente + +| Dokument | Bezug | +|----------|--------| +| `technical/TRAINING_FRAMEWORK_SPEC.md` | Rahmen-Slots = Serien-Sessions, **nicht** Intra-Einheit-Parallelität | +| `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md` | Kombinationsübungen, Archetypen, Stationslogik **im Item** | +| `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` | Fachliche Tiefe Kombi | +| `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | Nutzerüberblick | +| `technical/DATABASE_SCHEMA.md` | Aktueller Stand Tabellen | diff --git a/.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md b/.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md new file mode 100644 index 0000000..5d894e3 --- /dev/null +++ b/.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md @@ -0,0 +1,130 @@ +# Parallele Trainingsstreams — Technische Spezifikation (Umsetzung) + +**Status:** Entwurf · **Stand:** 2026-05-14 +**Fachgrundlage:** `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md` + +Dieses Dokument beschreibt die **Umsetzung** auf Basis der **aktuellen Codebasis** (Stand Analyse 2026-05-14): eine `training_unit` mit **`training_unit_sections`** und **`training_unit_section_items`** (Übung/Notiz, optional `planning_method_profile` für Kombinationsübungen, Migration **057**); Rahmen-**Blueprint**-Einheiten mit `framework_slot_id` (**037**); Leitung **`lead_trainer_profile_id`** (**038**); Co-Trainer **`assistant_trainer_profile_ids`** JSONB (**042**); Durchführung **`TrainingUnitRunPage`** (sequentiell über Sektionen). + +--- + +## 1. Ist-Stand (relevant) + +| Bereich | Aktuell | +|---------|---------| +| Planstruktur | **Eine** lineare Liste `training_unit_sections` je `training_unit_id`; Items in `training_unit_section_items`. | +| Rahmenprogramm | `training_framework_slots` verweisen auf **Blueprint**-`training_units` — Slots = **Serien-Spalten**, nicht simultane Breakouts in **einer** Halle. | +| Kombinationsübung | Ein **Item** kann Kombi sein; `planning_method_profile` = Snapshot; Coaching-UI teilweise (`CombinationPlanBracket` in Run/Peek). | +| Trainer-Zuweisung | `lead_trainer_profile_id`, `assistant_trainer_profile_ids` am **`training_units`**-Kopf; **keine** Zuordnung zu „welcher parallelen Spur“. | +| Run-Modus | `TrainingUnitRunPage`: sortierte Sektionen/Items, Checkliste, Fortschritt in `sessionStorage` pro Einheit. | + +**Konsequenz:** Parallele Streams erfordern ein **erweitertes konzeptionelles „Gefäß“** unterhalb der Einheit (Phasen und/oder Streams) und eine **Verknüpfung** bestehender Sektionen mit diesem Gefäß — oder eine **Migration** zu einem neuen Pflicht-Container (siehe §3). + +--- + +## 2. Zielarchitektur (logisch) + +``` +training_unit (Kalender-Einheit) +├── phase (order, kind: whole_group | parallel, optional Metadaten) +│ ├── [whole_group] → sections[] → items[] (wie heute) +│ └── [parallel] → stream (order, label, optional trainer_ids[]) +│ └── sections[] → items[] +``` + +**Abwärtskompatibilität:** Einheiten **ohne** explizite Phasen/Streams verhalten sich wie heute: **implizit** eine einzige „Gemeinschaftsphase“ mit den vorhandenen Sektionen (Migration: alle bestehenden Sektionen an diese Default-Hülle hängen). + +--- + +## 3. Datenmodell — Optionen + +### 3.1 Empfohlen: explizite Phasen + Streams (normalisiert) + +Neue Tabellen (Namen bei Implementierung final festlegen): + +| Tabelle | Zweck | +|---------|--------| +| `training_unit_phases` | `training_unit_id`, `order_index`, `phase_kind` (`whole_group` \| `parallel`), optional `title`, `guidance_notes`, optional `planned_duration_min` | +| `training_unit_parallel_streams` | `phase_id` (FK, nur wenn parent parallel), `order_index`, `title`/`label`, optional `notes`, optional `assigned_trainer_profile_ids` JSONB (oder 1:n-Hilfstabelle) | + +**Anpassung `training_unit_sections`:** Zusätzliche FK-Spalte(n), z. B.: + +- `phase_id` **NULL** und `parallel_stream_id` **NULL** → **Legacy / Default-Einheitsphase** (Migration setzt Default-Phase); oder +- genau einer von `phase_id` (whole group) oder `parallel_stream_id` gesetzt. + +**Constraints:** CHECK: nicht beide gesetzt; bei `phase_kind = parallel` Sektionen nur unter `parallel_stream_id`; bei `whole_group` nur unter `phase_id`. + +**Vorteil:** Klare Semantik, Reporting, API-Shape konsistent. + +### 3.2 Minimalvariante (nicht ideal fachlich) + +Nur **`training_unit_parallel_streams`** + `parallel_stream_id` auf Sektionen; Phasen implizit durch „Marker“-Sektionen oder Konvention. **Nicht empfohlen**, erschwert UI und Erklärbarkeit. + +--- + +## 4. API + +- **`GET /api/training-units/:id`** (und Listen-Payloads wo vollständiger Plan nötig): verschachtelte Struktur **Phasen → Streams → sections → items** oder flache `sections` mit ausgefüllten `phase_id` / `parallel_stream_id` (Frontend kann normalisieren). +- **`PUT/PATCH`:** Atomares Ersetzen der Phasen/Streams/Sektionen analog zu bestehendem `_replace_unit_sections`-Muster; **Validierung** der CHECK-Regeln serverseitig. +- **Blueprint / Rahmen:** Blueprint-`training_units` dürfen dieselbe Struktur tragen; `GET` Kalenderliste blendet Blueprints weiter aus (`framework_slot_id IS NOT NULL`). + +**Governance / Mandant:** Unverändert über Einheit → `group_id`; keine neuen Mandanten-Entitäten. + +--- + +## 5. Frontend + +### 5.1 Planung (`TrainingPlanningPage`) + +- Darstellung als **vertikale Phasen**: Gemeinschaftsblöcke + Parallelphase mit **N Spalten** (Streams). +- **Wiederverwendung:** `TrainingUnitSectionsEditor` **pro Stream** und pro Gemeinschaftsphase — analog zur Wiederverwendung **pro Rahmen-Slot** in `TrainingFrameworkProgramEditPage`. +- **Co-Trainer:** UI pro Stream (`assigned_trainer_profile_ids`); Regel zur **Kopfliste** `assistant_trainer_profile_ids` festlegen (z. B. Union aller Stream-Zuweisungen für „Wer ist heute dabei“ + Rückwärtskompatibilität wenn Stream-Felder leer). + +### 5.2 Durchführung (`TrainingUnitRunPage`) + +- Gemeinschaftsphasen: heutiges **lineares** Verhalten. +- Parallelphase: **Tabs, Akkordeon oder Swipe** zwischen Streams; Fortschritt **pro Stream** (Storage-Key z. B. `${unitId}:${streamId}`). +- Kombi-Items: unverändert `CombinationPlanBracket` / `effectiveComboMethodProfile`. +- Optional später: Filter „nur meine Spur“ anhand Session-Profil vs. Stream-Zuweisung. + +### 5.3 Vorlagen (`training_plan_templates`) + +- Erweiterung um **dieselbe** Phasen/Streams-Semantik (Kindtabellen oder serialisiertes JSON — Abgleich mit Kopierlogik aus Vorlage in Einheit). +- **Kein** Live-Spiegel: weiterhin Materialisierung beim Anwenden. + +--- + +## 6. Bezug Kombinationsübungen + +- **Variante A** (Rotation innerhalb einer Teilstrecke): ein oder mehrere **Items** vom Typ Kombi im jeweiligen Stream; Archetyp und Parameter wie in `TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md`. +- **Variante B** (synchron Hallenweit): erweiterte **Phasen-** oder **Stream-übergreifende** Metadaten — **nicht** in MVP-Zwang; eigenes Teilpaket nach fachlicher Freigabe (`PARALLEL_TRAINING_STREAMS_CONCEPT.md` §5.2). + +--- + +## 7. Migration und Risiken + +1. **Datenmigration:** Alle existierenden `training_unit_sections` einer Einheit einer **Default-Phase** `whole_group` zuordnen. +2. **API-Versionierung:** Clients, die nur flache `sections` erwarten, müssen angepasst werden (oder Server liefert **beides** kurzzeitig — nur wenn nötig). +3. **Performance:** Tiefe Kopien (Rahmen-Slot, Duplikat Einheit) müssen rekursiv Phasen/Streams mitsamt Sektionen/Items kopieren. +4. **Tests:** pytest für PUT/GET mit gemischten Phasen; ggf. Playwright-Smoke für Planung/Run. + +--- + +## 8. Implementierungsphasen (Vorschlag) + +| Phase | Inhalt | +|-------|--------| +| **P1** | Schema Phasen + Streams; Migration; GET/PATCH Einheit verschachtelt; Planungs-UI; Run-UI mit Stream-Tabs | +| **P2** | Trainer-Zuordnung pro Stream + effektive Anzeige; Vorlagen erweitert | +| **P3** | Synchroner Hallen-Takt / Rotationsmatrix (falls fachlich freigegeben) | + +--- + +## 9. Verwandte Dokumente + +| Dokument | Bezug | +|----------|--------| +| `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md` | Fachziele, Begriffe, Entscheidungsfragen | +| `technical/TRAINING_FRAMEWORK_SPEC.md` | Rahmen-Slot vs. Parallelität | +| `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md` | Kombi, `planning_method_profile` | +| `technical/DATABASE_SCHEMA.md`, `backend/migrations/` | DDL-Historie | +| `frontend/src/pages/TrainingPlanningPage.jsx`, `TrainingUnitRunPage.jsx`, `TrainingFrameworkProgramEditPage.jsx` | Ist-UI | diff --git a/.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md b/.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md index 2062851..485b574 100644 --- a/.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md +++ b/.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md @@ -15,6 +15,7 @@ | `DATABASE_SCHEMA.md` | **Nachgeordnete** Übersicht: Migrationshistorie und Tabellenliste; Detail-DDL primär **hier §2–§3** + SQL unter `backend/migrations/`. | | `functional/DOMAIN_MODEL.md` | Fachliche Begriffe; Kurzverweis auf Progressionsgraph ergänzt. | | `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | **Was** und **warum** (Bibliothek vs. Instanz, Governance, CURR‑Tabelle). | +| `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `technical/PARALLEL_TRAINING_STREAMS_SPEC.md` | **Parallele Streams / Breakout innerhalb einer Einheit** — orthogonale Domäne zu **Rahmen‑Slots** (Serien‑Sessions). | **Konsequenz:** Diese Datei bleibt der **technische Arbeitspool** für Rahmenprogramm Stufe 1–2. Abschnitt **§4** beschreibt explizit den **aktuellen Produktfreigabe-Umfang** und **bekannte Lücken** (damit Trainingsplanung weiter gebaut werden kann ohne falscher Erwartung an „Alternative‑Pakete“ in der UI). diff --git a/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md b/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md index d9b0952..6a00d68 100644 --- a/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md +++ b/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md @@ -15,6 +15,7 @@ | Dokument | Bezug | |----------|--------| | `TRAINING_FRAMEWORK_SPEC.md` | Rahmen-Bibliothek, Slot-Blueprint, Kopiersemantik (`from-framework-slot`) | +| `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `technical/PARALLEL_TRAINING_STREAMS_SPEC.md` | Parallele Teilstrecken **innerhalb einer Einheit**; Kombi-Übungen weiter nutzbar **pro Stream** für Stationsrotation | | `DATABASE_SCHEMA.md` | Aktueller Stand `training_units`, Sektionen, Items | | `functional/DOMAIN_MODEL.md` | Domänenbegriffe (bei Bedarf zu erweitern) | | `EXERCISES_*` (Katalog) | Einzelübungen, Varianten | diff --git a/backend/version.py b/backend/version.py index 5cda9fe..e798998 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.121" +APP_VERSION = "0.8.122" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260514062" @@ -36,6 +36,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.122", + "date": "2026-05-13", + "changes": [ + "Frontend Phase 3 (Teil): useExerciseListCatalogsAndQuery — Katalog-Fetch und Übungslisten-Laden/Keyset-Pagination aus ExercisesListPage in Hook ausgelagert.", + ], + }, { "version": "0.8.121", "date": "2026-05-13", diff --git a/frontend/src/hooks/useExerciseListCatalogsAndQuery.js b/frontend/src/hooks/useExerciseListCatalogsAndQuery.js new file mode 100644 index 0000000..64bbe00 --- /dev/null +++ b/frontend/src/hooks/useExerciseListCatalogsAndQuery.js @@ -0,0 +1,120 @@ +import { useState, useEffect, useCallback } from 'react' +import api from '../utils/api' + +export const EXERCISE_LIST_PAGE_SIZE = 100 + +/** + * Lädt Kataloge für Filter/Bulk einmalig und hält die Übungsliste (Offset + Keyset „Mehr laden“) synchron zu queryBase. + */ +export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey }) { + const [catalogs, setCatalogs] = useState({ + focusAreas: [], + styleDirections: [], + trainingTypes: [], + targetGroups: [], + skills: [], + }) + const [catalogsReady, setCatalogsReady] = useState(false) + const [exercises, setExercises] = useState([]) + const [listFetching, setListFetching] = useState(false) + const [loadingMore, setLoadingMore] = useState(false) + const [hasMore, setHasMore] = useState(false) + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const [fa, sd, tt, tg, sk] = await Promise.all([ + api.listFocusAreas(), + api.listStyleDirections(), + api.listTrainingTypes(), + api.listTargetGroups(), + api.listSkills(), + ]) + if (!cancelled) { + setCatalogs({ + focusAreas: fa, + styleDirections: sd, + trainingTypes: tt, + targetGroups: tg, + skills: sk, + }) + setCatalogsReady(true) + } + } catch (err) { + if (!cancelled) { + console.error(err) + alert('Kataloge konnten nicht geladen werden: ' + err.message) + setCatalogsReady(true) + } + } + })() + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + if (!catalogsReady || pageTab !== 'list') return + let cancelled = false + const run = async () => { + setListFetching(true) + try { + const batch = await api.listExercises({ + ...queryBase, + limit: EXERCISE_LIST_PAGE_SIZE, + offset: 0, + }) + if (cancelled) return + setExercises(batch) + setHasMore(batch.length === EXERCISE_LIST_PAGE_SIZE) + } catch (err) { + if (!cancelled) { + console.error('Failed to load data:', err) + alert('Fehler beim Laden: ' + err.message) + } + } finally { + if (!cancelled) setListFetching(false) + } + } + run() + return () => { + cancelled = true + } + }, [queryBase, catalogsReady, pageTab, tenantClubDepKey]) + + const loadMore = useCallback(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: EXERCISE_LIST_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 === EXERCISE_LIST_PAGE_SIZE) + } catch (err) { + alert('Fehler: ' + err.message) + } finally { + setLoadingMore(false) + } + }, [loadingMore, hasMore, exercises, queryBase]) + + return { + catalogs, + catalogsReady, + exercises, + setExercises, + listFetching, + loadingMore, + hasMore, + loadMore, + } +} diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index f58ae55..5e2473b 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -10,6 +10,7 @@ import ExerciseListBulkModal from '../components/exercises/ExerciseListBulkModal import ExerciseListSearchBar from '../components/exercises/ExerciseListSearchBar' import { buildExerciseListFilterChips } from '../utils/exerciseListFilterChips' import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../utils/exerciseListQuery' +import { useExerciseListCatalogsAndQuery } from '../hooks/useExerciseListCatalogsAndQuery' import { INITIAL_EXERCISE_LIST_FILTERS, mergeExerciseListPrefsFromApi, @@ -18,7 +19,6 @@ import { const ExerciseProgressionGraphPanel = lazy(() => import('../components/ExerciseProgressionGraphPanel')) -const PAGE_SIZE = 100 const BULK_MAX_IDS = 500 const EXERCISES_PAGE_TABS = [ { id: 'list', label: 'Liste' }, @@ -40,18 +40,6 @@ function ExercisesListPage() { } }) - const [exercises, setExercises] = useState([]) - const [catalogs, setCatalogs] = useState({ - focusAreas: [], - styleDirections: [], - trainingTypes: [], - targetGroups: [], - skills: [], - }) - const [catalogsReady, setCatalogsReady] = useState(false) - const [listFetching, setListFetching] = useState(false) - const [loadingMore, setLoadingMore] = useState(false) - const [hasMore, setHasMore] = useState(false) const [searchInput, setSearchInput] = useState('') const [aiSearchInput, setAiSearchInput] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('') @@ -115,6 +103,26 @@ function ExercisesListPage() { return () => window.removeEventListener('keydown', onKey) }, [filterModalOpen]) + const queryBase = useMemo( + () => buildExerciseListQueryBase(filters, debouncedSearch, debouncedAiSearch, mineOnly), + [filters, debouncedSearch, debouncedAiSearch, mineOnly] + ) + + const { + catalogs, + catalogsReady, + exercises, + setExercises, + listFetching, + loadingMore, + hasMore, + loadMore, + } = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey }) + + useEffect(() => { + setSelectedIds(new Set()) + }, [queryBase]) + const focusOptions = useMemo( () => catalogs.focusAreas.map((fa) => ({ @@ -191,15 +199,6 @@ function ExercisesListPage() { return [...new Set(titles)].slice(0, 80) }, [exercises]) - const queryBase = useMemo( - () => buildExerciseListQueryBase(filters, debouncedSearch, debouncedAiSearch, mineOnly), - [filters, debouncedSearch, debouncedAiSearch, mineOnly] - ) - - useEffect(() => { - setSelectedIds(new Set()) - }, [queryBase]) - const clubNameById = useMemo(() => { const m = {} for (const c of activeClubMemberships(user?.clubs)) { @@ -257,89 +256,6 @@ function ExercisesListPage() { return base }, [isSuperadmin]) - useEffect(() => { - let cancelled = false - ;(async () => { - try { - const [fa, sd, tt, tg, sk] = await Promise.all([ - api.listFocusAreas(), - api.listStyleDirections(), - api.listTrainingTypes(), - api.listTargetGroups(), - api.listSkills(), - ]) - if (!cancelled) { - setCatalogs({ - focusAreas: fa, - styleDirections: sd, - trainingTypes: tt, - targetGroups: tg, - skills: sk, - }) - setCatalogsReady(true) - } - } catch (err) { - if (!cancelled) { - console.error(err) - alert('Kataloge konnten nicht geladen werden: ' + err.message) - setCatalogsReady(true) - } - } - })() - return () => { - cancelled = true - } - }, []) - - useEffect(() => { - if (!catalogsReady || pageTab !== 'list') return - let cancelled = false - const run = async () => { - setListFetching(true) - try { - const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 }) - if (cancelled) return - setExercises(batch) - setHasMore(batch.length === PAGE_SIZE) - } catch (err) { - if (!cancelled) { - console.error('Failed to load data:', err) - alert('Fehler beim Laden: ' + err.message) - } - } finally { - if (!cancelled) setListFetching(false) - } - } - run() - return () => { - cancelled = true - } - }, [queryBase, catalogsReady, pageTab, tenantClubDepKey]) - - 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, - 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) - } catch (err) { - alert('Fehler: ' + err.message) - } finally { - setLoadingMore(false) - } - } - const handleDelete = async (exercise) => { if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return try { -- 2.43.0 From 2e105a99b81e4a9b686826a634db4f5aab94b83a Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 14 May 2026 11:48:11 +0200 Subject: [PATCH 3/3] chore(version): update version and changelog for release 0.8.123 - Bumped APP_VERSION to 0.8.123 and updated the changelog to reflect recent changes. - Fixed internal calls in GET /api/dashboard/kpis to use unwrap_query_default, preventing 500 errors due to FastAPI query defaults. - Enhanced list_exercises and list_training_units functions to utilize unwrap_query_default for improved query handling. - Added unit tests for unwrap_query_default to ensure correct behavior in various scenarios. --- backend/fastapi_param_unwrap.py | 16 ++++++++++++++++ backend/routers/exercises.py | 7 +++++++ backend/routers/training_planning.py | 15 +++++++++++++++ backend/tests/test_dashboard_kpis.py | 10 +++++++++- backend/version.py | 9 ++++++++- 5 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 backend/fastapi_param_unwrap.py diff --git a/backend/fastapi_param_unwrap.py b/backend/fastapi_param_unwrap.py new file mode 100644 index 0000000..b67d84b --- /dev/null +++ b/backend/fastapi_param_unwrap.py @@ -0,0 +1,16 @@ +"""Hilfen für direkte Python-Aufrufe von FastAPI-Route-Handlern (ohne Request-Kontext).""" +from __future__ import annotations + +from typing import Any + + +def unwrap_query_default(value: Any) -> Any: + """ + Parameter mit Annotation ``= Query(default=…)`` sind im Funktionskörper ``fastapi.params.Query``-Instanzen, + solange FastAPI sie nicht durch echte Werte ersetzt hat (interne Aufrufe, Aggregat-Endpunkte). + """ + try: + from fastapi.params import Query + except ImportError: + return value + return value.default if isinstance(value, Query) else value diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index c85840b..02ffcc6 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -19,6 +19,8 @@ from fastapi.responses import FileResponse, Response, StreamingResponse from pydantic import BaseModel, Field, model_validator from psycopg2.extras import Json +from fastapi_param_unwrap import unwrap_query_default + from db import get_db, get_cursor, r2d from club_tenancy import ( assert_valid_governance_visibility, @@ -1773,6 +1775,11 @@ def list_exercises( 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). """ + cursor_updated_at = unwrap_query_default(cursor_updated_at) + cursor_id = unwrap_query_default(cursor_id) + limit = unwrap_query_default(limit) + offset = unwrap_query_default(offset) + profile_id = tenant.profile_id c_ts_raw = (cursor_updated_at or "").strip() or None diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index d2c635b..461999e 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -10,6 +10,8 @@ from typing import Any, Dict, List, Optional, Tuple from fastapi import APIRouter, Depends, HTTPException, Query from psycopg2.extras import Json as PsycopgJson +from fastapi_param_unwrap import unwrap_query_default + from db import get_db, get_cursor, r2d from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql from club_tenancy import ( @@ -1341,6 +1343,19 @@ def list_training_units( ), tenant: TenantContext = Depends(get_tenant_context), ): + group_id = unwrap_query_default(group_id) + club_id = unwrap_query_default(club_id) + start_date = unwrap_query_default(start_date) + end_date = unwrap_query_default(end_date) + status = unwrap_query_default(status) + assigned_to_me = unwrap_query_default(assigned_to_me) + debrief_pending = unwrap_query_default(debrief_pending) + sort = unwrap_query_default(sort) + limit = unwrap_query_default(limit) + cursor_planned_date = unwrap_query_default(cursor_planned_date) + cursor_planned_time = unwrap_query_default(cursor_planned_time) + cursor_id = unwrap_query_default(cursor_id) + profile_id = tenant.profile_id role = tenant.global_role diff --git a/backend/tests/test_dashboard_kpis.py b/backend/tests/test_dashboard_kpis.py index 8847f01..c9ea3dc 100644 --- a/backend/tests/test_dashboard_kpis.py +++ b/backend/tests/test_dashboard_kpis.py @@ -1,13 +1,15 @@ -"""GET /api/dashboard/kpis: Auth (kein DB nötig).""" +"""GET /api/dashboard/kpis: Auth + interne Aufruf-Hilfen.""" from __future__ import annotations import os import pytest +from fastapi import Query from fastapi.testclient import TestClient os.environ.setdefault("SKIP_DB_MIGRATE", "1") +from fastapi_param_unwrap import unwrap_query_default from main import app @@ -16,6 +18,12 @@ def client() -> TestClient: return TestClient(app) +def test_unwrap_query_default_for_direct_route_calls() -> None: + assert unwrap_query_default(Query(default=None)) is None + assert unwrap_query_default("2026-01-01") == "2026-01-01" + assert unwrap_query_default(7) == 7 + + 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 e798998..7364ece 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.122" +APP_VERSION = "0.8.123" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260514062" @@ -36,6 +36,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.123", + "date": "2026-05-13", + "changes": [ + "Fix: GET /api/dashboard/kpis — interne Aufrufe von list_exercises / list_training_units erhielten FastAPI-Query-Defaults statt None; .strip() auf Query-Objekt → 500. unwrap_query_default in beiden Handlern (Hilfsmodul fastapi_param_unwrap.py).", + ], + }, { "version": "0.8.122", "date": "2026-05-13", -- 2.43.0