# Search & Filter Specification

**Version:** 1.1  
**Datum:** 2026-04-27  
**Status:** DRAFT - Awaiting Review  
**Autor:** Claude Code  
**Änderungen v1.1:** Abschnitt 1.2 – implementiertes Verhalten Übungsliste (Fokus, listFetching, Chips, datalist)

---

## 1. Suchstrategie

### 1.1 Multi-Layer Search

**Drei Suchebenen:**
1. **Keyword-Search:** Titel, Summary, Execution (PostgreSQL tsvector)
2. **Katalog-Filter:** Focus Area, Style, Target Group, Age Group, Skill
3. **Meta-Filter:** Visibility, Status, Creator, Club

**Kombination:** Alle Filter UND Keyword-Search kombinierbar

### 1.2 Übungsliste (Frontend, implementiert)

Auf **`ExercisesListPage`** gilt:

- Filter und Suche kombinieren sich; **Server-Request** bei geänderter Query (Debouncing möglich).
- **Kein Ersetzen der gesamten Seite** durch einen Ladezustand bei List-Refetch — nur die Liste zeigt einen **„Aktualisiere Treffer…“**-Zustand; Suchfelder bleiben montiert (**Fokus erhalten**).
- Erste Ladephase ohne Treffer: kompakter Spinner **unter** der Suchleiste.
- **`<datalist>`** (`list=` an Suchfeldern) mit Distinct-Titeln aus den aktuellen Treffern zur Auswahl.
- Zusätzliche Filter erscheinen als **Chips**; Zähler am Filter-Button = Anzahl aktiver Filter.

---

## 2. Volltext-Suche (PostgreSQL)

### 2.1 tsvector Implementation

**Migration:** Erweitert `exercises` Tabelle
```sql
-- Migration 014 (Teil)
ALTER TABLE exercises ADD COLUMN search_vector tsvector;

-- Index für Volltext-Suche
CREATE INDEX idx_exercises_search ON exercises USING gin(search_vector);

-- Trigger für automatische Updates
CREATE OR REPLACE FUNCTION update_exercises_search_vector()
RETURNS trigger AS $$
BEGIN
    NEW.search_vector :=
        setweight(to_tsvector('german', COALESCE(NEW.title, '')), 'A') ||
        setweight(to_tsvector('german', COALESCE(NEW.summary, '')), 'B') ||
        setweight(to_tsvector('german', COALESCE(NEW.execution, '')), 'C') ||
        setweight(to_tsvector('german', COALESCE(NEW.trainer_notes, '')), 'D');
    RETURN NEW;
END
$$ LANGUAGE plpgsql;

CREATE TRIGGER exercises_search_update
BEFORE INSERT OR UPDATE ON exercises
FOR EACH ROW EXECUTE FUNCTION update_exercises_search_vector();
```

**Gewichtung:**
- A (höchste): Titel
- B: Summary
- C: Execution
- D (niedrigste): Trainer Notes

### 2.2 Backend-Query

**Endpoint:** `GET /api/exercises?search=<keyword>`

```python
@router.get("/exercises")
def list_exercises(
    search: str = None,
    focus_area: int = None,
    visibility: str = None,
    status: str = None,
    skill_id: int = None,
    limit: int = 50,
    offset: int = 0,
    session: dict = Depends(require_auth)
):
    profile_id = session['profile_id']
    conditions = []
    params = []
    
    # Volltext-Suche
    if search:
        conditions.append("""
            search_vector @@ plainto_tsquery('german', %s)
        """)
        params.append(search)
    
    # Katalog-Filter
    if focus_area:
        conditions.append("""
            EXISTS (
                SELECT 1 FROM exercise_focus_areas
                WHERE exercise_id = exercises.id AND focus_area_id = %s
            )
        """)
        params.append(focus_area)
    
    # Meta-Filter
    if visibility:
        conditions.append("visibility = %s")
        params.append(visibility)
    
    if status:
        conditions.append("status = %s")
        params.append(status)
    
    # Skill-Filter
    if skill_id:
        conditions.append("""
            EXISTS (
                SELECT 1 FROM exercise_skills
                WHERE exercise_id = exercises.id AND skill_id = %s
            )
        """)
        params.append(skill_id)
    
    # WHERE-Clause zusammenbauen
    where_clause = " AND ".join(conditions) if conditions else "1=1"
    
    # Query mit Ranking (bei Volltext-Suche)
    if search:
        query = f"""
            SELECT exercises.*,
                   ts_rank(search_vector, plainto_tsquery('german', %s)) AS rank
            FROM exercises
            WHERE {where_clause}
            ORDER BY rank DESC, created_at DESC
            LIMIT %s OFFSET %s
        """
        params = [search] + params + [limit, offset]
    else:
        query = f"""
            SELECT exercises.* FROM exercises
            WHERE {where_clause}
            ORDER BY created_at DESC
            LIMIT %s OFFSET %s
        """
        params = params + [limit, offset]
    
    cur = get_db_cursor()
    cur.execute(query, params)
    return [r2d(row) for row in cur.fetchall()]
```

