- Updated CLAUDE.md to reflect the addition of exercise_progression_graphs in the backend routers. - Revised PROJECT_STATUS.md to document the current project status and recent milestones, including the implementation of the exercise progression graph feature. - Incremented versioning in DOMAIN_MODEL.md and DATABASE_SCHEMA.md to align with the latest migration updates. - Enhanced technical specifications in TRAINING_FRAMEWORK_SPEC.md to clarify the implementation details of the exercise progression graph and its integration with the training framework.
680 lines
16 KiB
Markdown
680 lines
16 KiB
Markdown
# Frontend Routing & Navigation Specification
|
|
|
|
**Version:** 1.2
|
|
**Datum:** 2026-04-30
|
|
**Status:** DRAFT - Awaiting Review
|
|
**Autor:** Claude Code
|
|
**Änderungen v1.2:** Übersicht **Übungen**: Tabs Liste \| Progressionsgraphen auf `/exercises`; Progressions-Editor ohne neue Routen (Panel + Formularblock unter `/exercises/:id/edit`)
|
|
**Änderungen v1.1:** Übungsvarianten-Bearbeitung nur unter `/exercises/:id/edit` (keine VariantFormPage-Routen)
|
|
|
|
---
|
|
|
|
## 1. Route-Struktur
|
|
|
|
### 1.1 Route-Übersicht
|
|
|
|
```
|
|
/exercises → ExercisesListPage — Tabs: **Liste** \| **Progressionsgraphen** (`ExerciseProgressionGraphPanel`)
|
|
/exercises/new → ExerciseFormPage (Create)
|
|
/exercises/{id} → ExerciseDetailPage (Accordion-Layout)
|
|
/exercises/{id}/edit → ExerciseFormPage (Edit inkl. Varianten-Editor inline + Block Progressionsgraph)
|
|
|
|
/exercise-blocks → ExerciseBlocksListPage (Meine Blocks)
|
|
/exercise-blocks/new → ExerciseBlockFormPage (Create)
|
|
/exercise-blocks/{id} → ExerciseBlockDetailPage (mit BlockEditor)
|
|
/exercise-blocks/{id}/edit → ExerciseBlockFormPage (Edit)
|
|
|
|
/admin/exercises/catalog → AdminCatalogPage (Fokusbereiche, Stile, Zielgruppen, Altersgruppen)
|
|
/admin/exercises/skills → AdminSkillsPage (Fähigkeiten-Matrix)
|
|
/admin/exercises/methods → AdminMethodsPage (Methoden-Katalog)
|
|
```
|
|
|
|
**URL-Parameter-Konventionen:**
|
|
- IDs immer numerisch: `/exercises/42` (nicht `/exercises/uuid`)
|
|
- Query-Parameter für Filter: `/exercises?focus_area=1&visibility=club`
|
|
- Pagination: `/exercises?limit=50&offset=100`
|
|
- Sortierung: `/exercises?sort=created_at&order=desc`
|
|
|
|
---
|
|
|
|
## 2. Navigation-Patterns
|
|
|
|
### 2.1 Haupt-Navigation (Desktop + Tablet)
|
|
|
|
**Primäre Nav (Top-Level):**
|
|
```
|
|
┌─────────────────────────────────────────┐
|
|
│ Shinkan │ Übungen │ Planung │ Admin │
|
|
└─────────────────────────────────────────┘
|
|
```
|
|
|
|
**Übungen-Submenu (Dropdown):**
|
|
```
|
|
Übungen
|
|
├─ Alle Übungen → /exercises
|
|
├─ Neue Übung → /exercises/new
|
|
├─ Meine Blocks → /exercise-blocks
|
|
├─ Neuer Block → /exercise-blocks/new
|
|
└─ Katalog verwalten → /admin/exercises/catalog (nur Admin)
|
|
```
|
|
|
|
### 2.2 Mobile Navigation (Hamburger-Menu)
|
|
|
|
```
|
|
☰ Menu
|
|
├─ Übungen
|
|
│ ├─ Alle anzeigen
|
|
│ ├─ Neue Übung
|
|
│ └─ Filter & Suche
|
|
├─ Planung
|
|
└─ Admin
|
|
└─ Katalog
|
|
```
|
|
|
|
### 2.3 Breadcrumbs (Detail/Edit-Seiten)
|
|
|
|
**Pattern:**
|
|
```
|
|
Home → Übungen → [Übungsname] → Bearbeiten (Varianten im gleichen Formular)
|
|
```
|
|
|
|
**Implementation:**
|
|
- Breadcrumbs dynamisch aus Route-Parametern generiert
|
|
- Letztes Element (aktuelle Seite) nicht klickbar
|
|
- Max. 4 Ebenen, dann `...` für gekürzte Pfade
|
|
|
|
---
|
|
|
|
## 3. State Management
|
|
|
|
### 3.1 URL-State (Query-Parameter)
|
|
|
|
**Filter-State in URL persistieren:**
|
|
```javascript
|
|
// ExercisesListPage.jsx
|
|
const [filters, setFilters] = useState({
|
|
focus_area: parseInt(searchParams.get('focus_area')) || null,
|
|
visibility: searchParams.get('visibility') || null,
|
|
status: searchParams.get('status') || null,
|
|
search: searchParams.get('search') || '',
|
|
limit: parseInt(searchParams.get('limit')) || 50,
|
|
offset: parseInt(searchParams.get('offset')) || 0,
|
|
})
|
|
|
|
// Bei Filter-Änderung → URL aktualisieren
|
|
const updateFilters = (newFilters) => {
|
|
const params = new URLSearchParams()
|
|
Object.entries(newFilters).forEach(([key, val]) => {
|
|
if (val) params.set(key, val)
|
|
})
|
|
navigate(`/exercises?${params.toString()}`)
|
|
}
|
|
```
|
|
|
|
**Vorteile:**
|
|
- Sharable URLs (Filter-Zustand übertragbar)
|
|
- Browser-Back funktioniert korrekt
|
|
- Keine verlorenen Filter beim Reload
|
|
|
|
### 3.2 Local State (React useState)
|
|
|
|
**Form-State (nicht in URL):**
|
|
```javascript
|
|
// ExerciseFormPage.jsx
|
|
const [formData, setFormData] = useState({
|
|
title: '',
|
|
summary: '',
|
|
goal: '',
|
|
execution: '',
|
|
// ... weitere Felder
|
|
})
|
|
```
|
|
|
|
**UI-State (nicht in URL):**
|
|
```javascript
|
|
// ExerciseDetailPage.jsx
|
|
const [expandedSections, setExpandedSections] = useState({
|
|
basics: true,
|
|
execution: false,
|
|
variants: false,
|
|
media: false,
|
|
})
|
|
```
|
|
|
|
### 3.3 Context (Global State)
|
|
|
|
**Katalog-Daten (einmalig laden, global verfügbar):**
|
|
```javascript
|
|
// CatalogContext.jsx
|
|
const CatalogContext = createContext()
|
|
|
|
export function CatalogProvider({ children }) {
|
|
const [focusAreas, setFocusAreas] = useState([])
|
|
const [trainingStyles, setTrainingStyles] = useState([])
|
|
const [targetGroups, setTargetGroups] = useState([])
|
|
const [ageGroups, setAgeGroups] = useState([])
|
|
const [skills, setSkills] = useState([])
|
|
|
|
useEffect(() => {
|
|
// Kataloge einmalig beim App-Start laden
|
|
Promise.all([
|
|
api.getFocusAreas(),
|
|
api.getTrainingStyles(),
|
|
api.getTargetGroups(),
|
|
api.getAgeGroups(),
|
|
api.getSkills(),
|
|
]).then(([fa, ts, tg, ag, sk]) => {
|
|
setFocusAreas(fa)
|
|
setTrainingStyles(ts)
|
|
setTargetGroups(tg)
|
|
setAgeGroups(ag)
|
|
setSkills(sk)
|
|
})
|
|
}, [])
|
|
|
|
return (
|
|
<CatalogContext.Provider value={{
|
|
focusAreas, trainingStyles, targetGroups, ageGroups, skills
|
|
}}>
|
|
{children}
|
|
</CatalogContext.Provider>
|
|
)
|
|
}
|
|
|
|
export const useCatalog = () => useContext(CatalogContext)
|
|
```
|
|
|
|
**Usage in Komponenten:**
|
|
```javascript
|
|
// ExerciseFormPage.jsx
|
|
const { focusAreas, trainingStyles } = useCatalog()
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Deep Linking
|
|
|
|
### 4.1 Detail-Seite mit Accordion-State
|
|
|
|
**Problem:** Link teilen → Empfänger soll direkt zur "Varianten"-Sektion springen
|
|
|
|
**Lösung:** Hash-Fragment in URL:
|
|
```
|
|
/exercises/42#variants
|
|
/exercises/42#media
|
|
```
|
|
|
|
**Implementation:**
|
|
```javascript
|
|
// ExerciseDetailPage.jsx
|
|
useEffect(() => {
|
|
const hash = window.location.hash.replace('#', '')
|
|
if (hash && expandedSections[hash] !== undefined) {
|
|
setExpandedSections(prev => ({ ...prev, [hash]: true }))
|
|
|
|
// Scroll zur Sektion
|
|
setTimeout(() => {
|
|
document.getElementById(hash)?.scrollIntoView({ behavior: 'smooth' })
|
|
}, 100)
|
|
}
|
|
}, [])
|
|
```
|
|
|
|
### 4.2 Externe Verknüpfung aus Trainingsplan
|
|
|
|
**Szenario:** Trainingsplan-Editor soll Übung verlinken
|
|
|
|
**URL-Format:**
|
|
```
|
|
/exercises/42?from=training_plan&plan_id=7
|
|
```
|
|
|
|
**Navigation-Logik:**
|
|
```javascript
|
|
// ExerciseDetailPage.jsx
|
|
const searchParams = useSearchParams()
|
|
const returnUrl = searchParams.get('from') === 'training_plan'
|
|
? `/training-plans/${searchParams.get('plan_id')}`
|
|
: '/exercises'
|
|
|
|
// "Zurück"-Button zeigt entsprechendes Ziel
|
|
<button onClick={() => navigate(returnUrl)}>
|
|
← {returnUrl.includes('training') ? 'Zum Trainingsplan' : 'Zur Übersicht'}
|
|
</button>
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Navigation Guards
|
|
|
|
### 5.1 Unsaved Changes Warning
|
|
|
|
**Pattern:**
|
|
```javascript
|
|
// ExerciseFormPage.jsx
|
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
|
|
|
// Bei Form-Änderung
|
|
const handleChange = (field, value) => {
|
|
setFormData(prev => ({ ...prev, [field]: value }))
|
|
setHasUnsavedChanges(true)
|
|
}
|
|
|
|
// Browser-Warning bei Reload/Close
|
|
useEffect(() => {
|
|
const handleBeforeUnload = (e) => {
|
|
if (hasUnsavedChanges) {
|
|
e.preventDefault()
|
|
e.returnValue = ''
|
|
}
|
|
}
|
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
}, [hasUnsavedChanges])
|
|
|
|
// React-Router Warning bei Navigation
|
|
const blocker = useBlocker(
|
|
({ currentLocation, nextLocation }) =>
|
|
hasUnsavedChanges &&
|
|
currentLocation.pathname !== nextLocation.pathname
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (blocker.state === 'blocked') {
|
|
const confirmed = window.confirm(
|
|
'Du hast ungespeicherte Änderungen. Seite wirklich verlassen?'
|
|
)
|
|
if (confirmed) {
|
|
blocker.proceed()
|
|
} else {
|
|
blocker.reset()
|
|
}
|
|
}
|
|
}, [blocker])
|
|
```
|
|
|
|
### 5.2 Permission-Check (Admin-Routes)
|
|
|
|
**Pattern:**
|
|
```javascript
|
|
// ProtectedRoute.jsx
|
|
function ProtectedRoute({ children, requiredRole = 'admin' }) {
|
|
const { profile } = useAuth()
|
|
|
|
if (!profile) {
|
|
return <Navigate to="/login" />
|
|
}
|
|
|
|
if (profile.role !== requiredRole) {
|
|
return <Navigate to="/exercises" replace />
|
|
}
|
|
|
|
return children
|
|
}
|
|
|
|
// App.jsx
|
|
<Route path="/admin/exercises/*" element={
|
|
<ProtectedRoute requiredRole="admin">
|
|
<AdminExerciseRoutes />
|
|
</ProtectedRoute>
|
|
} />
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Performance-Optimierung
|
|
|
|
### 6.1 Lazy Loading von Routen
|
|
|
|
**Pattern:**
|
|
```javascript
|
|
// App.jsx
|
|
const ExercisesListPage = lazy(() => import('./pages/ExercisesListPage'))
|
|
const ExerciseDetailPage = lazy(() => import('./pages/ExerciseDetailPage'))
|
|
const ExerciseFormPage = lazy(() => import('./pages/ExerciseFormPage'))
|
|
|
|
function App() {
|
|
return (
|
|
<Suspense fallback={<LoadingSpinner />}>
|
|
<Routes>
|
|
<Route path="/exercises" element={<ExercisesListPage />} />
|
|
<Route path="/exercises/:id" element={<ExerciseDetailPage />} />
|
|
<Route path="/exercises/:id/edit" element={<ExerciseFormPage />} />
|
|
</Routes>
|
|
</Suspense>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Vorteil:** Initial Bundle kleiner, Routen nur bei Bedarf nachgeladen
|
|
|
|
### 6.2 Prefetching von Detail-Daten
|
|
|
|
**Pattern:**
|
|
```javascript
|
|
// ExercisesListPage.jsx - Hover-Prefetch
|
|
const handleCardHover = (exerciseId) => {
|
|
// Prefetch Detail-Daten beim Hover
|
|
queryClient.prefetchQuery(['exercise', exerciseId], () =>
|
|
api.getExercise(exerciseId)
|
|
)
|
|
}
|
|
|
|
<ExerciseCard
|
|
exercise={exercise}
|
|
onMouseEnter={() => handleCardHover(exercise.id)}
|
|
onClick={() => navigate(`/exercises/${exercise.id}`)}
|
|
/>
|
|
```
|
|
|
|
**Vorteil:** Detail-Seite lädt sofort, da Daten bereits im Cache
|
|
|
|
---
|
|
|
|
## 7. Error-Handling
|
|
|
|
### 7.1 404 - Exercise Not Found
|
|
|
|
**Pattern:**
|
|
```javascript
|
|
// ExerciseDetailPage.jsx
|
|
const { data: exercise, error, isLoading } = useQuery(
|
|
['exercise', exerciseId],
|
|
() => api.getExercise(exerciseId)
|
|
)
|
|
|
|
if (error?.response?.status === 404) {
|
|
return (
|
|
<NotFoundPage
|
|
title="Übung nicht gefunden"
|
|
message="Diese Übung existiert nicht oder wurde gelöscht."
|
|
backLink="/exercises"
|
|
backLabel="Zur Übersicht"
|
|
/>
|
|
)
|
|
}
|
|
```
|
|
|
|
### 7.2 403 - No Permission
|
|
|
|
**Pattern:**
|
|
```javascript
|
|
if (error?.response?.status === 403) {
|
|
return (
|
|
<ForbiddenPage
|
|
title="Keine Berechtigung"
|
|
message="Du hast keinen Zugriff auf diese Übung."
|
|
backLink="/exercises"
|
|
/>
|
|
)
|
|
}
|
|
```
|
|
|
|
### 7.3 Network Error
|
|
|
|
**Pattern:**
|
|
```javascript
|
|
if (error?.message === 'Network Error') {
|
|
return (
|
|
<ErrorPage
|
|
title="Verbindungsfehler"
|
|
message="Server nicht erreichbar. Bitte prüfe deine Internetverbindung."
|
|
retryAction={() => refetch()}
|
|
/>
|
|
)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Mobile-Spezifische Navigation
|
|
|
|
### 8.1 Bottom Navigation (Mobile)
|
|
|
|
**Pattern:**
|
|
```
|
|
┌─────────────────────────────────────┐
|
|
│ │
|
|
│ (Content Area) │
|
|
│ │
|
|
├─────────────────────────────────────┤
|
|
│ 🏠 Home │ 📋 Übungen │ 📅 Plan │
|
|
└─────────────────────────────────────┘
|
|
```
|
|
|
|
**Implementation:**
|
|
```javascript
|
|
// MobileBottomNav.jsx
|
|
<nav className="mobile-bottom-nav">
|
|
<NavLink to="/" className={({ isActive }) => isActive ? 'active' : ''}>
|
|
<Icon name="home" />
|
|
<span>Home</span>
|
|
</NavLink>
|
|
<NavLink to="/exercises">
|
|
<Icon name="list" />
|
|
<span>Übungen</span>
|
|
</NavLink>
|
|
<NavLink to="/training-plans">
|
|
<Icon name="calendar" />
|
|
<span>Pläne</span>
|
|
</NavLink>
|
|
</nav>
|
|
```
|
|
|
|
**CSS:**
|
|
```css
|
|
.mobile-bottom-nav {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 60px;
|
|
background: var(--surface);
|
|
border-top: 1px solid var(--border);
|
|
display: flex;
|
|
justify-content: space-around;
|
|
z-index: 100;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.mobile-bottom-nav {
|
|
display: none; /* Desktop nutzt Top-Nav */
|
|
}
|
|
}
|
|
```
|
|
|
|
### 8.2 Swipe-Navigation (Detail-Seite)
|
|
|
|
**Pattern:** Swipe left/right zwischen Übungen (gleicher Filter)
|
|
|
|
**Implementation:**
|
|
```javascript
|
|
// ExerciseDetailPage.jsx
|
|
const { exercises } = useExercisesList() // aus Context/Query
|
|
const currentIndex = exercises.findIndex(e => e.id === parseInt(exerciseId))
|
|
|
|
const swipeHandlers = useSwipeable({
|
|
onSwipedLeft: () => {
|
|
const nextExercise = exercises[currentIndex + 1]
|
|
if (nextExercise) navigate(`/exercises/${nextExercise.id}`)
|
|
},
|
|
onSwipedRight: () => {
|
|
const prevExercise = exercises[currentIndex - 1]
|
|
if (prevExercise) navigate(`/exercises/${prevExercise.id}`)
|
|
},
|
|
preventDefaultTouchmoveEvent: true,
|
|
trackMouse: false,
|
|
})
|
|
|
|
<div {...swipeHandlers}>
|
|
{/* Exercise Detail Content */}
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Accessibility
|
|
|
|
### 9.1 Skip Links
|
|
|
|
**Pattern:**
|
|
```javascript
|
|
// App.jsx
|
|
<a href="#main-content" className="skip-link">
|
|
Zum Hauptinhalt springen
|
|
</a>
|
|
|
|
<main id="main-content">
|
|
{/* Content */}
|
|
</main>
|
|
```
|
|
|
|
**CSS:**
|
|
```css
|
|
.skip-link {
|
|
position: absolute;
|
|
top: -40px;
|
|
left: 0;
|
|
background: var(--accent);
|
|
color: white;
|
|
padding: 8px;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.skip-link:focus {
|
|
top: 0;
|
|
}
|
|
```
|
|
|
|
### 9.2 Focus Management
|
|
|
|
**Pattern:** Bei Navigation → Focus auf Hauptüberschrift setzen
|
|
|
|
```javascript
|
|
// ExerciseDetailPage.jsx
|
|
const titleRef = useRef(null)
|
|
|
|
useEffect(() => {
|
|
titleRef.current?.focus()
|
|
}, [exerciseId])
|
|
|
|
<h1 ref={titleRef} tabIndex={-1}>
|
|
{exercise.title}
|
|
</h1>
|
|
```
|
|
|
|
### 9.3 ARIA Live-Region für Filter
|
|
|
|
**Pattern:**
|
|
```javascript
|
|
// ExercisesListPage.jsx
|
|
<div aria-live="polite" aria-atomic="true" className="sr-only">
|
|
{exercises.length} Übungen gefunden
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
## 10. Testing-Strategie
|
|
|
|
### 10.1 Route-Tests (Playwright)
|
|
|
|
```javascript
|
|
// exercises.routing.spec.js
|
|
test('Navigation zu Detail-Seite funktioniert', async ({ page }) => {
|
|
await page.goto('/exercises')
|
|
|
|
const firstCard = page.locator('.exercise-card').first()
|
|
await firstCard.click()
|
|
|
|
await expect(page).toHaveURL(/\/exercises\/\d+/)
|
|
await expect(page.locator('h1')).toBeVisible()
|
|
})
|
|
|
|
test('Filter-State bleibt in URL erhalten', async ({ page }) => {
|
|
await page.goto('/exercises?focus_area=1')
|
|
|
|
await page.reload()
|
|
|
|
const url = new URL(page.url())
|
|
expect(url.searchParams.get('focus_area')).toBe('1')
|
|
})
|
|
```
|
|
|
|
### 10.2 Navigation-Guard-Tests
|
|
|
|
```javascript
|
|
test('Unsaved Changes Warning', async ({ page }) => {
|
|
await page.goto('/exercises/new')
|
|
|
|
await page.fill('[name="title"]', 'Test Übung')
|
|
|
|
page.on('dialog', dialog => {
|
|
expect(dialog.message()).toContain('ungespeicherte')
|
|
dialog.accept()
|
|
})
|
|
|
|
await page.goto('/exercises')
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## 11. Route-Konfiguration (React Router)
|
|
|
|
### 11.1 Vollständige Route-Definitionen
|
|
|
|
```javascript
|
|
// App.jsx
|
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
|
|
|
function App() {
|
|
return (
|
|
<BrowserRouter>
|
|
<CatalogProvider>
|
|
<Routes>
|
|
{/* Exercises Routes */}
|
|
<Route path="/exercises">
|
|
<Route index element={<ExercisesListPage />} />
|
|
<Route path="new" element={<ExerciseFormPage />} />
|
|
<Route path=":id" element={<ExerciseDetailPage />} />
|
|
<Route path=":id/edit" element={<ExerciseFormPage />} />
|
|
{/* Varianten: Bearbeitung inline in ExerciseFormPage, keine eigenen Routen */}
|
|
</Route>
|
|
|
|
{/* Exercise Blocks Routes */}
|
|
<Route path="/exercise-blocks">
|
|
<Route index element={<ExerciseBlocksListPage />} />
|
|
<Route path="new" element={<ExerciseBlockFormPage />} />
|
|
<Route path=":id" element={<ExerciseBlockDetailPage />} />
|
|
<Route path=":id/edit" element={<ExerciseBlockFormPage />} />
|
|
</Route>
|
|
|
|
{/* Admin Routes */}
|
|
<Route path="/admin/exercises" element={
|
|
<ProtectedRoute requiredRole="admin">
|
|
<Outlet />
|
|
</ProtectedRoute>
|
|
}>
|
|
<Route path="catalog" element={<AdminCatalogPage />} />
|
|
<Route path="skills" element={<AdminSkillsPage />} />
|
|
<Route path="methods" element={<AdminMethodsPage />} />
|
|
</Route>
|
|
|
|
{/* Redirects */}
|
|
<Route path="/" element={<Navigate to="/exercises" replace />} />
|
|
<Route path="*" element={<NotFoundPage />} />
|
|
</Routes>
|
|
</CatalogProvider>
|
|
</BrowserRouter>
|
|
)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
**Version:** 1.2
|
|
**Letzte Änderung:** 2026-04-30
|
|
**Status:** REVIEWED - Pending Implementation
|
|
**Review-Änderungen:** Progressionsgraphen-UI (Tabs, Formularblock); Exercise Blocks Routes + Navigation (früher)
|