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..b196944 --- /dev/null +++ b/.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md @@ -0,0 +1,115 @@ +# Navigation — Return-Kontext (Rücksprung) + +**Stand:** 2026-05-20 +**Status:** Spezifikation + schrittweise Umsetzung (Pilot) +**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` | +| (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: **`PageReturnLink`** — einheitlicher Zurück-Link oben auf Editor-/Detailseiten. + +--- + +## 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 `PageReturnLink` 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). + +--- + +## Pilot-Umsetzung (Phase 1) + +- [x] Spec + Utility + Tests +- [x] `PageReturnLink` +- [x] Übungsliste → Modul speichern → Modul-Editor (dynamischer Zurück-Link) +- [x] Planung: `SaveExercisesAsModuleModal` leitet Return-Kontext weiter +- [x] `TrainingUnitEditPage`: `goBack` über `goNavReturn` (Legacy-bridge) + +## Folge-Phasen (noch offen) + +- Weitere Editoren (Übung, Vorlage, Rahmenprogramm) +- Optional: globaler Zurück-Button in App-Chrome (Mobile) +- Nach Speichern: explizite Aktion „Zurück zum Ausgang“ im Toast +- `ExercisePeekModal` → Vollseite mit Return + +--- + +## 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 65d308e..48bb744 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1459,6 +1459,21 @@ 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-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; diff --git a/frontend/src/components/PageReturnLink.jsx b/frontend/src/components/PageReturnLink.jsx new file mode 100644 index 0000000..87d4975 --- /dev/null +++ b/frontend/src/components/PageReturnLink.jsx @@ -0,0 +1,36 @@ +import React from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { goNavReturn, resolveNavReturnTarget } from '../utils/navReturnContext' + +/** + * Einheitlicher Zurück-Link für Editor-/Detailseiten (PWA-sicher). + */ +export default function PageReturnLink({ + fallbackPath, + fallbackLabel, + className = 'page-return-link', + style, +}) { + const location = useLocation() + const navigate = useNavigate() + const target = resolveNavReturnTarget(location, { + path: fallbackPath, + label: fallbackLabel, + }) + + if (!target?.path || !target?.label) return null + + const handleClick = (e) => { + e.preventDefault() + goNavReturn(navigate, location, { + path: fallbackPath, + label: fallbackLabel, + }) + } + + return ( + + ← {target.label} + + ) +} diff --git a/frontend/src/components/exercises/ExercisesListPageRoot.jsx b/frontend/src/components/exercises/ExercisesListPageRoot.jsx index 5bdfc84..4b4e2c6 100644 --- a/frontend/src/components/exercises/ExercisesListPageRoot.jsx +++ b/frontend/src/components/exercises/ExercisesListPageRoot.jsx @@ -11,6 +11,7 @@ import ExerciseListSearchBar from './ExerciseListSearchBar' import ExerciseListBulkToolbar from './ExerciseListBulkToolbar' import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal' import ExercisePeekModal from '../ExercisePeekModal' +import { buildExercisesListReturnContext } from '../../utils/navReturnContext' import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips' import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery' import { readExerciseListSessionState, writeExerciseListSessionState } from '../../utils/exerciseListSessionState' @@ -296,6 +297,8 @@ function ExercisesListPageRoot() { const selectedExercisesInListOrder = selectedExercisesDisplay + const exercisesModuleReturnContext = useMemo(() => buildExercisesListReturnContext(), []) + const bulkVisibilityOptions = useMemo(() => { const base = [ { id: '', label: '— nicht ändern —' }, @@ -595,6 +598,7 @@ function ExercisesListPageRoot() { open={saveModuleModalOpen} onClose={() => setSaveModuleModalOpen(false)} selectedExercises={selectedExercisesInListOrder} + returnContext={exercisesModuleReturnContext} onSuccess={clearSelection} /> diff --git a/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx b/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx index 15dc269..0579f89 100644 --- a/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx +++ b/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx @@ -8,6 +8,7 @@ import { useAuth } from '../../context/AuthContext' import { activeClubMemberships, getDefaultClubIdForGovernanceForms } from '../../utils/activeClub' import { hydrateExercisePlanningRow } from '../../utils/trainingUnitSectionsForm' import { buildRowsPayload, moduleItemToPayload } from '../../utils/exerciseListSelection' +import { navigateWithAppReturn } from '../../utils/navReturnContext' /** * Erstellt ein Trainingsmodul aus per Checkbox ausgewählten Übungen (Reihenfolge = Auswahlreihenfolge). @@ -18,6 +19,8 @@ export default function SaveSelectedExercisesAsModuleModal({ onClose, /** @type {Array<{ id: number, title?: string }>} */ selectedExercises, + /** Return-Kontext für Modul-Editor (z. B. Übungsliste) */ + returnContext, onSuccess, }) { const navigate = useNavigate() @@ -153,7 +156,7 @@ export default function SaveSelectedExercisesAsModuleModal({ ] await api.updateTrainingModule(mid, { items: merged }) toast.success(`${newItemsPayload.length} Übung(en) an Modul angefügt.`) - navigate(`/planning/training-modules/${mid}`) + navigateWithAppReturn(navigate, `/planning/training-modules/${mid}`, returnContext) onSuccess?.() onClose() return @@ -184,7 +187,7 @@ export default function SaveSelectedExercisesAsModuleModal({ }) toast.success('Trainingsmodul gespeichert.') if (created?.id) { - navigate(`/planning/training-modules/${created.id}`) + navigateWithAppReturn(navigate, `/planning/training-modules/${created.id}`, returnContext) } onSuccess?.() onClose() diff --git a/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx b/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx index ee2c119..3c49298 100644 --- a/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx +++ b/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx @@ -7,6 +7,7 @@ import { useToast } from '../../context/ToastContext' import { useAuth } from '../../context/AuthContext' import { activeClubMemberships } from '../../utils/activeClub' import { collectExercisePlacementsForModule } from '../../utils/trainingPlanModuleFromUnit' +import { navigateWithAppReturn } from '../../utils/navReturnContext' /** * Erstellt ein Trainingsmodul aus den Übungen einer gespeicherten Trainingseinheit (Mehrfachauswahl). @@ -16,6 +17,7 @@ export default function SaveExercisesAsModuleModal({ onClose, unitId, planningModalClubId, + returnContext, onSuccess, }) { const navigate = useNavigate() @@ -134,7 +136,7 @@ export default function SaveExercisesAsModuleModal({ }) toast.success('Trainingsmodul gespeichert.') if (created?.id) { - navigate(`/planning/training-modules/${created.id}`) + navigateWithAppReturn(navigate, `/planning/training-modules/${created.id}`, returnContext) } onSuccess?.() onClose() diff --git a/frontend/src/pages/TrainingModuleEditPage.jsx b/frontend/src/pages/TrainingModuleEditPage.jsx index 064897d..ea416d6 100644 --- a/frontend/src/pages/TrainingModuleEditPage.jsx +++ b/frontend/src/pages/TrainingModuleEditPage.jsx @@ -1,14 +1,21 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Link, useNavigate, useParams } from 'react-router-dom' +import { useLocation, useNavigate, useParams } from 'react-router-dom' import api from '../utils/api' import ExercisePickerModal from '../components/ExercisePickerModal' import FormActionBar from '../components/FormActionBar' +import PageReturnLink from '../components/PageReturnLink' import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm' import { useAuth } from '../context/AuthContext' import { useToast } from '../context/ToastContext' import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt' import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker' import { activeClubMemberships, getDefaultClubIdForGovernanceForms, getTenantClubDependencyKey } from '../utils/activeClub' +import { + TRAINING_MODULES_LIST_PATH, + buildTrainingModulesListReturnContext, + goNavReturn, + preserveAppReturnOnNavigate, +} from '../utils/navReturnContext' function moduleFormSnapshot({ title, @@ -62,6 +69,7 @@ function swapItems(arr, i, j) { export default function TrainingModuleEditPage() { const { id: routeId } = useParams() const navigate = useNavigate() + const location = useLocation() const isNew = !routeId || routeId === 'new' const moduleId = !isNew ? parseInt(routeId, 10) : NaN @@ -302,6 +310,12 @@ export default function TrainingModuleEditPage() { } } + const moduleListReturn = useMemo(() => buildTrainingModulesListReturnContext(), []) + + const goBack = useCallback(() => { + goNavReturn(navigate, location, moduleListReturn) + }, [navigate, location, moduleListReturn]) + const performModuleSave = async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => { if (!title.trim()) { toast.error('Titel ist Pflicht.') @@ -315,9 +329,11 @@ export default function TrainingModuleEditPage() { const created = await api.createTrainingModule(body) toast.success('Trainingsmodul angelegt.') if (closeAfter) { - navigate('/planning/training-modules') + goBack() } else if (!fromUnsavedDialog) { - navigate(`/planning/training-modules/${created.id}`, { replace: true }) + preserveAppReturnOnNavigate(navigate, location, `/planning/training-modules/${created.id}`, { + replace: true, + }) } return true } @@ -325,7 +341,7 @@ export default function TrainingModuleEditPage() { baselineRef.current = moduleFormSnapshot(latestFormRef.current) setBypassDirty(false) toast.success('Gespeichert.') - if (closeAfter) navigate('/planning/training-modules') + if (closeAfter) goBack() return true } catch (err) { const msg = err.message || 'Speichern fehlgeschlagen' @@ -361,11 +377,10 @@ export default function TrainingModuleEditPage() { return (
- - ← Zurück zur Modul‑Bibliothek - -
+
Reihenfolge der Positionen entspricht der späteren Übernahme in einen Abschnitt der Einheit (Kopie).
@@ -670,7 +685,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/TrainingUnitEditPage.jsx b/frontend/src/pages/TrainingUnitEditPage.jsx
index 45b0745..ae5841e 100644
--- a/frontend/src/pages/TrainingUnitEditPage.jsx
+++ b/frontend/src/pages/TrainingUnitEditPage.jsx
@@ -33,6 +33,7 @@ import PageFormEditorChrome from '../components/PageFormEditorChrome'
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
import { buildPlanUnitEditPath, planningHubPathFromReturnState } from '../utils/planningUnitRoutes'
+import { goNavReturn, buildCurrentLocationReturnContext } from '../utils/navReturnContext'
export default function TrainingUnitEditPage() {
const { id: routeId } = useParams()
@@ -119,8 +120,16 @@ export default function TrainingUnitEditPage() {
const [saveModuleOpen, setSaveModuleOpen] = useState(false)
const goBack = useCallback(() => {
- navigate(planningHubPathFromReturnState(location.state?.planningReturn))
- }, [location.state, navigate])
+ goNavReturn(navigate, location, {
+ path: planningHubPathFromReturnState(location.state?.planningReturn),
+ label: 'Zurück zur Planung',
+ })
+ }, [location, navigate])
+
+ const moduleSaveReturnContext = useMemo(
+ () => buildCurrentLocationReturnContext(location, 'Zurück zur Trainingseinheit'),
+ [location]
+ )
const planningClubId = useMemo(() => {
const gid = Number(formData.group_id)
@@ -731,6 +740,7 @@ export default function TrainingUnitEditPage() {
onSuccess={() => setSaveModuleOpen(false)}
unitId={editingUnit?.id}
planningModalClubId={planningClubId}
+ returnContext={moduleSaveReturnContext}
/>