### 2.3 Highlighting (Optional)

**Snippet mit Treffer-Markierung:**
```python
def get_search_snippet(exercise, search_term):
    """Generiert Snippet mit <mark>-Tags um Treffer"""
    cur = get_db_cursor()
    cur.execute("""
        SELECT ts_headline('german', execution,
            plainto_tsquery('german', %s),
            'StartSel=<mark>, StopSel=</mark>, MaxWords=50, MinWords=30'
        ) AS snippet
        FROM exercises WHERE id = %s
    """, (search_term, exercise['id']))
    
    result = cur.fetchone()
    return result['snippet'] if result else exercise['summary']
```

**Response mit Snippet:**
```json
{
  "id": 1,
  "title": "Maai - Distanzübung",
  "summary": "...",
  "snippet": "...Partner hält <mark>Pratzen</mark>, Ausführung wie Haupt..."
}
```

---

## 3. Frontend-Filter-UI

### 3.1 Komponente: SearchFilterBar

**Layout:**
```
┌────────────────────────────────────────────────────────┐
│ 🔍 [Suche...]                       [Filter ▼]  [×]    │
├────────────────────────────────────────────────────────┤
│ Aktive Filter:                                         │
│  [Karate ×]  [Breitensport ×]  [Freigegeben ×]       │
└────────────────────────────────────────────────────────┘
```

**Code:**
```jsx
function SearchFilterBar({ onFiltersChange, initialFilters = {} }) {
  const [search, setSearch] = useState(initialFilters.search || '')
  const [showFilterPanel, setShowFilterPanel] = useState(false)
  const [filters, setFilters] = useState(initialFilters)
  
  const { focusAreas, trainingStyles, targetGroups } = useCatalog()
  
  const handleSearchChange = debounce((value) => {
    const newFilters = { ...filters, search: value }
    setFilters(newFilters)
    onFiltersChange(newFilters)
  }, 300)
  
  const handleFilterChange = (key, value) => {
    const newFilters = { ...filters, [key]: value }
    setFilters(newFilters)
    onFiltersChange(newFilters)
  }
  
  const clearFilter = (key) => {
    const newFilters = { ...filters }
    delete newFilters[key]
    setFilters(newFilters)
    onFiltersChange(newFilters)
  }
  
  const clearAllFilters = () => {
    setFilters({})
    setSearch('')
    onFiltersChange({})
  }
  
  return (
    <div className="search-filter-bar">
      {/* Suchfeld */}
      <div className="search-input">
        <SearchIcon />
        <input
          type="search"
          placeholder="Übungen durchsuchen..."
          value={search}
          onChange={(e) => {
            setSearch(e.target.value)
            handleSearchChange(e.target.value)
          }}
        />
      </div>
      
      {/* Filter-Toggle */}
      <button
        className="btn-filter"
        onClick={() => setShowFilterPanel(!showFilterPanel)}
      >
        Filter {Object.keys(filters).length > 0 && `(${Object.keys(filters).length})`}
        <ChevronIcon direction={showFilterPanel ? 'up' : 'down'} />
      </button>
      
      {/* Clear-Button */}
      {Object.keys(filters).length > 0 && (
        <button className="btn-clear" onClick={clearAllFilters}>
          ×
        </button>
      )}
      
      {/* Aktive Filter (Chips) */}
      {Object.keys(filters).length > 0 && (
        <div className="active-filters">
          <span>Aktive Filter:</span>
          {filters.focus_area && (
            <Chip
              label={focusAreas.find(f => f.id === filters.focus_area)?.name}
              onRemove={() => clearFilter('focus_area')}
            />
          )}
          {filters.visibility && (
            <Chip
              label={filters.visibility}
              onRemove={() => clearFilter('visibility')}
            />
          )}
          {filters.status && (
            <Chip
              label={filters.status}
              onRemove={() => clearFilter('status')}
            />
          )}
        </div>
      )}
      
      {/* Filter-Panel (aufklappbar) */}
      {showFilterPanel && (
        <div className="filter-panel">
          <div className="filter-section">
            <label>Fokusbereich</label>
            <select
              value={filters.focus_area || ''}
              onChange={(e) => handleFilterChange('focus_area', parseInt(e.target.value) || null)}
            >
              <option value="">Alle</option>
              {focusAreas.map(fa => (
                <option key={fa.id} value={fa.id}>{fa.name}</option>
              ))}
            </select>
          </div>
          
          <div className="filter-section">
            <label>Stil</label>
            <select
              value={filters.training_style || ''}
              onChange={(e) => handleFilterChange('training_style', parseInt(e.target.value) || null)}
            >
              <option value="">Alle</option>
              {trainingStyles.map(ts => (
                <option key={ts.id} value={ts.id}>{ts.name}</option>
              ))}
            </select>
          </div>
          
          <div className="filter-section">
            <label>Sichtbarkeit</label>
            <select
              value={filters.visibility || ''}
              onChange={(e) => handleFilterChange('visibility', e.target.value || null)}
            >
              <option value="">Alle</option>
              <option value="private">Privat</option>
              <option value="club">Verein</option>
              <option value="official">Offiziell</option>
            </select>
          </div>
          
          <div className="filter-section">
            <label>Status</label>
            <select
              value={filters.status || ''}
              onChange={(e) => handleFilterChange('status', e.target.value || null)}
            >
              <option value="">Alle</option>
              <option value="draft">Entwurf</option>
              <option value="in_review">In Prüfung</option>
              <option value="approved">Freigegeben</option>
              <option value="archived">Archiviert</option>
            </select>
          </div>
        </div>
      )}
    </div>
  )
}
```

