diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index 86fa2df..04bd1f6 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -645,6 +645,21 @@ def _merge_ids(multi: list[int], single: Optional[int]) -> list[int]:
return out
+def _dedupe_positive_ids(ids: list[int]) -> list[int]:
+ seen: set[int] = set()
+ out: list[int] = []
+ for raw in ids or []:
+ try:
+ xi = int(raw)
+ except (TypeError, ValueError):
+ continue
+ if xi < 1 or xi in seen:
+ continue
+ seen.add(xi)
+ out.append(xi)
+ return out
+
+
def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]:
seen = set()
out = []
@@ -954,6 +969,18 @@ def list_exercises(
default=False,
description="Wenn true: nur Übungen mit mindestens einem Fokusbereich",
),
+ focus_only_without_focus_areas: bool = Query(
+ default=False,
+ description="Nur Übungen ohne einen einzigen Fokusbereich (M:N exercise_focus_areas leer)",
+ ),
+ focus_area_must_include_ids: list[int] = Query(
+ default=[],
+ description="Alle genannten Fokusbereiche müssen gesetzt sein (UND / „+“)",
+ ),
+ focus_area_must_exclude_ids: list[int] = Query(
+ default=[],
+ description="Keiner dieser Fokusbereiche darf gesetzt sein („−“)",
+ ),
include_archived: bool = Query(
default=False,
description="Archivierte einbeziehen; Standard false (außer Statusfilter enthält archived)",
@@ -1022,18 +1049,58 @@ def list_exercises(
where.append(f"(e.status IS NULL OR LOWER(TRIM(e.status::text)) NOT IN ({ph}))")
params.extend(st_excl)
- if exclude_without_focus:
- where.append(
- "EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
- )
+ focus_only = focus_only_without_focus_areas
+ must_inc = _dedupe_positive_ids(list(focus_area_must_include_ids))
+ must_exc = _dedupe_positive_ids(list(focus_area_must_exclude_ids))
+ fa_or = _merge_ids(focus_area_ids, focus_area)
- fa_ids = _merge_ids(focus_area_ids, focus_area)
- if fa_ids:
- ph = ",".join(["%s"] * len(fa_ids))
+ if focus_only:
+ if exclude_without_focus:
+ raise HTTPException(
+ status_code=400,
+ detail="focus_only_without_focus_areas schließt exclude_without_focus aus.",
+ )
+ if fa_or:
+ raise HTTPException(
+ status_code=400,
+ detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_ids (ODER-Liste) verwendet werden.",
+ )
+ if must_inc:
+ raise HTTPException(
+ status_code=400,
+ detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_must_include_ids verwendet werden.",
+ )
+ if must_exc:
+ raise HTTPException(
+ status_code=400,
+ detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_must_exclude_ids verwendet werden.",
+ )
where.append(
- f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
+ "NOT EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
)
- params.extend(fa_ids)
+ else:
+ if exclude_without_focus:
+ where.append(
+ "EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
+ )
+ if fa_or:
+ ph = ",".join(["%s"] * len(fa_or))
+ where.append(
+ f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
+ )
+ params.extend(fa_or)
+ for fid in must_inc:
+ where.append(
+ "EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id = %s)"
+ )
+ params.append(fid)
+ if must_exc:
+ ph = ",".join(["%s"] * len(must_exc))
+ where.append(
+ f"NOT EXISTS (SELECT 1 FROM exercise_focus_areas efa "
+ f"WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
+ )
+ params.extend(must_exc)
sk_ids = _merge_ids(skill_ids, skill_id)
if sk_ids:
diff --git a/backend/version.py b/backend/version.py
index 73e271a..6e41b32 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.39"
+APP_VERSION = "0.8.40"
BUILD_DATE = "2026-05-06"
DB_SCHEMA_VERSION = "20260506043"
@@ -15,7 +15,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
- "exercises": "2.9.0", # DELETE RBAC (Trainer/Vereinsadmin/Plattform); Nutzungs-409; Listenfilter Negativlisten + Archiv-Standard; exercise_list_prefs
+ "exercises": "2.10.0", # GET /exercises: focus_area_must_include/exclude_ids, focus_only_without_focus_areas; UI +/- Fokusregeln
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@@ -27,6 +27,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.40",
+ "date": "2026-05-06",
+ "changes": [
+ "Übungen Liste: Fokusfilter mit UND-+ (must_include) und UND-− (must_exclude), nur ohne Fokusbereich (focus_only_without); Frontend Dropdown + Mit / − Ohne",
+ ],
+ },
{
"version": "0.8.39",
"date": "2026-05-06",
diff --git a/frontend/src/components/ExerciseFocusRulePicker.jsx b/frontend/src/components/ExerciseFocusRulePicker.jsx
new file mode 100644
index 0000000..8146f55
--- /dev/null
+++ b/frontend/src/components/ExerciseFocusRulePicker.jsx
@@ -0,0 +1,145 @@
+import React, { useMemo, useState } from 'react'
+
+function newRuleKey() {
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
+ return `fr-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
+}
+
+/**
+ * Fokusbereiche mit „+“ (muss haben) und „−“ (darf nicht haben); optional nur Übungen ohne Fokus-Zuordnung.
+ */
+export default function ExerciseFocusRulePicker({
+ focusOptions,
+ focusRules,
+ focusOnlyWithout,
+ excludeWithoutFocus,
+ legacyFocusAreaIds = [],
+ onPatch,
+}) {
+ const [pendingId, setPendingId] = useState('')
+
+ const legacyWarning = useMemo(
+ () => Array.isArray(legacyFocusAreaIds) && legacyFocusAreaIds.length > 0,
+ [legacyFocusAreaIds]
+ )
+
+ const addRule = (mode) => {
+ const id = String(pendingId || '').trim()
+ if (!id || focusOnlyWithout) return
+ const dup = (focusRules || []).some(
+ (r) => String(r.focus_area_id) === id && r.mode === mode
+ )
+ if (dup) return
+ onPatch({
+ focus_rules: [...(focusRules || []), { key: newRuleKey(), focus_area_id: id, mode }],
+ })
+ setPendingId('')
+ }
+
+ const removeRule = (key) => {
+ onPatch({
+ focus_rules: (focusRules || []).filter((r) => r.key !== key),
+ })
+ }
+
+ const setFocusOnly = (on) => {
+ if (on) {
+ onPatch({
+ focus_only_without: true,
+ exclude_without_focus: false,
+ focus_rules: [],
+ focus_area_ids: [],
+ })
+ setPendingId('')
+ return
+ }
+ onPatch({ focus_only_without: false })
+ }
+
+ return (
+
+
+ setFocusOnly(e.target.checked)}
+ />
+ Nur Übungen ohne Fokusbereich (keine Zuordnung)
+
+
+ {!focusOnlyWithout ? (
+ <>
+ {legacyWarning ? (
+
+ Es sind noch ältere Fokusfilter (ODER-Liste) aktiv — bitte über die Chips entfernen oder durch Regeln
+ ersetzen.
+
+ ) : null}
+
Fokusbereiche (+ mit / − ohne)
+
+ Mehrere „+“: alle müssen gesetzt sein (UND). Mehrere „−“: keiner davon darf gesetzt sein.
+ Kombination z. B. „+ Karate“ und „− Fitness“ = hat Karate, aber nicht zusätzlich Fitness.
+
+
+ setPendingId(e.target.value)}
+ aria-label="Fokusbereich wählen"
+ >
+ Fokusbereich wählen …
+ {focusOptions.map((o) => (
+
+ {o.label || o.id}
+
+ ))}
+
+ addRule('require')}>
+ + Mit
+
+ addRule('forbid')}>
+ − Ohne
+
+
+ {(focusRules || []).length > 0 ? (
+
+ {(focusRules || []).map((r) => {
+ const opt = focusOptions.find((o) => String(o.id) === String(r.focus_area_id))
+ const name = opt?.label || r.focus_area_id
+ return (
+
+
+ {r.mode === 'forbid' ? '−' : '+'} {name}
+
+ removeRule(r.key)}>
+ Entfernen
+
+
+ )
+ })}
+
+ ) : null}
+ >
+ ) : (
+
+ Solange diese Option aktiv ist, sind Fokus-Regeln und die ODER-Fokusliste deaktiviert.
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx
index 8e4af3e..1f36dd4 100644
--- a/frontend/src/components/ExercisePickerModal.jsx
+++ b/frontend/src/components/ExercisePickerModal.jsx
@@ -11,6 +11,7 @@ import {
mergeExerciseListPrefsFromApi,
} from '../constants/exerciseListFilters'
import MultiSelectCombo from './MultiSelectCombo'
+import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
const PAGE_SIZE = 100
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
@@ -154,6 +155,19 @@ export default function ExercisePickerModal({
const n = (v) => (v === '' || v == null ? undefined : Number(v))
const ids = (arr) =>
Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
+ const mustInc = []
+ const mustExc = []
+ for (const r of filters.focus_rules || []) {
+ const id = Number(r.focus_area_id)
+ if (!Number.isFinite(id) || id < 1) continue
+ if (r.mode === 'forbid') mustExc.push(id)
+ else mustInc.push(id)
+ }
+ const uniqNums = (arr) => [...new Set(arr)]
+ if (mustInc.length) q.focus_area_must_include_ids = uniqNums(mustInc)
+ if (mustExc.length) q.focus_area_must_exclude_ids = uniqNums(mustExc)
+ if (filters.focus_only_without) q.focus_only_without_focus_areas = true
+
const fa = ids(filters.focus_area_ids)
if (fa?.length) q.focus_area_ids = fa
const sd = ids(filters.style_direction_ids)
@@ -297,18 +311,17 @@ export default function ExercisePickerModal({
{filterOpen && (
- Zwischen den Bereichen gilt UND , innerhalb ODER wie in der Übungsübersicht.
+ Zwischen den Bereichen gilt UND . Fokus: „+ mit“ / „− ohne“ kombinierbar (mehrere „+“ =
+ alle gesetzt). Sonstige Mehrfachauswahl pro Feld mit ODER .
-
-
- Fokus
- setFilters((f) => ({ ...f, focus_area_ids: v }))}
- options={focusOptions}
- placeholder="Fokus …"
- />
-
+
setFilters((f) => ({ ...f, ...patch }))}
+ />
+
Stilrichtung
-
+
setFilters((f) => ({ ...f, exclude_without_focus: e.target.checked }))}
+ onChange={(e) =>
+ setFilters((f) => ({
+ ...f,
+ exclude_without_focus: e.target.checked,
+ ...(e.target.checked ? { focus_only_without: false } : {}),
+ }))
+ }
/>
Ohne Fokus ausblenden
diff --git a/frontend/src/constants/exerciseListFilters.js b/frontend/src/constants/exerciseListFilters.js
index 31c92f2..a84e74d 100644
--- a/frontend/src/constants/exerciseListFilters.js
+++ b/frontend/src/constants/exerciseListFilters.js
@@ -1,6 +1,11 @@
/** Gemeinsame Default-Filter für Übungslisten (Übersicht + Auswahlmodal). */
export const INITIAL_EXERCISE_LIST_FILTERS = {
+ /** Legacy: ODER über mehrere Fokus-IDs (wird nur noch aus gespeicherten Presets oder Chips genutzt). */
focus_area_ids: [],
+ /** Regeln: mode require = muss Fokus haben, forbid = darf diesen Fokus nicht haben (UND über alle require). */
+ focus_rules: [],
+ /** Nur Übungen ohne einen Eintrag in exercise_focus_areas. */
+ focus_only_without: false,
style_direction_ids: [],
training_type_ids: [],
target_group_ids: [],
@@ -25,6 +30,20 @@ export function mergeExerciseListPrefsFromApi(raw) {
if (!raw || typeof raw !== 'object') return out
for (const k of PREFS_KEYS) {
if (raw[k] === undefined) continue
+ if (k === 'focus_rules' && Array.isArray(raw[k])) {
+ out.focus_rules = raw[k]
+ .filter((r) => r && r.focus_area_id != null && (r.mode === 'require' || r.mode === 'forbid'))
+ .map((r, i) => ({
+ key: r.key || `imp-${i}-${r.focus_area_id}-${r.mode}`,
+ focus_area_id: String(r.focus_area_id),
+ mode: r.mode,
+ }))
+ continue
+ }
+ if (k === 'focus_only_without') {
+ out.focus_only_without = !!raw[k]
+ continue
+ }
if (k.endsWith('_ids') || k.endsWith('_any')) {
if (Array.isArray(raw[k])) out[k] = raw[k].map(String)
continue
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx
index e5fc226..7acac57 100644
--- a/frontend/src/pages/ExercisesListPage.jsx
+++ b/frontend/src/pages/ExercisesListPage.jsx
@@ -16,6 +16,7 @@ import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo'
+import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import PageSectionNav from '../components/PageSectionNav'
import {
@@ -221,11 +222,33 @@ function ExercisesListPage() {
const filterChips = useMemo(() => {
const chips = []
+ ;(filters.focus_rules || []).forEach((r) => {
+ const opt = focusOptions.find((o) => String(o.id) === String(r.focus_area_id))
+ const verb = r.mode === 'forbid' ? 'Fokus ohne' : 'Fokus mit'
+ chips.push({
+ key: `fr-${r.key}`,
+ label: `${verb}: ${opt?.label ?? r.focus_area_id}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ focus_rules: prev.focus_rules.filter((x) => x.key !== r.key),
+ })),
+ })
+ })
+
+ if (filters.focus_only_without) {
+ chips.push({
+ key: 'focus-only-none',
+ label: 'Nur ohne Fokusbereich',
+ onRemove: () => setFilters((prev) => ({ ...prev, focus_only_without: false })),
+ })
+ }
+
;(filters.focus_area_ids || []).forEach((id) => {
const opt = focusOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `fa-${id}`,
- label: `Fokus: ${opt?.label ?? id}`,
+ label: `Fokus (ODER, älter): ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
@@ -385,6 +408,19 @@ function ExercisesListPage() {
const n = (v) => (v === '' || v == null ? undefined : Number(v))
const ids = (arr) =>
Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
+ const mustInc = []
+ const mustExc = []
+ for (const r of filters.focus_rules || []) {
+ const id = Number(r.focus_area_id)
+ if (!Number.isFinite(id) || id < 1) continue
+ if (r.mode === 'forbid') mustExc.push(id)
+ else mustInc.push(id)
+ }
+ const uniqNums = (arr) => [...new Set(arr)]
+ if (mustInc.length) q.focus_area_must_include_ids = uniqNums(mustInc)
+ if (mustExc.length) q.focus_area_must_exclude_ids = uniqNums(mustExc)
+ if (filters.focus_only_without) q.focus_only_without_focus_areas = true
+
const fa = ids(filters.focus_area_ids)
if (fa?.length) q.focus_area_ids = fa
const sd = ids(filters.style_direction_ids)
@@ -858,22 +894,21 @@ function ExercisesListPage() {
- Zwischen den Bereichen gilt UND . Innerhalb eines Feldes werden mehrere Einträge mit{' '}
- ODER verknüpft.
+ Zwischen den Bereichen gilt UND . Fokusbereiche: mehrere „+ mit“ bedeuten alle müssen
+ gesetzt sein; „− ohne“ schließt Übungen aus, die diesen Fokus zusätzlich haben. Stilrichtung /
+ Trainingsstil / Zielgruppe: mehrere Werte pro Feld mit ODER .
Zuordnung
-
-
- Fokus
- setFilters({ ...filters, focus_area_ids: v })}
- options={focusOptions}
- placeholder="Fokus suchen oder „▼ Alle“ …"
- />
-
+
setFilters((prev) => ({ ...prev, ...patch }))}
+ />
+
Stilrichtung
-
+
setFilters({ ...filters, exclude_without_focus: e.target.checked })}
+ onChange={(e) =>
+ setFilters((prev) => ({
+ ...prev,
+ exclude_without_focus: e.target.checked,
+ ...(e.target.checked ? { focus_only_without: false } : {}),
+ }))
+ }
/>
Übungen ohne Fokusbereich ausblenden
diff --git a/frontend/src/version.js b/frontend/src/version.js
index ec76d77..bc8f7cd 100644
--- a/frontend/src/version.js
+++ b/frontend/src/version.js
@@ -1,13 +1,13 @@
// Shinkan Jinkendo Frontend Version
-export const APP_VERSION = "0.8.39"
+export const APP_VERSION = "0.8.40"
export const BUILD_DATE = "2026-05-06"
export const PAGE_VERSIONS = {
LoginPage: "1.0.0",
Dashboard: "1.0.0",
AccountSettingsPage: "1.0.0",
- ExercisesPage: "1.4.0", // Negativfilter, Archiv-Standard, gespeicherte Standardfilter (exercise_list_prefs); Löschen-UX
+ ExercisesPage: "1.5.0", // Fokus +/- Regeln, nur ohne Fokusbereich; Filterprefs
ClubsPage: "1.1.0",
SkillsPage: "1.0.0",
TrainingPlanningPage: "1.4.0",