From 518918a6e52df12e2f136417a7d8ce409562b309 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 6 May 2026 17:15:44 +0200 Subject: [PATCH] feat: update version and enhance exercise filtering features - Bumped application version to 0.8.40 and updated module versions accordingly. - Introduced new focus area filtering options in the ExercisesListPage, allowing users to include or exclude exercises based on specified focus areas. - Added utility functions for deduplicating and merging focus area IDs to improve filtering logic. - Enhanced the ExercisePickerModal and ExercisesListPage components to support new focus rules and improve user experience with focus area selections. --- backend/routers/exercises.py | 85 ++++++++-- backend/version.py | 11 +- .../components/ExerciseFocusRulePicker.jsx | 145 ++++++++++++++++++ .../src/components/ExercisePickerModal.jsx | 54 +++++-- frontend/src/constants/exerciseListFilters.js | 19 +++ frontend/src/pages/ExercisesListPage.jsx | 80 ++++++++-- frontend/src/version.js | 4 +- 7 files changed, 357 insertions(+), 41 deletions(-) create mode 100644 frontend/src/components/ExerciseFocusRulePicker.jsx 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 ( +
+ + + {!focusOnlyWithout ? ( + <> + {legacyWarning ? ( +

+ Es sind noch ältere Fokusfilter (ODER-Liste) aktiv — bitte über die Chips entfernen oder durch Regeln + ersetzen. +

+ ) : null} + +

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

+
+ + + +
+ {(focusRules || []).length > 0 ? ( + + ) : 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.

-
-
- - setFilters((f) => ({ ...f, focus_area_ids: v }))} - options={focusOptions} - placeholder="Fokus …" - /> -
+ setFilters((f) => ({ ...f, ...patch }))} + /> +
-

- 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

-
-
- - setFilters({ ...filters, focus_area_ids: v })} - options={focusOptions} - placeholder="Fokus suchen oder „▼ Alle“ …" - /> -
+ setFilters((prev) => ({ ...prev, ...patch }))} + /> +
-