### 3.2 Debouncing (Performance)

**Verhindert API-Call bei jedem Tastendruck:**
```javascript
function debounce(func, wait) {
  let timeout
  return function executedFunction(...args) {
    const later = () => {
      clearTimeout(timeout)
      func(...args)
    }
    clearTimeout(timeout)
    timeout = setTimeout(later, wait)
  }
}
```

**Nur nach 300ms Pause wird gesucht → weniger Server-Last**

---

## 4. Erweiterte Filter

### 4.1 Multi-Select-Filter

**Beispiel: Mehrere Zielgruppen gleichzeitig:**
```jsx
<MultiSelect
  options={targetGroups}
  selected={filters.target_groups || []}
  onChange={(selected) => handleFilterChange('target_groups', selected)}
  label="Zielgruppen"
  placeholder="Wähle eine oder mehrere..."
/>
```

**Backend-Query:**
```python
if target_groups:  # List von IDs
    conditions.append("""
        EXISTS (
            SELECT 1 FROM exercise_target_groups
            WHERE exercise_id = exercises.id
              AND target_group_id = ANY(%s)
        )
    """)
    params.append(target_groups)
```

### 4.2 Range-Filter (Dauer, Gruppengröße)

**UI:**
```jsx
<div className="filter-range">
  <label>Dauer (Minuten)</label>
  <input
    type="number"
    placeholder="Min"
    value={filters.duration_min || ''}
    onChange={(e) => handleFilterChange('duration_min', parseInt(e.target.value) || null)}
  />
  <span>bis</span>
  <input
    type="number"
    placeholder="Max"
    value={filters.duration_max || ''}
    onChange={(e) => handleFilterChange('duration_max', parseInt(e.target.value) || null)}
  />
</div>
```

**Backend-Query:**
```python
if duration_min:
    conditions.append("duration_max >= %s")
    params.append(duration_min)

if duration_max:
    conditions.append("duration_min <= %s")
    params.append(duration_max)
```

### 4.3 Equipment-Filter (JSONB)

