UX Verbesserung, Navigationsspeicherung #40

Merged
Lars merged 6 commits from develop into main 2026-05-20 10:44:00 +02:00
10 changed files with 440 additions and 15 deletions
Showing only changes of commit 6e6270b717 - Show all commits

View File

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

View File

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

View File

@ -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 (
<a href={target.path} className={className} style={style} onClick={handleClick}>
{target.label}
</a>
)
}

View File

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

View File

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

View File

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

View File

@ -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 (
<div className="app-page">
<p style={{ marginBottom: '0.75rem' }}>
<Link to="/planning/training-modules" style={{ color: 'var(--accent-dark)', fontWeight: 600 }}>
Zurück zur ModulBibliothek
</Link>
</p>
<PageReturnLink
fallbackPath={TRAINING_MODULES_LIST_PATH}
fallbackLabel="Zurück zur Modul-Bibliothek"
/>
<h1 className="page-title">{isNew ? 'Neues Trainingsmodul' : 'Trainingsmodul bearbeiten'}</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', marginBottom: '1.25rem', maxWidth: '40rem' }}>
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"
/>
</form>

View File

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

View File

@ -0,0 +1,140 @@
/**
* Einheitlicher Rücksprung-Kontext für PWA-Navigation (siehe NAV_RETURN_CONTEXT_SPEC.md).
*/
import {
buildPlanningHubReturnState,
planningHubPathFromReturnState,
} from './planningUnitRoutes'
export const NAV_RETURN_STATE_KEY = 'appReturn'
export const EXERCISES_LIST_PATH = '/exercises'
export const TRAINING_MODULES_LIST_PATH = '/planning/training-modules'
export function buildNavReturnContext(opts) {
const path = String(opts?.path || '').trim()
const label = String(opts?.label || '').trim()
if (!path || !label) return null
const ctx = { v: 1, path, label }
if (opts?.kind) ctx.kind = opts.kind
if (opts?.payload && typeof opts.payload === 'object') {
ctx.payload = opts.payload
}
return ctx
}
export function buildExercisesListReturnContext() {
return buildNavReturnContext({
path: EXERCISES_LIST_PATH,
label: 'Zurück zur Übungsliste',
kind: 'exerciseList',
})
}
export function buildTrainingModulesListReturnContext() {
return buildNavReturnContext({
path: TRAINING_MODULES_LIST_PATH,
label: 'Zurück zur Modul-Bibliothek',
kind: 'trainingModulesList',
})
}
/**
* Rücksprung zur aktuellen Route (z. B. Einheiten-Editor).
*/
export function buildCurrentLocationReturnContext(location, label) {
const path = `${location?.pathname || ''}${location?.search || ''}`.trim()
if (!path) return null
return buildNavReturnContext({
path,
label: String(label || 'Zurück').trim(),
kind: 'currentLocation',
})
}
/** @param {ReturnType<typeof buildPlanningHubReturnState>|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<typeof buildNavReturnContext>|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 })
}

View File

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