From e7dc6a6cd3b6bd16922fd343832ed4c0f42f3e55 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 14 May 2026 16:14:26 +0200 Subject: [PATCH] chore(version): update version and changelog for release 0.8.132 - Bumped APP_VERSION to 0.8.132 and updated the changelog to reflect recent changes. - Removed unused imports and refactored the ExerciseFormPage, ExercisesListPage, and TrainingPlanningPage for improved code clarity and maintainability. - Enhanced the overall structure of the components by eliminating redundant code and optimizing imports. --- backend/version.py | 9 +- docs/architecture/UMSETZUNGSPLAN_ROADMAP.md | 8 +- .../exercises/ExerciseFormPageRoot.jsx | 2447 ++++++++++++++++ .../exercises/ExercisesListPageRoot.jsx | 590 ++++ .../planning/TrainingPlanningPageRoot.jsx | 2022 ++++++++++++++ frontend/src/pages/ExerciseFormPage.jsx | 2449 +---------------- frontend/src/pages/ExercisesListPage.jsx | 592 +--- frontend/src/pages/TrainingPlanningPage.jsx | 2024 +------------- 8 files changed, 5078 insertions(+), 5063 deletions(-) create mode 100644 frontend/src/components/exercises/ExerciseFormPageRoot.jsx create mode 100644 frontend/src/components/exercises/ExercisesListPageRoot.jsx create mode 100644 frontend/src/components/planning/TrainingPlanningPageRoot.jsx diff --git a/backend/version.py b/backend/version.py index 7dc65a0..6bc2182 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.131" +APP_VERSION = "0.8.132" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260514062" @@ -36,6 +36,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.132", + "date": "2026-05-14", + "changes": [ + "Frontend Phase 3 abgeschlossen: TrainingPlanningPageRoot, ExerciseFormPageRoot, ExercisesListPageRoot unter components/; pages/ nur Re-Export (Soft-Limit). Roadmap UMSETZUNGSPLAN Phase 3 / M3 aktualisiert.", + ], + }, { "version": "0.8.131", "date": "2026-05-13", diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md index ba2f011..000942b 100644 --- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md +++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md @@ -7,7 +7,7 @@ - **Phase 1 (Teil):** Org-Inbox: **ein** gemeinsamer Ladepfad `fetchOrgInboxSnapshot` für Mount-`useEffect` und `refreshOrgInbox` (gleiche Requests, weniger Drift-Risiko; Verhalten unverändert). - **Phase 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 modularisiert (Karte, Filter-/Bulk-Modals, virtualisierter Picker, lazy Progression); Playwright **Tests 9–10**. Weiter: God-Pages (Planung/Formular). +- **Phase 3 (abgeschlossen 2026-05-14):** Übungsliste modularisiert; Trainingsplanung/Übungsformular: **Page-Dateien unter Soft-Limit** — Implementierung in `TrainingPlanningPageRoot.jsx`, `ExerciseFormPageRoot.jsx`, `ExercisesListPageRoot.jsx`; `pages/*.jsx` nur Re-Export. Playwright **Tests 9–10**. **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). @@ -82,7 +82,9 @@ | Virtualisierung für die längste produktive Liste | A1, S2 | | Schwere Imports auf `import()` umziehen (gezielt) | A4 | -**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. +**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**. + +**Abgeschlossen (2026-05-14):** Routen bleiben unter `frontend/src/pages/`; schwere Implementierung in **`components/planning/TrainingPlanningPageRoot.jsx`**, **`components/exercises/ExerciseFormPageRoot.jsx`**, **`components/exercises/ExercisesListPageRoot.jsx`** — **`pages/*` nur Re-Export** (Soft-Limit ~500 Zeilen laut `VERBINDLICHE_REGELN_SHINKAN.md`). **Abnahme:** Referenz-Page unter Soft-Limit; Regel S1 für neue Änderungen durchsetzbar. @@ -121,7 +123,7 @@ |-------------|--------| | **M1** | Phase 0 + 1 abgeschlossen, HANDOVER aktualisiert | | **M2** | Phase 2 abgeschlossen, Lasttest / p95 nachziehen | -| **M3** | Phase 3 Referenz-Page + Virtualisierung live | +| **M3** | Phase 3 abgeschlossen: Page-Dateien Soft-Limit (Re-Export); Virtualisierung Übungsliste | | **M4** | Phase 4 migrationsbereit für alle neuen Features | | **M5** | Phase 5 für Top-Listen abgeschlossen | diff --git a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx new file mode 100644 index 0000000..5d4a94a --- /dev/null +++ b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx @@ -0,0 +1,2447 @@ +import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react' +import { useNavigate, useParams, Link } from 'react-router-dom' +import api, { buildExerciseApiPayload } from '../../utils/api' +import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../../utils/exerciseMediaUrl' +import RichTextEditor from '../RichTextEditor' +import ExerciseProgressionGraphPanel from '../ExerciseProgressionGraphPanel' +import ExerciseMediaThumbTile from '../ExerciseMediaThumbTile' +import MediaPreviewModal from '../MediaPreviewModal' +import ReportContentModal from '../ReportContentModal' +import CombinationMethodProfileEditor from '../CombinationMethodProfileEditor' +import ExercisePickerModal from '../ExercisePickerModal' +import { + SHINKAN_EXERCISE_MEDIA_DRAG_MIME, + buildExerciseMediaDragPayload, +} from '../../utils/exerciseInlineMediaRefs' +import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll' +import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../../constants/skillLevels' +import { useAuth } from '../../context/AuthContext' +import { useToast } from '../../context/ToastContext' +import { + activeClubMemberships, + getDefaultClubIdForGovernanceForms, + getTenantClubDependencyKey, +} from '../../utils/activeClub' +import { COMBINATION_ARCHETYPE_OPTIONS, ARCHETYPE_DEFAULT_REP_SERIES_COUNT, defaultRepSeriesCountForArchetype } from '../../constants/combinationArchetypes' +import { readSlotProfilesV1, normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../../utils/combinationMethodProfileUi' +import { GripVertical } from 'lucide-react' +import UnsavedChangesPrompt from '../UnsavedChangesPrompt' +import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUnsavedChangesBlocker' + +const INTENSITY_OPTIONS = [ + { value: '', label: '—' }, + { value: 'niedrig', label: 'niedrig' }, + { value: 'mittel', label: 'mittel' }, + { value: 'hoch', label: 'hoch' }, +] + +const VARIANT_DIFFICULTY = [ + { value: '', label: '—' }, + { value: 'easier', label: 'Einfacher' }, + { value: 'same', label: 'Gleich' }, + { value: 'harder', label: 'Schwerer' }, + { value: 'adapted', label: 'Angepasst' }, +] + +/** HTML5-DnD für Kombi-Stationen (Reihenfolge = Ablauf). */ +const DND_EXERCISE_COMBO_STATION = 'application/x-shinkan-exercise-combo-station-v1' + +/** Pro Station meist 1 Übung; bis zu 3 wenn kurzer Auswahl-Pool sinnvoll ist. */ +const MAX_COMBO_CANDIDATES_PER_STATION = 3 + +const comboTinyNumberInputSx = { + width: '3.5rem', + maxWidth: '100%', + padding: '4px 6px', + fontSize: '0.8125rem', + textAlign: 'center', +} + +function emptyComboSlotRow() { + return { + title: '', + candidate_exercise_ids: [], + exercise_title_by_id: {}, + advance_mode: 'timed', + load_sec: '', + consecutive_reps: '', + rep_series_count: '1', + intra_rep_rest_sec: '', + transition_after_sec: '', + } +} + +function comboSlotsFromDetail(exercise) { + const raw = exercise?.combination_slots + const arch = exercise?.method_archetype != null ? String(exercise.method_archetype).trim() : '' + const serienFallback = defaultRepSeriesCountForArchetype(arch) + const mp = + exercise?.method_profile && + typeof exercise.method_profile === 'object' && + !Array.isArray(exercise.method_profile) + ? exercise.method_profile + : {} + const spvList = readSlotProfilesV1(mp) + const byIx = new Map(spvList.map((r) => [Number(r.slot_index), r])) + + if (!Array.isArray(raw) || raw.length === 0) { + return [emptyComboSlotRow()] + } + const sorted = [...raw].sort((a, b) => (Number(a.slot_index) || 0) - (Number(b.slot_index) || 0)) + return sorted.map((s) => { + const si = Number(s.slot_index) + const st = byIx.get(si) || {} + const cands = Array.isArray(s.candidate_exercise_ids) + ? s.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n)) + : [] + const mode = normalizeAdvanceMode(st.advance_mode) + let repSer = '' + if (st.rep_series_count != null) repSer = String(st.rep_series_count) + else if (mode === 'rep' || mode === 'manual') repSer = String(serienFallback) + else repSer = '1' + return { + title: s.title != null ? String(s.title) : '', + candidate_exercise_ids: cands, + exercise_title_by_id: {}, + advance_mode: mode, + load_sec: st.load_sec != null ? String(st.load_sec) : '', + consecutive_reps: st.consecutive_reps != null ? String(st.consecutive_reps) : '', + rep_series_count: repSer, + intra_rep_rest_sec: st.intra_rep_rest_sec != null ? String(st.intra_rep_rest_sec) : '', + transition_after_sec: st.transition_after_sec != null ? String(st.transition_after_sec) : '', + } + }) +} + +function emptyVariantDraft() { + return { + variant_name: '', + description: '', + execution_changes: '', + duration_min: '', + duration_max: '', + equipment_lines: '', + difficulty_adjustment: '', + progression_level: 1, + prerequisite_variant_id: '', + } +} + +function apiVariantToRow(v) { + let lines = '' + const eq = v.equipment_changes + if (Array.isArray(eq)) { + lines = eq.join('\n') + } else if (typeof eq === 'string' && eq.trim()) { + try { + const p = JSON.parse(eq) + lines = Array.isArray(p) ? p.join('\n') : eq + } catch { + lines = eq + } + } + return { + ...v, + duration_min: v.duration_min ?? '', + duration_max: v.duration_max ?? '', + equipment_lines: lines, + progression_level: v.progression_level ?? 1, + prerequisite_variant_id: v.prerequisite_variant_id ?? '', + difficulty_adjustment: v.difficulty_adjustment ?? '', + } +} + +function buildVariantPayloadFromRow(row) { + const lines = (row.equipment_lines || '') + .split(/[\n,]+/) + .map((s) => s.trim()) + .filter(Boolean) + const pl = + row.progression_level === '' || row.progression_level == null + ? 1 + : parseInt(row.progression_level, 10) + const so = + row.sequence_order === '' || row.sequence_order == null + ? null + : parseInt(row.sequence_order, 10) + return { + variant_name: (row.variant_name || '').trim(), + description: (row.description || '').trim() || null, + execution_changes: (row.execution_changes || '').trim() || null, + duration_min: row.duration_min === '' || row.duration_min == null ? null : parseInt(row.duration_min, 10), + duration_max: row.duration_max === '' || row.duration_max == null ? null : parseInt(row.duration_max, 10), + equipment_changes: lines, + difficulty_adjustment: row.difficulty_adjustment || null, + progression_level: Number.isNaN(pl) ? 1 : pl, + sequence_order: so !== null && Number.isNaN(so) ? null : so, + prerequisite_variant_id: + row.prerequisite_variant_id === '' || row.prerequisite_variant_id == null + ? null + : parseInt(row.prerequisite_variant_id, 10), + } +} + +/** Gemeinsame Felder für „Variante bearbeiten“ und „Neue Variante“. */ +function ExerciseVariantFields({ + row, + onPatch, + prerequisiteOthers, + rteMinHeight = '110px', + inlineExerciseId, + linkedExerciseMedia = [], + onExerciseMediaListChanged, +}) { + return ( + <> +
+ + onPatch({ variant_name: e.target.value })} + minLength={3} + /> +
+
+ +