shinkan-jinkendo/.claude/docs/technical/EXERCISES_FRONTEND_ROUTING.md
Lars e4451e1362
Some checks failed
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 1s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Has been cancelled
Enhance Exercise Management and AI Integration
- Updated the exercise form to include a tabbed navigation structure, improving user experience with sections for Stammdaten, Anleitung, Einordnung, Varianten, and Medien & Mehr.
- Introduced the concept of **Freigabelevel** (visibility level) in the UI, replacing previous terminology for clarity and consistency across components.
- Implemented new AI endpoints for exercise suggestions and regeneration, allowing for dynamic content generation without direct database writes.
- Removed the legacy `is_primary` flag from exercise skills in the UI, ensuring that intensity levels (`niedrig`, `mittel`, `hoch`) are the primary focus for skill management.
- Enhanced the variant management process with improved saving mechanisms and UI updates to reflect changes more intuitively.
2026-05-22 07:52:31 +02:00

700 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Frontend Routing & Navigation Specification
**Version:** 1.3
**Datum:** 2026-05-20
**Status:** DRAFT - Awaiting Review
**Autor:** Claude Code
**Änderungen v1.3:** Übungsformular Tab-Navigation unter `/exercises/:id/edit` (Stammdaten … Medien & Mehr); Freigabelevel als UI-Begriff
**Ä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: Registerkarten + Varianten inline + 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`
### 1.2 Übungsformular Registerkarten (`/exercises/new`, `/exercises/:id/edit`)
**Implementierung:** `ExerciseFormPageRoot.jsx` + `ExerciseFormLayout.jsx` (`ExerciseFormTabBar`, `ExerciseFormPanel`).
| Tab-ID | Label | Verfügbarkeit |
|--------|-------|---------------|
| `stammdaten` | Stammdaten | immer |
| `anleitung` | Anleitung | immer |
| `einordnung` | Einordnung | immer |
| `kombination` | Kombination | nur `exercise_kind=combination` |
| `varianten` | Varianten | Edit-Modus; nicht bei Kombination; disabled bei Neuanlage |
| `medien` | Medien & Mehr | Edit-Modus; disabled bei Neuanlage |
**UX-Regeln:**
- Nur ein Panel sichtbar (`activeFormTab`); Navigation über `PageSectionNav`.
- **Freigabelevel** (Feld `visibility`) in Stammdaten — Konstante `EXERCISE_VISIBILITY_FIELD_LABEL`.
- Varianten-Änderungen werden mit **Speichern** in der Aktionsleiste persistiert (`persistPendingVariantChanges`); Button „Variante anlegen“ optional sofort.
- Kein URL-Hash pro Tab (Tab-State nur lokal).
---
## 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.3
**Letzte Änderung:** 2026-05-20
**Status:** REVIEWED - Pending Implementation
**Review-Änderungen:** Formular-Registerkarten; Progressionsgraphen-UI (Tabs, Formularblock); Exercise Blocks Routes + Navigation (früher)