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

18 KiB
Raw Blame History

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:

// 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):

// ExerciseFormPage.jsx
const [formData, setFormData] = useState({
  title: '',
  summary: '',
  goal: '',
  execution: '',
  // ... weitere Felder
})

UI-State (nicht in URL):

// 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):

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

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

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

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

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

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

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

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

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

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:

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:

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

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

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

Pattern:

// App.jsx
<a href="#main-content" className="skip-link">
  Zum Hauptinhalt springen
</a>

<main id="main-content">
  {/* Content */}
</main>

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

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

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

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

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

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