**UI:**
```jsx
<div className="filter-equipment">
  <label>
    <input
      type="checkbox"
      checked={filters.no_equipment || false}
      onChange={(e) => handleFilterChange('no_equipment', e.target.checked)}
    />
    Ohne Hilfsmittel
  </label>
</div>
```

**Backend-Query:**
```python
if no_equipment:
    conditions.append("""
        (equipment IS NULL OR equipment = '[]'::jsonb)
    """)
```

---

## 5. Faceted Search (Treffer-Zählung pro Filter)

### 5.1 Backend-Aggregation

**Zeigt Anzahl Treffer pro Filter-Option:**
```python
@router.get("/exercises/facets")
def get_facets(search: str = None, session: dict = Depends(require_auth)):
    base_condition = "1=1"
    params = []
    
    if search:
        base_condition = "search_vector @@ plainto_tsquery('german', %s)"
        params = [search]
    
    cur = get_db_cursor()
    
    # Focus Area Facets
    cur.execute(f"""
        SELECT fa.id, fa.name, COUNT(DISTINCT e.id) as count
        FROM focus_areas fa
        LEFT JOIN exercise_focus_areas efa ON fa.id = efa.focus_area_id
        LEFT JOIN exercises e ON efa.exercise_id = e.id
        WHERE {base_condition}
        GROUP BY fa.id, fa.name
        ORDER BY count DESC
    """, params)
    focus_areas = [r2d(r) for r in cur.fetchall()]
    
    # Visibility Facets
    cur.execute(f"""
        SELECT visibility, COUNT(*) as count
        FROM exercises
        WHERE {base_condition}
        GROUP BY visibility
    """, params)
    visibility = [r2d(r) for r in cur.fetchall()]
    
    return {
        "focus_areas": focus_areas,
        "visibility": visibility,
        # ... weitere Facets
    }
```

### 5.2 Frontend-Anzeige

**Zeigt Treffer-Anzahl neben Filter:**
```jsx
<select>
  <option value="">Alle</option>
  {facets.focus_areas.map(fa => (
    <option key={fa.id} value={fa.id}>
      {fa.name} ({fa.count})
    </option>
  ))}
</select>
```

---

## 6. Saved Searches / Presets

### 6.1 Favoriten-Filter speichern

**UI:**
```jsx
<button onClick={handleSaveFilter}>
  ⭐ Filter speichern
</button>

{savedFilters.map(sf => (
  <button
    key={sf.id}
    onClick={() => loadFilter(sf)}
    className="saved-filter"
  >
    {sf.name}
  </button>
))}
```

**Backend-Tabelle:**
```sql
CREATE TABLE saved_exercise_searches (
    id SERIAL PRIMARY KEY,
    profile_id INT REFERENCES profiles(id) ON DELETE CASCADE,
    name VARCHAR(100) NOT NULL,
    filters JSONB NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);
```

**API:**
```python
@router.post("/exercises/saved-searches")
def save_search(
    name: str,
    filters: dict,
    session: dict = Depends(require_auth)
):
    profile_id = session['profile_id']
    
    cur = get_db_cursor()
    cur.execute("""
        INSERT INTO saved_exercise_searches (profile_id, name, filters)
        VALUES (%s, %s, %s)
        RETURNING id, created_at
    """, (profile_id, name, json.dumps(filters)))
    
    result = cur.fetchone()
    return {
        "id": result['id'],
        "name": name,
        "filters": filters,
        "created_at": result['created_at'].isoformat(),
    }
```

---

## 7. Sortierung

### 7.1 Sortier-Optionen

**UI:**
```jsx
<select
  value={sortBy}
  onChange={(e) => setSortBy(e.target.value)}
>
  <option value="relevance">Relevanz</option>
  <option value="newest">Neueste zuerst</option>
  <option value="oldest">Älteste zuerst</option>
  <option value="title_asc">Titel A-Z</option>
  <option value="title_desc">Titel Z-A</option>
  <option value="duration">Dauer (kurz → lang)</option>
</select>
```

### 7.2 Backend-Sortierung

**Query-Parameter:** `?sort=newest`

