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

- 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:
Lars 2026-05-14 08:59:06 +02:00
parent b06d026dd0
commit 9da29a2231
9 changed files with 298 additions and 206 deletions

View File

@ -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",

View File

@ -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 **4ag** — u.a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).

View File

@ -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 058062, 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.
---

View File

@ -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",

View File

@ -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;

View File

@ -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}>

View 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>
)
}

View File

@ -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">

View File

@ -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);