diff --git a/.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md b/.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md new file mode 100644 index 0000000..1628e83 --- /dev/null +++ b/.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md @@ -0,0 +1,144 @@ +# Navigation — Return-Kontext (Rücksprung) + +**Stand:** 2026-05-20 +**Status:** Spezifikation + Phase 1–2 umgesetzt +**Ziel:** In der PWA (ohne Browser-Back) zuverlässig an den fachlichen Ausgangspunkt zurückkehren — inkl. sinnvollem Label und optional UI-State. + +--- + +## Problem + +Viele Flows navigieren von Kontext A zu Editor/Detail B (z. B. Übungsliste → Modulbearbeitung). Die Zielseite kennt A nicht und bietet nur einen **fest verdrahteten** Zurück-Link (z. B. immer „Modul-Bibliothek“). In der installierten PWA fehlt zusätzlich die Browser-Chrome. + +Betroffen u. a.: + +- Übungsliste → Modul anlegen/bearbeiten +- Planung → Einheiten-Editor (teilweise gelöst via `planningReturn`) +- Modals mit Speichern + Redirect auf Vollseite + +--- + +## Strategie (Hybrid) + +| Mechanismus | Wann | +|-------------|------| +| **Expliziter Return-Kontext** (`appReturn` in Router-State) | Seitenwechsel, bei denen das Ziel einen fachlichen Rücksprung anbieten soll | +| **History-Back** (`navigate(-1)`) | Fallback, wenn kein Kontext gesetzt ist und History-Eintrag existiert | +| **Default-Pfad** | Fallback der Zielseite (z. B. Modul-Bibliothek) | +| **Modal schließen** | Overlays/Peek — kein Routing-Return | + +**Nicht** als alleinige Lösung: reines Browser-Back (History durch `replace`, Deep Links, Reload unzuverlässig). + +--- + +## Datenmodell + +Router-State-Schlüssel: **`appReturn`** + +```javascript +{ + v: 1, // Schema-Version + path: '/exercises', // Ziel-URL (inkl. Query, falls nötig) + label: 'Zurück zur Übungsliste', // Anzeige im UI (vollständiger Satz) + kind: 'exerciseList', // optional: Typ für erweiterte Wiederherstellung + payload: { ... } // optional: kind-spezifische Daten +} +``` + +### `kind`-Werte (erweiterbar) + +| kind | payload | path-Ableitung | +|------|---------|----------------| +| `exerciseList` | — | `/exercises` (Filter/Auswahl via sessionStorage) | +| `planningHub` | `buildPlanningHubReturnState(...)` | `planningHubPathFromReturnState(payload)` | +| `trainingModulesList` | — | `/planning/training-modules` | +| `planTemplatesList` | — | `/planning/plan-templates` | +| `frameworkProgramsList` | — | `/planning/framework-programs` | +| `settings` | — | `/settings` | +| `dashboard` | — | `/` | +| `mediaLibrary` | — | `/media` | +| `trainingRun` | `{ unitId }` | `/planning/run/:unitId` | +| `currentLocation` | — | aktuelle Route (z. B. Einheiten-Editor) | +| (frei) | — | `path` direkt gesetzt | + +### Legacy-Kompatibilität + +Bestehendes Feld **`planningReturn`** (Planung ↔ Einheiten-Editor) wird beim Lesen in `appReturn` **bridged** — keine Big-Bang-Migration nötig. + +--- + +## API (Frontend) + +Zentrale Datei: `frontend/src/utils/navReturnContext.js` + +| Funktion | Zweck | +|----------|--------| +| `buildNavReturnContext({ path, label, kind?, payload? })` | Kontext-Objekt erzeugen | +| `buildExercisesListReturnContext()` | Standard-Rückkehr Übungsliste | +| `buildPlanningHubReturnContext(hubState)` | Planungs-Hub inkl. Filter-Query | +| `buildTrainingModulesListReturnContext()` | Modul-Bibliothek | +| `readNavReturnFromLocation(location)` | Kontext aus `location.state` (+ Legacy) | +| `resolveNavReturnTarget(location, fallback)` | `{ path, label }` für UI | +| `goNavReturn(navigate, location, fallback?)` | Programmatischer Rücksprung (priorisiert: Kontext → History → Fallback) | +| `navigateWithAppReturn(navigate, to, returnContext, options?)` | Navigation mit gesetztem `appReturn` | +| `preserveAppReturnOnNavigate(navigate, location, to, options?)` | Weiterleiten, bestehenden Kontext behalten (z. B. nach `replace`) | + +UI-Komponente: **`PageReturnButton`** — app-typischer Zurück-Schalter (Button mit Pfeil, kein Router-Link). +Links **zum** Ziel: **`NavStateLink`** mit `returnContext` der Quellseite. + +### Editor-Aktionen + +Auf Vollseiten-Editoren mit **`FormActionBar`** (`placement="bottom"`) oder **`PageFormEditorChrome`**: + +- **Kein** separater Zurück-Link/Button oben (wirkt in der App redundant) +- **Abbrechen** → `goBack()` / `goNavReturn(...)` (Einsprungspunkt) +- **Speichern & Schließen** → nach erfolgreichem Save ebenfalls `goBack()` +- Sticky Action Bar unten nutzen + +**PageReturnButton** nur auf **Leseseiten** ohne Editor-Leiste (z. B. Übungsdetail, Einstellungen-Unterseiten, Trainingsablauf). + +--- + +## Regeln für Entwickler + +1. **Jede Navigation** von Kontext A zu Editor B, wo der Nutzer „weitermachen“ soll, setzt `appReturn` (oder nutzt `navigateWithAppReturn`). +2. **Zielseite** zeigt `PageReturnButton` mit sinnvollem **Default-Fallback** (Bibliothek/Hub). +3. **Nach Create + `replace: true`:** Return-Kontext mit `preserveAppReturnOnNavigate` erhalten. +4. **Modals:** Schließen reicht; Redirect nach Speichern = Seiten-Navigation → Return setzen. +5. **Kein Return-Kontext** in `location.state` für interne Bibliothek → Detail → Bearbeiten, wenn Herkunft = offensichtliche Elternliste (Default-Fallback genügt). +6. **UI-State** (Filter, Auswahl): weiter über bestehende Session-Mechanismen (z. B. `exerciseListSessionState`), nicht im Return-Payload duplizieren, außer kind erfordert Query-Reconstruction (Planung). + +--- + +## Umsetzungsstand + +### Phase 1 (Pilot) + +- [x] Spec + Utility + Tests +- [x] `PageReturnButton` (ersetzt Link-Variante) +- [x] Übungsliste → Modul speichern → Modul-Editor +- [x] Planung: `SaveExercisesAsModuleModal` leitet Return-Kontext weiter +- [x] `TrainingUnitEditPage`: `goBack` über `goNavReturn` (Legacy-bridge) + +### Phase 2 (Flows verbinden) + +- [x] Listen → Editoren: Übungen, Module, Vorlagen, Rahmenprogramme +- [x] Dashboard → Übung bearbeiten / Trainingsablauf / Einheit bearbeiten +- [x] Einstellungen-Unterseiten (Rechtliches, Systeminfo) +- [x] Trainingsablauf + Coach-Modus (`trainingRun`, Planungs-Fallback) +- [x] Medienbibliothek → verknüpfte Übungen/Einheiten +- [x] `ExercisePeekModal` → Vollseite mit Return +- [x] Editoren: Abbrechen + Speichern & Schließen → Einsprungspunkt + +### Optional (später) + +- Globaler Zurück-Button in App-Chrome (Mobile) +- Nach Speichern: explizite Aktion „Zurück zum Ausgang“ im Toast + +--- + +## Referenzen + +- Bestehend: `frontend/src/utils/planningUnitRoutes.js` (`planningReturn`) +- Session Übungsliste: `frontend/src/utils/exerciseListSessionState.js` +- PWA-Kontext: `docs/FACHLICHE_NUTZERFUNKTIONEN.md`, App-Shell in `App.jsx` diff --git a/frontend/src/app.css b/frontend/src/app.css index f5bcf83..05f59c5 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1459,6 +1459,36 @@ html.modal-scroll-locked .app-main { .page-form-editor__intro { min-width: 0; } +.page-return-link { + display: inline-block; + margin-bottom: 0.75rem; + color: var(--accent-dark); + font-weight: 600; + text-decoration: none; +} +.page-return-btn { + display: inline-flex; + align-items: center; + gap: 6px; + margin-bottom: 0.75rem; + max-width: 100%; +} +.page-return-btn__icon { + flex-shrink: 0; +} +.page-return-btn span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.page-return-link:hover { + text-decoration: underline; +} +@media (prefers-color-scheme: dark) { + .page-return-link { + color: var(--accent); + } +} .page-form-editor__back { display: inline-block; margin-bottom: 0.35rem; @@ -2949,15 +2979,66 @@ html.modal-scroll-locked .app-main { flex-shrink: 0; accent-color: var(--accent); } +.exercise-card--selection-pinned { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent); +} +.exercise-card__selection-badge { + display: inline-flex; + align-items: center; + margin-left: 8px; + padding: 2px 7px; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--accent-dark); + background: var(--accent-soft); + vertical-align: middle; +} +.exercises-selection-section { + margin-bottom: 1rem; +} +.exercises-selection-section__head { + margin-bottom: 0.65rem; +} +.exercises-selection-section__title { + margin: 0; + font-size: 1rem; + font-weight: 700; + color: var(--text1); +} +.exercises-selection-section__hint { + margin: 4px 0 0; + font-size: 0.85rem; + color: var(--text3); + line-height: 1.45; +} +.exercises-list-grid--selection { + margin-bottom: 0.25rem; +} .exercise-card-body-flex { flex: 1; min-width: 0; } +.exercise-card__body--clickable { + cursor: pointer; +} +.exercise-card__body--clickable:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 8px; +} .exercise-card-title { margin: 0 0 8px; font-size: 1.05rem; line-height: 1.3; font-weight: 700; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; } .exercise-card-title a { color: inherit; diff --git a/frontend/src/components/ExercisePeekModal.jsx b/frontend/src/components/ExercisePeekModal.jsx index 9fae195..0afc490 100644 --- a/frontend/src/components/ExercisePeekModal.jsx +++ b/frontend/src/components/ExercisePeekModal.jsx @@ -5,6 +5,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' import api from '../utils/api' +import NavStateLink from './NavStateLink' import ExerciseRichTextBlock from './ExerciseRichTextBlock' import CombinationPlanBracket from './CombinationPlanBracket' import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile' @@ -36,6 +37,8 @@ export default function ExercisePeekModal({ titleFallback, /** Nur Planung: effektives method_profile aus Zeilen-Katalog + Planungs-Override */ peekExtras, + /** Rücksprung-Kontext für „Vollständige Übungsseite“ */ + returnContext, }) { const [loading, setLoading] = useState(false) const [err, setErr] = useState(null) @@ -255,13 +258,24 @@ export default function ExercisePeekModal({ {top?.exerciseId != null ? (
diff --git a/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx b/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx index 084dcd3..1fd134d 100644 --- a/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx +++ b/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx @@ -5,6 +5,7 @@ export default function ExerciseListBulkToolbar({ bulkMaxIds, onClearSelection, onOpenBulkModal, + onOpenSaveModuleModal, }) { if (selectedCount < 1) return null @@ -14,6 +15,9 @@ export default function ExerciseListBulkToolbar({ + diff --git a/frontend/src/components/exercises/ExerciseListCard.jsx b/frontend/src/components/exercises/ExerciseListCard.jsx index 234c69d..a0d1aee 100644 --- a/frontend/src/components/exercises/ExerciseListCard.jsx +++ b/frontend/src/components/exercises/ExerciseListCard.jsx @@ -1,5 +1,10 @@ -import React from 'react' -import { Link } from 'react-router-dom' +import React, { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import NavStateLink from '../NavStateLink' +import { + buildExercisesListReturnContext, + navigateWithAppReturn, +} from '../../utils/navReturnContext' import { Eye, Pencil, @@ -131,23 +136,75 @@ function ExerciseCardScopeStatus({ exercise }) { /** * 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 }) { +export default function ExerciseListCard({ + exercise, + user, + selectedIds, + toggleSelect, + onDelete, + onPeek, + selectionPinned = false, +}) { + const navigate = useNavigate() + const listReturn = useMemo(() => buildExercisesListReturnContext(), []) const focusNames = exerciseFocusNames(exercise) const styleNames = coerceApiNameList(exercise.style_direction_names) const typeNames = coerceApiNameList(exercise.training_type_names) + const titleText = (exercise.title || 'Übung').replace(/"/g, '') + + const openExercisePage = () => + navigateWithAppReturn(navigate, `/exercises/${exercise.id}`, listReturn) + + const handleBodyClick = (e) => { + if (e.target.closest('a, button, input, textarea, select, label, [role="button"]')) return + openExercisePage() + } + + const handleBodyKeyDown = (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return + if (e.target.closest('a, button, input, textarea, select, label, [role="button"]')) return + e.preventDefault() + openExercisePage() + } + return ( -
-
- ← Zurück zu Einstellungen
-
+
-
- ← Zurück zu Einstellungen
-
+
buildPlanningHubFallbackReturnContext(), []) + const runReturn = useMemo(() => buildTrainingRunReturnContext(unitId), [unitId]) const coachFocusResetRef = useRef(null) @@ -667,9 +674,11 @@ export default function TrainingCoachPage() { return (
{loadError || 'Nicht gefunden.'}
-- - ← Zurück zur Modul‑Bibliothek - -
Reihenfolge der Positionen entspricht der späteren Übernahme in einen Abschnitt der Einheit (Kopie). @@ -670,7 +679,7 @@ export default function TrainingModuleEditPage() { saving={saving} onSave={() => handleSave()} onSaveAndClose={handleSaveAndClose} - onCancel={() => navigate('/planning/training-modules')} + onCancel={goBack} cancelLabel="Abbrechen" /> diff --git a/frontend/src/pages/TrainingModulesListPage.jsx b/frontend/src/pages/TrainingModulesListPage.jsx index 41e0b34..6ab62e1 100644 --- a/frontend/src/pages/TrainingModulesListPage.jsx +++ b/frontend/src/pages/TrainingModulesListPage.jsx @@ -1,12 +1,14 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' -import { Link } from 'react-router-dom' import api from '../utils/api' +import NavStateLink from '../components/NavStateLink' import { useAuth } from '../context/AuthContext' import { getTenantClubDependencyKey } from '../utils/activeClub' +import { buildTrainingModulesListReturnContext } from '../utils/navReturnContext' export default function TrainingModulesListPage() { const { user } = useAuth() const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) + const modulesListReturn = useMemo(() => buildTrainingModulesListReturnContext(), []) const [rows, setRows] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') @@ -60,9 +62,14 @@ export default function TrainingModulesListPage() { lokale Kopie (mit Herkunftsmarkierung).
{(r.summary || '').trim() || '—'}{' '}
diff --git a/frontend/src/pages/TrainingPlanTemplateEditPage.jsx b/frontend/src/pages/TrainingPlanTemplateEditPage.jsx
index 11d95d4..ad91522 100644
--- a/frontend/src/pages/TrainingPlanTemplateEditPage.jsx
+++ b/frontend/src/pages/TrainingPlanTemplateEditPage.jsx
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { Link, useNavigate, useParams } from 'react-router-dom'
+import { useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
import FormActionBar from '../components/FormActionBar'
@@ -7,6 +7,10 @@ import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
import { useAuth } from '../context/AuthContext'
import { useToast } from '../context/ToastContext'
+import { useNavReturn } from '../hooks/useNavReturn'
+import {
+ buildPlanTemplatesListReturnContext,
+} from '../utils/navReturnContext'
import {
defaultSection,
formSectionsFromPlanTemplateRows,
@@ -31,6 +35,8 @@ function templateFormSnapshot({ name, description, visibility, clubIdField, sect
export default function TrainingPlanTemplateEditPage() {
const { id: routeId } = useParams()
const navigate = useNavigate()
+ const templatesListReturn = useMemo(() => buildPlanTemplatesListReturnContext(), [])
+ const { goBack } = useNavReturn(templatesListReturn)
const templateId = parseInt(routeId, 10)
const toast = useToast()
const { user } = useAuth()
@@ -183,7 +189,7 @@ export default function TrainingPlanTemplateEditPage() {
baselineRef.current = templateFormSnapshot(latestFormRef.current)
setBypassDirty(false)
toast.success('Vorlage gespeichert.')
- if (closeAfter) navigate('/planning/plan-templates')
+ if (closeAfter) goBack()
return true
} catch (err) {
const msg = err.message || 'Speichern fehlgeschlagen'
@@ -220,12 +226,6 @@ export default function TrainingPlanTemplateEditPage() {
return (
-
- ← Zurück zu Vorlagen
-
- {loadError || 'Trainingseinheit nicht gefunden.'}
Trainingsvorlage bearbeiten
@@ -321,7 +321,7 @@ export default function TrainingPlanTemplateEditPage() {
variant="page"
formId="plan-template-form"
saving={saving}
- onCancel={() => navigate('/planning/plan-templates')}
+ onCancel={goBack}
onSaveAndClose={handleSaveAndClose}
/>
diff --git a/frontend/src/pages/TrainingPlanTemplatesListPage.jsx b/frontend/src/pages/TrainingPlanTemplatesListPage.jsx
index af2b843..e94f94c 100644
--- a/frontend/src/pages/TrainingPlanTemplatesListPage.jsx
+++ b/frontend/src/pages/TrainingPlanTemplatesListPage.jsx
@@ -1,11 +1,12 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
-import { Link } from 'react-router-dom'
import api from '../utils/api'
+import NavStateLink from '../components/NavStateLink'
import { useAuth } from '../context/AuthContext'
import { useToast } from '../context/ToastContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import { canDeleteLibraryContent, canEditLibraryContent } from '../utils/libraryContentPermissions'
import PlanTemplateStructurePreview from '../components/planning/PlanTemplateStructurePreview'
+import { buildPlanTemplatesListReturnContext } from '../utils/navReturnContext'
function visibilityLabel(v) {
const x = String(v || 'club').toLowerCase()
@@ -16,6 +17,7 @@ function visibilityLabel(v) {
export default function TrainingPlanTemplatesListPage() {
const { user } = useAuth()
+ const templatesListReturn = useMemo(() => buildPlanTemplatesListReturnContext(), [])
const toast = useToast()
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [rows, setRows] = useState([])
@@ -113,8 +115,9 @@ export default function TrainingPlanTemplatesListPage() {
>