```python
SORT_OPTIONS = {
    'relevance': 'rank DESC',  # nur bei Volltext-Suche
    'newest': 'created_at DESC',
    'oldest': 'created_at ASC',
    'title_asc': 'title ASC',
    'title_desc': 'title DESC',
    'duration': 'duration_min ASC',
}

@router.get("/exercises")
def list_exercises(sort: str = 'newest', ...):
    order_by = SORT_OPTIONS.get(sort, 'created_at DESC')
    
    # Bei Volltext-Suche IMMER nach Relevanz sortieren
    if search and sort != 'relevance':
        order_by = f"rank DESC, {order_by}"
    
    query = f"""
        SELECT ... FROM exercises
        WHERE {where_clause}
        ORDER BY {order_by}
        LIMIT %s OFFSET %s
    """
```

---

## 8. Performance-Optimierung

### 8.1 Indizes

**Wichtige Indizes:**
```sql
-- Volltext-Suche
CREATE INDEX idx_exercises_search ON exercises USING gin(search_vector);

-- Häufige Filter
CREATE INDEX idx_exercises_visibility ON exercises(visibility);
CREATE INDEX idx_exercises_status ON exercises(status);
CREATE INDEX idx_exercises_created_at ON exercises(created_at DESC);

-- M:N Relations
CREATE INDEX idx_exercise_focus_areas_focus ON exercise_focus_areas(focus_area_id);
CREATE INDEX idx_exercise_skills_skill ON exercise_skills(skill_id);
```

### 8.2 Query-Caching (Backend)

**Cachen häufiger Abfragen:**
```python
from functools import lru_cache
import hashlib

@lru_cache(maxsize=100)
def get_exercises_cached(filters_hash: str):
    # filters_hash = SHA256 von JSON(filters)
    # Cached für 60 Sekunden
    pass

def list_exercises(filters: dict):
    filters_hash = hashlib.sha256(
        json.dumps(filters, sort_keys=True).encode()
    ).hexdigest()
    
    return get_exercises_cached(filters_hash)
```

### 8.3 Client-Side Caching (React Query)

**Nutzt React Query für intelligentes Caching:**
```jsx
const { data: exercises, isLoading } = useQuery(
  ['exercises', filters],
  () => api.listExercises(filters),
  {
    staleTime: 5 * 60 * 1000, // 5 Minuten fresh
    cacheTime: 10 * 60 * 1000, // 10 Minuten im Cache
  }
)
```

---

## 9. Mobile-Optimierung

### 9.1 Filter-Drawer (Mobile)

**Statt Dropdown → Fullscreen-Drawer:**
```jsx
{/* Desktop: Inline-Panel */}
<div className="filter-panel desktop-only">
  {/* Filter-Felder */}
</div>

{/* Mobile: Drawer */}
<Drawer
  isOpen={showFilterDrawer}
  onClose={() => setShowFilterDrawer(false)}
  position="bottom"
>
  <div className="filter-drawer">
    <h3>Filter</h3>
    {/* Filter-Felder */}
    <button onClick={applyFilters}>
      {resultsCount} Ergebnisse anzeigen
    </button>
  </div>
</Drawer>
```

### 9.2 Sticky Search-Bar

**Bleibt beim Scrollen oben:**
```css
.search-filter-bar {
  position: sticky;
  top: 0;
  z-index: 10;
  background: var(--bg);
  border-bottom: 1px solid var(--border);
  padding: 12px;
}
```

---

## 10. Testing

### 10.1 Volltext-Suche-Tests

```javascript
test('Volltext-Suche findet Übung im Titel', async ({ page }) => {
  await page.goto('/exercises')
  
  await page.fill('input[type="search"]', 'Maai')
  
  await expect(page.locator('.exercise-card:has-text("Maai")')).toBeVisible()
})

test('Volltext-Suche findet Übung in Execution', async ({ page }) => {
  await page.goto('/exercises')
  
  await page.fill('input[type="search"]', 'Pratzen')
  
  await expect(page.locator('.exercise-card')).toHaveCount(1)
})
```

### 10.2 Filter-Kombination-Tests

```javascript
test('Filter kombinierbar: Focus Area + Visibility', async ({ page }) => {
  await page.goto('/exercises')
  
  await page.selectOption('select[name="focus_area"]', '1') // Karate
  await page.selectOption('select[name="visibility"]', 'club')
  
  const cards = page.locator('.exercise-card')
  await expect(cards).toHaveCount(3)
})
```

---

**Version:** 1.0  
**Letzte Änderung:** 2026-04-24  
**Status:** DRAFT - Awaiting Review
