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

+

{isNew ? 'Neues Trainingsmodul' : 'Trainingsmodul bearbeiten'}

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} /> |object} hubState */ +export function buildPlanningHubReturnContext(hubState = {}) { + const payload = buildPlanningHubReturnState(hubState) + return buildNavReturnContext({ + path: planningHubPathFromReturnState(payload), + label: 'Zurück zur Planung', + kind: 'planningHub', + payload, + }) +} + +/** Legacy planningReturn → appReturn */ +export function appReturnFromPlanningReturn(planningReturn) { + if (!planningReturn || planningReturn.v !== 1) return null + return buildPlanningHubReturnContext(planningReturn) +} + +/** + * @param {import('react-router-dom').Location|{ state?: object }|null|undefined} location + */ +export function readNavReturnFromLocation(location) { + const state = location?.state + if (!state || typeof state !== 'object') return null + const raw = state[NAV_RETURN_STATE_KEY] + if (raw?.v === 1 && raw.path && raw.label) return raw + if (state.planningReturn) return appReturnFromPlanningReturn(state.planningReturn) + return null +} + +/** + * @param {import('react-router-dom').Location|{ state?: object }|null|undefined} location + * @param {{ path: string, label: string }|null|undefined} fallback + */ +export function resolveNavReturnTarget(location, fallback) { + const ctx = readNavReturnFromLocation(location) + if (ctx?.path && ctx?.label) { + return { path: ctx.path, label: ctx.label, fromContext: true } + } + if (fallback?.path && fallback?.label) { + return { path: fallback.path, label: fallback.label, fromContext: false } + } + return null +} + +/** + * @param {import('react-router-dom').NavigateFunction} navigate + * @param {import('react-router-dom').Location|{ state?: object }|null|undefined} location + * @param {{ path?: string, label?: string }|null|undefined} fallback + */ +export function goNavReturn(navigate, location, fallback) { + const target = resolveNavReturnTarget(location, fallback) + if (target?.fromContext && target.path) { + navigate(target.path) + return + } + if (typeof window !== 'undefined' && window.history.length > 1) { + navigate(-1) + return + } + if (fallback?.path) { + navigate(fallback.path) + return + } + navigate('/') +} + +/** + * @param {import('react-router-dom').NavigateFunction} navigate + * @param {string} to + * @param {ReturnType|null|undefined} returnContext + * @param {object} [options] + */ +export function navigateWithAppReturn(navigate, to, returnContext, options = {}) { + const state = { ...(options.state || {}) } + if (returnContext) state[NAV_RETURN_STATE_KEY] = returnContext + navigate(to, { ...options, state }) +} + +/** + * Bestehenden appReturn (oder Legacy planningReturn) beim Weiterleiten erhalten. + */ +export function preserveAppReturnOnNavigate(navigate, location, to, options = {}) { + const existing = readNavReturnFromLocation(location) + const state = { ...(options.state || {}) } + if (existing) state[NAV_RETURN_STATE_KEY] = existing + navigate(to, { ...options, state }) +} diff --git a/frontend/src/utils/navReturnContext.test.js b/frontend/src/utils/navReturnContext.test.js new file mode 100644 index 0000000..a9dc92f --- /dev/null +++ b/frontend/src/utils/navReturnContext.test.js @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest' +import { + appReturnFromPlanningReturn, + buildExercisesListReturnContext, + buildNavReturnContext, + buildPlanningHubReturnContext, + goNavReturn, + readNavReturnFromLocation, + resolveNavReturnTarget, +} from './navReturnContext.js' + +describe('navReturnContext', () => { + it('buildNavReturnContext requires path and label', () => { + expect(buildNavReturnContext({ path: '/x', label: 'Zurück' })).toEqual({ + v: 1, + path: '/x', + label: 'Zurück', + }) + expect(buildNavReturnContext({ path: '', label: 'X' })).toBeNull() + }) + + it('buildExercisesListReturnContext', () => { + const ctx = buildExercisesListReturnContext() + expect(ctx.path).toBe('/exercises') + expect(ctx.kind).toBe('exerciseList') + }) + + it('readNavReturnFromLocation prefers appReturn', () => { + const ctx = buildExercisesListReturnContext() + expect(readNavReturnFromLocation({ state: { appReturn: ctx } })).toEqual(ctx) + }) + + it('readNavReturnFromLocation bridges planningReturn', () => { + const loc = { + state: { + planningReturn: { + v: 1, + selectedGroupId: '3', + planView: 'list', + calendarMonthStr: '', + startDate: '', + endDate: '', + planScope: 'group', + assignedToMeOnly: false, + }, + }, + } + const ctx = readNavReturnFromLocation(loc) + expect(ctx?.kind).toBe('planningHub') + expect(ctx?.path).toContain('/planning') + expect(ctx?.label).toBe('Zurück zur Planung') + }) + + it('appReturnFromPlanningReturn', () => { + const ctx = appReturnFromPlanningReturn({ + v: 1, + selectedGroupId: '', + planView: 'calendar', + calendarMonthStr: '2026-05', + startDate: '', + endDate: '', + planScope: 'group', + assignedToMeOnly: false, + }) + expect(ctx?.path).toContain('month=2026-05') + }) + + it('resolveNavReturnTarget uses fallback when no state', () => { + const fb = { path: '/planning/training-modules', label: 'Zurück zur Modul-Bibliothek' } + expect(resolveNavReturnTarget({ state: null }, fb)).toEqual({ ...fb, fromContext: false }) + }) + + it('goNavReturn navigates to context path first', () => { + const navigate = vi.fn() + const ctx = buildExercisesListReturnContext() + goNavReturn(navigate, { state: { appReturn: ctx } }, null) + expect(navigate).toHaveBeenCalledWith('/exercises') + }) + + it('buildPlanningHubReturnContext', () => { + const ctx = buildPlanningHubReturnContext({ selectedGroupId: '7', planView: 'list' }) + expect(ctx?.path).toContain('group=7') + expect(ctx?.kind).toBe('planningHub') + }) +})