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
|
||||
|
||||
APP_VERSION = "0.8.118"
|
||||
APP_VERSION = "0.8.119"
|
||||
BUILD_DATE = "2026-05-12"
|
||||
DB_SCHEMA_VERSION = "20260514062"
|
||||
|
||||
|
|
@ -36,6 +36,13 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.119",
|
||||
"date": "2026-05-13",
|
||||
"changes": [
|
||||
"Frontend Phase 3 (Teil): Übungsliste — ExerciseListCard-Komponente, Progressions-Tab lazy (Suspense); Übungspicker-Modal mit @tanstack/react-virtual; content-visibility auf Karten im Übungs-Gitter; Playwright-Test 9 Übungsliste.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.118",
|
||||
"date": "2026-05-14",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||
|
||||
**Stand:** 2026-05-14
|
||||
**App-Version / DB-Schema:** App **0.8.118**, DB-Schema **`20260514062`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`)
|
||||
**Stand:** 2026-05-13
|
||||
**App-Version / DB-Schema:** App **0.8.119**, DB-Schema **`20260514062`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`)
|
||||
|
||||
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
- **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**.
|
||||
- **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
|
||||
|
||||
### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.118**)
|
||||
### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.119**)
|
||||
|
||||
- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§ 10.2.1** IDs, **§ 10.4** Coaching-Stufen, **§ 10.6** Produkt-Backlog, **Anhang A** Abgleich).
|
||||
- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4a–g** — u. a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@
|
|||
- **Phase 1 (Teil):** Org-Inbox: **ein** gemeinsamer Ladepfad `fetchOrgInboxSnapshot` für Mount-`useEffect` und `refreshOrgInbox` (gleiche Requests, weniger Drift-Risiko; Verhalten unverändert).
|
||||
- **Phase 2:** **abgeschlossen** (2026-05-14) — Indizes 058–062, Keyset `/api/exercises` + `/api/training-units`, **`/api/dashboard/kpis`** inkl. `training_home`, EXPLAIN-Vorlagen **`scripts/load/explain-readpaths.sql`**.
|
||||
- **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget.
|
||||
- **Phase 1:** **verzögertes Erstlade** Org-Inbox per Idle ist umgesetzt.
|
||||
|
||||
**Ziel:** Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**.
|
||||
- **Phase 3 (gestartet 2026-05-13):** Übungsliste — extrahierte Karte, **virtualisierter** Picker, **lazy** Progressions-Panel; Playwright **Test 9**; Grid `data-testid`. Weiter: God-Pages (Planung/Formular) zerteilen. Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**.
|
||||
**Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md).
|
||||
|
||||
---
|
||||
|
|
@ -82,6 +80,8 @@
|
|||
| Virtualisierung für die längste produktive Liste | A1, S2 |
|
||||
| Schwere Imports auf `import()` umziehen (gezielt) | A4 |
|
||||
|
||||
**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten in `components/exercises/ExerciseListCard.jsx`; Tab „Progressionsgraphen“ lädt **`ExerciseProgressionGraphPanel`** per `React.lazy` + `Suspense`; **`ExercisePickerModal`** virtualisiert (`@tanstack/react-virtual`, Scroll-Container `data-testid="exercise-picker-scroll"`); Gitter `data-testid="exercises-list-grid"` + `content-visibility` in `app.css`; Playwright **Test 9**. Offen: Seite unter Soft-Limit (~500 Zeilen), vollständige Zerteilung `TrainingPlanningPage` / `ExerciseFormPage`.
|
||||
|
||||
**Abnahme:** Referenz-Page unter Soft-Limit; Regel S1 für neue Änderungen durchsetzbar.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^0.344.0",
|
||||
"marked": "^18.0.3",
|
||||
|
|
|
|||
|
|
@ -2578,6 +2578,8 @@ a.analysis-split__nav-item {
|
|||
.exercises-list-grid > .exercise-card {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 240px;
|
||||
}
|
||||
.exercise-card-layout {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
* Übungssuche mit Volltext-, KI-/Semantikfeld (aktuell gleiche Engine wie Suche) und erweiterten Filtern.
|
||||
* Paginierung bis max. 100 Treffer pro Request (API-Limit).
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import api from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
||||
|
|
@ -59,6 +60,7 @@ export default function ExercisePickerModal({
|
|||
const [quickTitle, setQuickTitle] = useState('')
|
||||
const [quickSummary, setQuickSummary] = useState('')
|
||||
const [quickSaving, setQuickSaving] = useState(false)
|
||||
const pickerScrollRef = useRef(null)
|
||||
|
||||
const toggleMultiPick = (ex) => {
|
||||
setMultiPicked((prev) =>
|
||||
|
|
@ -276,6 +278,14 @@ export default function ExercisePickerModal({
|
|||
}
|
||||
}
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: list.length,
|
||||
getScrollElement: () => pickerScrollRef.current,
|
||||
estimateSize: () => 88,
|
||||
overscan: 8,
|
||||
getItemKey: (index) => String(list[index]?.id ?? index),
|
||||
})
|
||||
|
||||
const resetFilters = () => setFilters({ ...INITIAL_FILTERS })
|
||||
|
||||
const submitQuickCreate = async () => {
|
||||
|
|
@ -585,7 +595,11 @@ export default function ExercisePickerModal({
|
|||
</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) ? (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<div className="spinner" />
|
||||
|
|
@ -597,8 +611,18 @@ export default function ExercisePickerModal({
|
|||
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: 10 }}>
|
||||
{list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}
|
||||
</p>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{list.map((ex) => {
|
||||
<div
|
||||
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 rowInner = (
|
||||
<>
|
||||
|
|
@ -630,9 +654,22 @@ export default function ExercisePickerModal({
|
|||
) : null}
|
||||
</>
|
||||
)
|
||||
if (multiSelect) {
|
||||
return (
|
||||
<li key={ex.id}>
|
||||
return (
|
||||
<div
|
||||
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
|
||||
className="tu-ex-picker-multi-row"
|
||||
style={{
|
||||
|
|
@ -642,7 +679,6 @@ export default function ExercisePickerModal({
|
|||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '10px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: '8px',
|
||||
border: picked ? '2px solid var(--accent)' : '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
|
|
@ -659,34 +695,30 @@ export default function ExercisePickerModal({
|
|||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>{rowInner}</div>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<li key={ex.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectExercise(ex)
|
||||
onClose()
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '10px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{rowInner}
|
||||
</button>
|
||||
</li>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectExercise(ex)
|
||||
onClose()
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{rowInner}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div style={{ textAlign: 'center', marginTop: 12 }}>
|
||||
<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 {
|
||||
Eye,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Globe,
|
||||
Users,
|
||||
Lock,
|
||||
CheckCircle2,
|
||||
Archive,
|
||||
CircleDot,
|
||||
FilePenLine,
|
||||
} from 'lucide-react'
|
||||
import api from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
|
||||
|
|
@ -19,9 +7,8 @@ import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
|||
import MultiSelectCombo from '../components/MultiSelectCombo'
|
||||
import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
|
||||
import CatalogRulePicker from '../components/CatalogRulePicker'
|
||||
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
||||
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
|
||||
import PageSectionNav from '../components/PageSectionNav'
|
||||
import ExerciseListCard from '../components/exercises/ExerciseListCard'
|
||||
import {
|
||||
INITIAL_EXERCISE_LIST_FILTERS,
|
||||
mergeExerciseListPrefsFromApi,
|
||||
|
|
@ -29,8 +16,8 @@ import {
|
|||
splitMnCatalogRules,
|
||||
splitScalarCatalogRules,
|
||||
} from '../constants/exerciseListFilters'
|
||||
import { coerceApiNameList } from '../utils/sanitizeHtml'
|
||||
import { canUserRequestExerciseDelete } from '../utils/exercisePermissions'
|
||||
|
||||
const ExerciseProgressionGraphPanel = lazy(() => import('../components/ExerciseProgressionGraphPanel'))
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
const BULK_MAX_IDS = 500
|
||||
|
|
@ -40,22 +27,6 @@ const EXERCISES_PAGE_TABS = [
|
|||
]
|
||||
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
||||
|
||||
const VIS_LABELS = { official: 'Global', club: 'Verein', private: 'Privat' }
|
||||
const STATUS_LABELS = {
|
||||
draft: 'Entwurf',
|
||||
in_review: 'In Prüfung',
|
||||
approved: 'Freigegeben',
|
||||
archived: 'Archiv',
|
||||
}
|
||||
|
||||
function visibilityLabel(v) {
|
||||
return VIS_LABELS[v] || v || '—'
|
||||
}
|
||||
|
||||
function statusLabel(s) {
|
||||
return STATUS_LABELS[s] || s || '—'
|
||||
}
|
||||
|
||||
function pushCatalogRuleFilterChips(chips, field, rules, options, topicLabel, setFilters) {
|
||||
;(rules || []).forEach((r) => {
|
||||
const rid = String(r.id ?? r.focus_area_id ?? '')
|
||||
|
|
@ -72,54 +43,6 @@ function pushCatalogRuleFilterChips(chips, field, rules, options, topicLabel, se
|
|||
})
|
||||
}
|
||||
|
||||
function exerciseFocusNames(ex) {
|
||||
const fromApi = coerceApiNameList(ex.focus_area_names)
|
||||
if (fromApi.length) return fromApi
|
||||
if (ex.focus_area) return [ex.focus_area]
|
||||
return []
|
||||
}
|
||||
|
||||
function exerciseCardClassName(exercise, userId) {
|
||||
const vis = exercise.visibility || 'private'
|
||||
const visKey = vis === 'official' || vis === 'club' || vis === 'private' ? vis : 'private'
|
||||
const mine = userId != null && Number(exercise.created_by) === Number(userId)
|
||||
return ['card', 'exercise-card', `exercise-card--scope-${visKey}`, mine ? 'exercise-card--mine' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function ExerciseCardScopeStatus({ exercise }) {
|
||||
const v = exercise.visibility || 'private'
|
||||
const s = exercise.status || 'draft'
|
||||
const visLabel = visibilityLabel(v)
|
||||
const stLabel = statusLabel(s)
|
||||
const tip = `${visLabel} · ${stLabel}`
|
||||
let VisIcon = Lock
|
||||
if (v === 'official') VisIcon = Globe
|
||||
else if (v === 'club') VisIcon = Users
|
||||
let StatIcon = FilePenLine
|
||||
if (s === 'approved') StatIcon = CheckCircle2
|
||||
else if (s === 'archived') StatIcon = Archive
|
||||
else if (s === 'in_review') StatIcon = CircleDot
|
||||
return (
|
||||
<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) {
|
||||
const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr))
|
||||
return o ? String(o.level) : String(levelStr)
|
||||
|
|
@ -835,7 +758,18 @@ function ExercisesListPage() {
|
|||
/>
|
||||
|
||||
{pageTab === 'progression' ? (
|
||||
<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">
|
||||
|
|
@ -1384,89 +1318,17 @@ function ExercisesListPage() {
|
|||
{exercises.length} angezeigt
|
||||
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
||||
</p>
|
||||
<div className="exercises-list-grid">
|
||||
{exercises.map((exercise) => {
|
||||
const focusNames = exerciseFocusNames(exercise)
|
||||
const styleNames = coerceApiNameList(exercise.style_direction_names)
|
||||
const typeNames = coerceApiNameList(exercise.training_type_names)
|
||||
return (
|
||||
<div key={exercise.id} 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={() => handleDelete(exercise)}
|
||||
>
|
||||
<Trash2 size={18} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="exercises-list-grid" data-testid="exercises-list-grid">
|
||||
{exercises.map((exercise) => (
|
||||
<ExerciseListCard
|
||||
key={exercise.id}
|
||||
exercise={exercise}
|
||||
user={user}
|
||||
selectedIds={selectedIds}
|
||||
toggleSelect={toggleSelect}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<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');
|
||||
});
|
||||
|
||||
test('9. Übungsliste: nach Laden entweder Treffer-Gitter oder Leerhinweis', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/exercises', { waitUntil: 'networkidle' });
|
||||
const main = page.locator('.app-main');
|
||||
await expect(main.getByRole('heading', { level: 1, name: /Übungen/i })).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
await expect(main.locator('.spinner')).toHaveCount(0, { timeout: 20000 });
|
||||
const grid = main.getByTestId('exercises-list-grid');
|
||||
const empty = main.locator('.exercises-empty-text');
|
||||
await expect(grid.or(empty).first()).toBeVisible({ timeout: 15000 });
|
||||
console.log('✓ Übungsliste: Endzustand sichtbar (Gitter oder leer)');
|
||||
});
|
||||
|
||||
test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await login(page);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user