chore(version): update version and changelog for release 0.8.119
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m2s
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m2s
- 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.
This commit is contained in:
parent
b06d026dd0
commit
9da29a2231
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.118"
|
APP_VERSION = "0.8.119"
|
||||||
BUILD_DATE = "2026-05-12"
|
BUILD_DATE = "2026-05-12"
|
||||||
DB_SCHEMA_VERSION = "20260514062"
|
DB_SCHEMA_VERSION = "20260514062"
|
||||||
|
|
||||||
|
|
@ -36,6 +36,13 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.118",
|
||||||
"date": "2026-05-14",
|
"date": "2026-05-14",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||||
|
|
||||||
**Stand:** 2026-05-14
|
**Stand:** 2026-05-13
|
||||||
**App-Version / DB-Schema:** App **0.8.118**, DB-Schema **`20260514062`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`)
|
**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**.
|
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`**.
|
- **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`.
|
- **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).
|
- **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).
|
- **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).
|
||||||
|
|
|
||||||
|
|
@ -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 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`**.
|
- **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.
|
- **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget.
|
||||||
- **Phase 1:** **verzögertes Erstlade** Org-Inbox per Idle ist umgesetzt.
|
- **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**.
|
||||||
|
|
||||||
**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).
|
**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 |
|
| Virtualisierung für die längste produktive Liste | A1, S2 |
|
||||||
| Schwere Imports auf `import()` umziehen (gezielt) | A4 |
|
| 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.
|
**Abnahme:** Referenz-Page unter Soft-Limit; Regel S1 für neue Änderungen durchsetzbar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"marked": "^18.0.3",
|
"marked": "^18.0.3",
|
||||||
|
|
|
||||||
|
|
@ -2578,6 +2578,8 @@ a.analysis-split__nav-item {
|
||||||
.exercises-list-grid > .exercise-card {
|
.exercises-list-grid > .exercise-card {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: auto 240px;
|
||||||
}
|
}
|
||||||
.exercise-card-layout {
|
.exercise-card-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
* Übungssuche mit Volltext-, KI-/Semantikfeld (aktuell gleiche Engine wie Suche) und erweiterten Filtern.
|
* Übungssuche mit Volltext-, KI-/Semantikfeld (aktuell gleiche Engine wie Suche) und erweiterten Filtern.
|
||||||
* Paginierung bis max. 100 Treffer pro Request (API-Limit).
|
* 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 api from '../utils/api'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
||||||
|
|
@ -59,6 +60,7 @@ export default function ExercisePickerModal({
|
||||||
const [quickTitle, setQuickTitle] = useState('')
|
const [quickTitle, setQuickTitle] = useState('')
|
||||||
const [quickSummary, setQuickSummary] = useState('')
|
const [quickSummary, setQuickSummary] = useState('')
|
||||||
const [quickSaving, setQuickSaving] = useState(false)
|
const [quickSaving, setQuickSaving] = useState(false)
|
||||||
|
const pickerScrollRef = useRef(null)
|
||||||
|
|
||||||
const toggleMultiPick = (ex) => {
|
const toggleMultiPick = (ex) => {
|
||||||
setMultiPicked((prev) =>
|
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 resetFilters = () => setFilters({ ...INITIAL_FILTERS })
|
||||||
|
|
||||||
const submitQuickCreate = async () => {
|
const submitQuickCreate = async () => {
|
||||||
|
|
@ -585,7 +595,11 @@ export default function ExercisePickerModal({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '12px 1rem' }}>
|
<div
|
||||||
|
ref={pickerScrollRef}
|
||||||
|
data-testid="exercise-picker-scroll"
|
||||||
|
style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '12px 1rem' }}
|
||||||
|
>
|
||||||
{!catalogsReady || (loading && list.length === 0) ? (
|
{!catalogsReady || (loading && list.length === 0) ? (
|
||||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||||
<div className="spinner" />
|
<div className="spinner" />
|
||||||
|
|
@ -597,8 +611,18 @@ export default function ExercisePickerModal({
|
||||||
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: 10 }}>
|
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: 10 }}>
|
||||||
{list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}
|
{list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}
|
||||||
</p>
|
</p>
|
||||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
<div
|
||||||
{list.map((ex) => {
|
role="list"
|
||||||
|
aria-label="Übungstreffer"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: rowVirtualizer.getTotalSize(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((vi) => {
|
||||||
|
const ex = list[vi.index]
|
||||||
|
if (!ex) return null
|
||||||
const picked = multiPicked.some((p) => p.id === ex.id)
|
const picked = multiPicked.some((p) => p.id === ex.id)
|
||||||
const rowInner = (
|
const rowInner = (
|
||||||
<>
|
<>
|
||||||
|
|
@ -630,9 +654,22 @@ export default function ExercisePickerModal({
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
if (multiSelect) {
|
return (
|
||||||
return (
|
<div
|
||||||
<li key={ex.id}>
|
key={vi.key}
|
||||||
|
role="listitem"
|
||||||
|
data-index={vi.index}
|
||||||
|
ref={rowVirtualizer.measureElement}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
transform: `translateY(${vi.start}px)`,
|
||||||
|
paddingBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{multiSelect ? (
|
||||||
<label
|
<label
|
||||||
className="tu-ex-picker-multi-row"
|
className="tu-ex-picker-multi-row"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -642,7 +679,6 @@ export default function ExercisePickerModal({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
padding: '10px 12px',
|
padding: '10px 12px',
|
||||||
marginBottom: 8,
|
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
border: picked ? '2px solid var(--accent)' : '1px solid var(--border)',
|
border: picked ? '2px solid var(--accent)' : '1px solid var(--border)',
|
||||||
background: 'var(--surface2)',
|
background: 'var(--surface2)',
|
||||||
|
|
@ -659,34 +695,30 @@ export default function ExercisePickerModal({
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>{rowInner}</div>
|
<div style={{ flex: 1, minWidth: 0 }}>{rowInner}</div>
|
||||||
</label>
|
</label>
|
||||||
</li>
|
) : (
|
||||||
)
|
<button
|
||||||
}
|
type="button"
|
||||||
return (
|
onClick={() => {
|
||||||
<li key={ex.id}>
|
onSelectExercise(ex)
|
||||||
<button
|
onClose()
|
||||||
type="button"
|
}}
|
||||||
onClick={() => {
|
style={{
|
||||||
onSelectExercise(ex)
|
width: '100%',
|
||||||
onClose()
|
textAlign: 'left',
|
||||||
}}
|
padding: '10px 12px',
|
||||||
style={{
|
borderRadius: '8px',
|
||||||
width: '100%',
|
border: '1px solid var(--border)',
|
||||||
textAlign: 'left',
|
background: 'var(--surface2)',
|
||||||
padding: '10px 12px',
|
cursor: 'pointer',
|
||||||
marginBottom: 8,
|
}}
|
||||||
borderRadius: '8px',
|
>
|
||||||
border: '1px solid var(--border)',
|
{rowInner}
|
||||||
background: 'var(--surface2)',
|
</button>
|
||||||
cursor: 'pointer',
|
)}
|
||||||
}}
|
</div>
|
||||||
>
|
|
||||||
{rowInner}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</div>
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div style={{ textAlign: 'center', marginTop: 12 }}>
|
<div style={{ textAlign: 'center', marginTop: 12 }}>
|
||||||
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
||||||
|
|
|
||||||
174
frontend/src/components/exercises/ExerciseListCard.jsx
Normal file
174
frontend/src/components/exercises/ExerciseListCard.jsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Eye,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Globe,
|
||||||
|
Users,
|
||||||
|
Lock,
|
||||||
|
CheckCircle2,
|
||||||
|
Archive,
|
||||||
|
CircleDot,
|
||||||
|
FilePenLine,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import ExerciseRichTextBlock from '../ExerciseRichTextBlock'
|
||||||
|
import { coerceApiNameList } from '../../utils/sanitizeHtml'
|
||||||
|
import { canUserRequestExerciseDelete } from '../../utils/exercisePermissions'
|
||||||
|
|
||||||
|
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 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 (
|
||||||
|
<div
|
||||||
|
className="exercise-card__meta-compact"
|
||||||
|
title={tip}
|
||||||
|
aria-label={`Sichtbarkeit: ${visLabel}. Status: ${stLabel}.`}
|
||||||
|
>
|
||||||
|
<span className="exercise-card__meta-glyph">
|
||||||
|
<VisIcon size={15} strokeWidth={2} aria-hidden />
|
||||||
|
</span>
|
||||||
|
<span className="exercise-card__meta-sep" aria-hidden>
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span className="exercise-card__meta-glyph">
|
||||||
|
<StatIcon size={15} strokeWidth={2} aria-hidden />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kartenzeile in der Übungsliste (Fokus/Planung — keine Virtualisierung im Grid, dafür content-visibility in app.css).
|
||||||
|
*/
|
||||||
|
export default function ExerciseListCard({ exercise, user, selectedIds, toggleSelect, onDelete }) {
|
||||||
|
const focusNames = exerciseFocusNames(exercise)
|
||||||
|
const styleNames = coerceApiNameList(exercise.style_direction_names)
|
||||||
|
const typeNames = coerceApiNameList(exercise.training_type_names)
|
||||||
|
return (
|
||||||
|
<div className={exerciseCardClassName(exercise, user?.id)}>
|
||||||
|
<div className="exercise-card-layout exercise-card-layout--grow">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.has(Number(exercise.id))}
|
||||||
|
onChange={() => toggleSelect(exercise.id)}
|
||||||
|
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
|
||||||
|
className="exercise-card-layout__check"
|
||||||
|
/>
|
||||||
|
<div className="exercise-card__body exercise-card-body-flex">
|
||||||
|
<h3 className="exercise-card-title">
|
||||||
|
<Link to={`/exercises/${exercise.id}`}>{exercise.title}</Link>
|
||||||
|
</h3>
|
||||||
|
<div className="exercise-card-tags">
|
||||||
|
{focusNames.map((name) => (
|
||||||
|
<span key={`fa:${name}`} className="exercise-tag exercise-tag--accent">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{styleNames.map((name) => (
|
||||||
|
<span key={`sd:${name}`} className="exercise-tag exercise-tag--style">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{typeNames.map((name) => (
|
||||||
|
<span key={`tt:${name}`} className="exercise-tag exercise-tag--training">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' ? (
|
||||||
|
<span
|
||||||
|
className="exercise-tag"
|
||||||
|
style={{ background: 'var(--accent-soft)', color: 'var(--accent-dark)' }}
|
||||||
|
>
|
||||||
|
Kombination
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{exercise.summary && String(exercise.summary).trim() ? (
|
||||||
|
<div className="exercise-card-summary exercise-card-summary--rich">
|
||||||
|
<ExerciseRichTextBlock
|
||||||
|
html={exercise.summary}
|
||||||
|
exerciseId={exercise.id}
|
||||||
|
media={exercise.media || []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="exercise-card__footer">
|
||||||
|
<ExerciseCardScopeStatus exercise={exercise} />
|
||||||
|
<div className="exercise-card__actions exercise-card__actions--icons">
|
||||||
|
<Link
|
||||||
|
to={`/exercises/${exercise.id}`}
|
||||||
|
className="exercise-card__icon-btn"
|
||||||
|
title="Ansehen"
|
||||||
|
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ ansehen`}
|
||||||
|
>
|
||||||
|
<Eye size={18} strokeWidth={2} aria-hidden />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to={`/exercises/${exercise.id}/edit`}
|
||||||
|
className="exercise-card__icon-btn"
|
||||||
|
title="Bearbeiten"
|
||||||
|
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ bearbeiten`}
|
||||||
|
>
|
||||||
|
<Pencil size={18} strokeWidth={2} aria-hidden />
|
||||||
|
</Link>
|
||||||
|
{canUserRequestExerciseDelete(user, exercise) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="exercise-card__icon-btn exercise-card__icon-btn--danger"
|
||||||
|
title="Löschen"
|
||||||
|
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ löschen`}
|
||||||
|
onClick={() => onDelete(exercise)}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} strokeWidth={2} aria-hidden />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 { 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 api from '../utils/api'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
|
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
|
||||||
|
|
@ -19,9 +7,8 @@ import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
||||||
import MultiSelectCombo from '../components/MultiSelectCombo'
|
import MultiSelectCombo from '../components/MultiSelectCombo'
|
||||||
import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
|
import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
|
||||||
import CatalogRulePicker from '../components/CatalogRulePicker'
|
import CatalogRulePicker from '../components/CatalogRulePicker'
|
||||||
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
|
||||||
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
|
|
||||||
import PageSectionNav from '../components/PageSectionNav'
|
import PageSectionNav from '../components/PageSectionNav'
|
||||||
|
import ExerciseListCard from '../components/exercises/ExerciseListCard'
|
||||||
import {
|
import {
|
||||||
INITIAL_EXERCISE_LIST_FILTERS,
|
INITIAL_EXERCISE_LIST_FILTERS,
|
||||||
mergeExerciseListPrefsFromApi,
|
mergeExerciseListPrefsFromApi,
|
||||||
|
|
@ -29,8 +16,8 @@ import {
|
||||||
splitMnCatalogRules,
|
splitMnCatalogRules,
|
||||||
splitScalarCatalogRules,
|
splitScalarCatalogRules,
|
||||||
} from '../constants/exerciseListFilters'
|
} from '../constants/exerciseListFilters'
|
||||||
import { coerceApiNameList } from '../utils/sanitizeHtml'
|
|
||||||
import { canUserRequestExerciseDelete } from '../utils/exercisePermissions'
|
const ExerciseProgressionGraphPanel = lazy(() => import('../components/ExerciseProgressionGraphPanel'))
|
||||||
|
|
||||||
const PAGE_SIZE = 100
|
const PAGE_SIZE = 100
|
||||||
const BULK_MAX_IDS = 500
|
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 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) {
|
function pushCatalogRuleFilterChips(chips, field, rules, options, topicLabel, setFilters) {
|
||||||
;(rules || []).forEach((r) => {
|
;(rules || []).forEach((r) => {
|
||||||
const rid = String(r.id ?? r.focus_area_id ?? '')
|
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 (
|
|
||||||
<div
|
|
||||||
className="exercise-card__meta-compact"
|
|
||||||
title={tip}
|
|
||||||
aria-label={`Sichtbarkeit: ${visLabel}. Status: ${stLabel}.`}
|
|
||||||
>
|
|
||||||
<span className="exercise-card__meta-glyph">
|
|
||||||
<VisIcon size={15} strokeWidth={2} aria-hidden />
|
|
||||||
</span>
|
|
||||||
<span className="exercise-card__meta-sep" aria-hidden>
|
|
||||||
·
|
|
||||||
</span>
|
|
||||||
<span className="exercise-card__meta-glyph">
|
|
||||||
<StatIcon size={15} strokeWidth={2} aria-hidden />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function levelOptionShort(levelStr) {
|
function levelOptionShort(levelStr) {
|
||||||
const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr))
|
const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr))
|
||||||
return o ? String(o.level) : String(levelStr)
|
return o ? String(o.level) : String(levelStr)
|
||||||
|
|
@ -835,7 +758,18 @@ function ExercisesListPage() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{pageTab === 'progression' ? (
|
{pageTab === 'progression' ? (
|
||||||
<ExerciseProgressionGraphPanel />
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
<p className="muted" style={{ marginTop: '12px' }}>
|
||||||
|
Lade Progressionsgraphen…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ExerciseProgressionGraphPanel />
|
||||||
|
</Suspense>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="card exercise-search-bar">
|
<div className="card exercise-search-bar">
|
||||||
|
|
@ -1384,89 +1318,17 @@ function ExercisesListPage() {
|
||||||
{exercises.length} angezeigt
|
{exercises.length} angezeigt
|
||||||
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
||||||
</p>
|
</p>
|
||||||
<div className="exercises-list-grid">
|
<div className="exercises-list-grid" data-testid="exercises-list-grid">
|
||||||
{exercises.map((exercise) => {
|
{exercises.map((exercise) => (
|
||||||
const focusNames = exerciseFocusNames(exercise)
|
<ExerciseListCard
|
||||||
const styleNames = coerceApiNameList(exercise.style_direction_names)
|
key={exercise.id}
|
||||||
const typeNames = coerceApiNameList(exercise.training_type_names)
|
exercise={exercise}
|
||||||
return (
|
user={user}
|
||||||
<div key={exercise.id} className={exerciseCardClassName(exercise, user?.id)}>
|
selectedIds={selectedIds}
|
||||||
<div className="exercise-card-layout exercise-card-layout--grow">
|
toggleSelect={toggleSelect}
|
||||||
<input
|
onDelete={handleDelete}
|
||||||
type="checkbox"
|
/>
|
||||||
checked={selectedIds.has(Number(exercise.id))}
|
))}
|
||||||
onChange={() => toggleSelect(exercise.id)}
|
|
||||||
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
|
|
||||||
className="exercise-card-layout__check"
|
|
||||||
/>
|
|
||||||
<div className="exercise-card__body exercise-card-body-flex">
|
|
||||||
<h3 className="exercise-card-title">
|
|
||||||
<Link to={`/exercises/${exercise.id}`}>
|
|
||||||
{exercise.title}
|
|
||||||
</Link>
|
|
||||||
</h3>
|
|
||||||
<div className="exercise-card-tags">
|
|
||||||
{focusNames.map((name) => (
|
|
||||||
<span key={`fa:${name}`} className="exercise-tag exercise-tag--accent">{name}</span>
|
|
||||||
))}
|
|
||||||
{styleNames.map((name) => (
|
|
||||||
<span key={`sd:${name}`} className="exercise-tag exercise-tag--style">{name}</span>
|
|
||||||
))}
|
|
||||||
{typeNames.map((name) => (
|
|
||||||
<span key={`tt:${name}`} className="exercise-tag exercise-tag--training">{name}</span>
|
|
||||||
))}
|
|
||||||
{(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' ? (
|
|
||||||
<span className="exercise-tag" style={{ background: 'var(--accent-soft)', color: 'var(--accent-dark)' }}>
|
|
||||||
Kombination
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{exercise.summary && String(exercise.summary).trim() ? (
|
|
||||||
<div className="exercise-card-summary exercise-card-summary--rich">
|
|
||||||
<ExerciseRichTextBlock
|
|
||||||
html={exercise.summary}
|
|
||||||
exerciseId={exercise.id}
|
|
||||||
media={exercise.media || []}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="exercise-card__footer">
|
|
||||||
<ExerciseCardScopeStatus exercise={exercise} />
|
|
||||||
<div className="exercise-card__actions exercise-card__actions--icons">
|
|
||||||
<Link
|
|
||||||
to={`/exercises/${exercise.id}`}
|
|
||||||
className="exercise-card__icon-btn"
|
|
||||||
title="Ansehen"
|
|
||||||
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ ansehen`}
|
|
||||||
>
|
|
||||||
<Eye size={18} strokeWidth={2} aria-hidden />
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to={`/exercises/${exercise.id}/edit`}
|
|
||||||
className="exercise-card__icon-btn"
|
|
||||||
title="Bearbeiten"
|
|
||||||
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ bearbeiten`}
|
|
||||||
>
|
|
||||||
<Pencil size={18} strokeWidth={2} aria-hidden />
|
|
||||||
</Link>
|
|
||||||
{canUserRequestExerciseDelete(user, exercise) ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="exercise-card__icon-btn exercise-card__icon-btn--danger"
|
|
||||||
title="Löschen"
|
|
||||||
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ löschen`}
|
|
||||||
onClick={() => handleDelete(exercise)}
|
|
||||||
>
|
|
||||||
<Trash2 size={18} strokeWidth={2} aria-hidden />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div className="exercises-load-more">
|
<div className="exercises-load-more">
|
||||||
|
|
|
||||||
|
|
@ -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');
|
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 }) => {
|
test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1280, height: 800 });
|
await page.setViewportSize({ width: 1280, height: 800 });
|
||||||
await login(page);
|
await login(page);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user