From 789b640ad06cb623408df1d47f0f52b38b20f004 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 14 May 2026 08:24:47 +0200 Subject: [PATCH] chore(version): update version and changelog for release 0.8.115 - Bumped APP_VERSION to 0.8.115 and updated the changelog to reflect changes, including the introduction of keyset pagination for the GET /api/exercises endpoint. - Enhanced the exercises router to support cursor-based pagination using cursor_updated_at and cursor_id, improving performance and user experience. - Updated frontend components to utilize the new pagination method, removing offset-based loading logic. --- backend/routers/exercises.py | 51 +++++++++++- backend/tests/test_exercises_list_keyset.py | 82 +++++++++++++++++++ backend/version.py | 11 ++- docs/HANDOVER.md | 4 +- docs/architecture/UMSETZUNGSPLAN_ROADMAP.md | 2 +- .../src/components/ExercisePickerModal.jsx | 13 +-- frontend/src/pages/ExercisesListPage.jsx | 16 ++-- 7 files changed, 161 insertions(+), 18 deletions(-) create mode 100644 backend/tests/test_exercises_list_keyset.py diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 3a8d3ab..7613102 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -9,6 +9,7 @@ import json import logging import os import re +from datetime import datetime from pathlib import Path from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple from urllib.parse import quote @@ -1653,6 +1654,20 @@ def bulk_patch_exercises_metadata( } +def _parse_cursor_updated_at_list(raw: Optional[str]) -> datetime: + s = (raw or "").strip() + if not s: + raise HTTPException(status_code=400, detail="cursor_updated_at leer") + if s.endswith("Z"): + s = s[:-1] + "+00:00" + try: + return datetime.fromisoformat(s) + except ValueError: + raise HTTPException( + status_code=400, detail="cursor_updated_at ungültig (ISO-8601 erwartet)" + ) + + @router.get("/exercises") def list_exercises( focus_area_ids: list[int] = Query(default=[], description="ODER: mind. einer dieser Fokusbereiche"), @@ -1678,6 +1693,15 @@ def list_exercises( ), limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0), + cursor_updated_at: Optional[str] = Query( + default=None, + description="Keyset: ISO-8601 von updated_at der letzten Zeile; zusammen mit cursor_id (offset dann 0)", + ), + cursor_id: Optional[int] = Query( + default=None, + ge=1, + description="Keyset: id der letzten Zeile (Tie‑break bei gleichem updated_at); mit cursor_updated_at", + ), include_variants: bool = Query( default=False, description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI", @@ -1746,9 +1770,26 @@ def list_exercises( Liste aller Übungen mit Filtern. Lightweight Response (ohne M:N Details, nur IDs und Namen). Optional include_variants für Variantenauswahl in der Trainingsplanung. + Keyset: cursor_updated_at + cursor_id ersetzt große OFFSET-Werte (Sortierung: updated_at DESC, id DESC). """ profile_id = tenant.profile_id + c_ts_raw = (cursor_updated_at or "").strip() or None + use_keyset = c_ts_raw is not None and cursor_id is not None + if (c_ts_raw is not None) != (cursor_id is not None): + raise HTTPException( + status_code=400, + detail="cursor_updated_at und cursor_id müssen zusammen gesetzt werden", + ) + if use_keyset and offset != 0: + raise HTTPException( + status_code=400, + detail="Keyset-Pagination: offset nicht kombinieren (nur cursor_* oder nur offset)", + ) + cursor_ts_val: Optional[datetime] = None + if use_keyset: + cursor_ts_val = _parse_cursor_updated_at_list(c_ts_raw) + with get_db() as conn: cur = get_cursor(conn) @@ -1981,6 +2022,12 @@ def list_exercises( where.append("e.search_vector @@ plainto_tsquery('german', %s)") params.append(qtext) + if cursor_ts_val is not None and cursor_id is not None: + where.append( + "(e.updated_at < %s OR (e.updated_at = %s AND e.id < %s))" + ) + params.extend([cursor_ts_val, cursor_ts_val, cursor_id]) + variants_sql = "" if include_variants: variants_sql = """, @@ -2046,10 +2093,10 @@ def list_exercises( LEFT JOIN profiles p ON e.created_by = p.id LEFT JOIN clubs c ON e.club_id = c.id WHERE {' AND '.join(where)} - ORDER BY e.updated_at DESC + ORDER BY e.updated_at DESC, e.id DESC LIMIT %s OFFSET %s """ - params.extend([limit, offset]) + params.extend([limit, 0 if use_keyset else offset]) cur.execute(query, params) rows = cur.fetchall() diff --git a/backend/tests/test_exercises_list_keyset.py b/backend/tests/test_exercises_list_keyset.py new file mode 100644 index 0000000..1a4abf8 --- /dev/null +++ b/backend/tests/test_exercises_list_keyset.py @@ -0,0 +1,82 @@ +"""GET /api/exercises: Keyset-Parameter-Validierung (ohne DB-Zwang).""" +from __future__ import annotations + +import os + +import pytest +from fastapi.testclient import TestClient + +os.environ.setdefault("SKIP_DB_MIGRATE", "1") + +from auth import require_auth +from main import app +from tenant_context import TenantContext, get_tenant_context + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +@pytest.fixture(autouse=True) +def _clear_overrides() -> None: + yield + app.dependency_overrides.pop(require_auth, None) + app.dependency_overrides.pop(get_tenant_context, None) + + +def test_list_exercises_keyset_incomplete_returns_400(client: TestClient) -> None: + app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"} + app.dependency_overrides[get_tenant_context] = lambda: TenantContext( + profile_id=1, + global_role="trainer", + effective_club_id=None, + club_ids=frozenset(), + memberships=[], + ) + r = client.get( + "/api/exercises", + params={"cursor_id": "42"}, + headers={"X-Auth-Token": "test"}, + ) + assert r.status_code == 400 + assert "cursor_updated_at" in r.json().get("detail", "").lower() + + +def test_list_exercises_keyset_with_offset_returns_400(client: TestClient) -> None: + app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"} + app.dependency_overrides[get_tenant_context] = lambda: TenantContext( + profile_id=1, + global_role="trainer", + effective_club_id=None, + club_ids=frozenset(), + memberships=[], + ) + r = client.get( + "/api/exercises", + params={ + "cursor_id": "1", + "cursor_updated_at": "2026-01-01T12:00:00.000Z", + "offset": "10", + }, + headers={"X-Auth-Token": "test"}, + ) + assert r.status_code == 400 + assert "offset" in r.json().get("detail", "").lower() + + +def test_list_exercises_keyset_bad_timestamp_returns_400(client: TestClient) -> None: + app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"} + app.dependency_overrides[get_tenant_context] = lambda: TenantContext( + profile_id=1, + global_role="trainer", + effective_club_id=None, + club_ids=frozenset(), + memberships=[], + ) + r = client.get( + "/api/exercises", + params={"cursor_id": "1", "cursor_updated_at": "not-a-date"}, + headers={"X-Auth-Token": "test"}, + ) + assert r.status_code == 400 diff --git a/backend/version.py b/backend/version.py index 4996b24..c3e9e88 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.114" +APP_VERSION = "0.8.115" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260514060" @@ -21,7 +21,7 @@ MODULE_VERSIONS = { "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.27.4", # Migration 060: Listen-Skalierung (Partial + Junction is_primary) + "exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break "training_units": "0.2.0", "training_programs": "0.1.0", "planning": "0.9.3", # GET training-units/:id Sektions-Items: combination_slots + Kandidaten-Titel für Druck/Run @@ -36,6 +36,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.115", + "date": "2026-05-14", + "changes": [ + "GET /api/exercises: optionale Keyset-Pagination (cursor_updated_at ISO-8601 + cursor_id), stabile Sortierung updated_at DESC, id DESC; „Mehr laden“ in Übungsliste und Picker nutzt Keyset statt OFFSET.", + ], + }, { "version": "0.8.114", "date": "2026-05-14", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 3312965..93da39b 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,7 +1,7 @@ # Shinkan Jinkendo – Entwicklungsstand & Handover **Stand:** 2026-05-14 -**App-Version / DB-Schema:** App **0.8.114**, DB-Schema **`20260514060`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`) +**App-Version / DB-Schema:** App **0.8.115**, DB-Schema **`20260514060`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`) Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**. @@ -76,7 +76,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl - **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**. - **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`. -### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.114**) +### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.115**) - **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§ 10.2.1** IDs, **§ 10.4** Coaching-Stufen, **§ 10.6** Produkt-Backlog, **Anhang A** Abgleich). - **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4a–g** — u. a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung). diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md index e251af0..44fe8ad 100644 --- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md +++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md @@ -64,7 +64,7 @@ | Summary-API finalisieren/erweitern falls in P1 nur Teilbereich | B1 | | Optional: erste Keyset-Pagination für eine Liste mit bekanntem Sort-Key | B3 | -**Teil erledigt (2026-05-14):** Migration **058** (`exercises`: globale `updated_at`-Sortierung / `created_by`+`updated_at`), **059** (`training_units`: Kalenderliste ohne Blueprint), **060** (`exercises`: Partial-Indizes `official`/`club` inkl. Archiv-Filter; Junction `is_primary` für List-Subqueries). Rest: `EXPLAIN` unter Produktionsvolumen, Fähigkeits-Level-Filter nur bei Bedarf (ggf. Ausdrucks-Index). +**Teil erledigt (2026-05-14):** Migration **058** (`exercises`: globale `updated_at`-Sortierung / `created_by`+`updated_at`), **059** (`training_units`: Kalenderliste ohne Blueprint), **060** (`exercises`: Partial-Indizes `official`/`club` inkl. Archiv-Filter; Junction `is_primary` für List-Subqueries); **Keyset** für `GET /api/exercises` (`cursor_updated_at` + `cursor_id`, UI „Mehr laden“ in Liste + Picker). Rest: `EXPLAIN` unter Produktionsvolumen, Fähigkeits-Level-Filter nur bei Bedarf (ggf. Ausdrucks-Index). **Abnahme:** p95 der optimierten Routen **verbessert** ggü. Phase 0 oder dokumentierte Obergrenze eingehalten. diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index e93a896..952c232 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -53,7 +53,6 @@ export default function ExercisePickerModal({ const [list, setList] = useState([]) const [loading, setLoading] = useState(false) const [loadingMore, setLoadingMore] = useState(false) - const [offset, setOffset] = useState(0) const [hasMore, setHasMore] = useState(false) const [multiPicked, setMultiPicked] = useState([]) const [quickOpen, setQuickOpen] = useState(false) @@ -118,7 +117,6 @@ export default function ExercisePickerModal({ setFilters({ ...INITIAL_FILTERS }) setFilterOpen(false) setList([]) - setOffset(0) setHasMore(false) setMultiPicked([]) setQuickOpen(false) @@ -227,7 +225,6 @@ export default function ExercisePickerModal({ const reload = useCallback(async () => { if (!open || !catalogsReady) return setLoading(true) - setOffset(0) try { const batch = await api.listExercises({ ...queryBase, @@ -238,7 +235,6 @@ export default function ExercisePickerModal({ }) setList(Array.isArray(batch) ? batch : []) setHasMore(batch?.length === PAGE_SIZE) - setOffset(batch?.length ?? 0) } catch (e) { console.error(e) alert(e.message || 'Laden fehlgeschlagen') @@ -255,6 +251,8 @@ export default function ExercisePickerModal({ const loadMore = async () => { if (!hasMore || loadingMore || loading) return + const last = list[list.length - 1] + if (!last?.id || last.updated_at == null) return setLoadingMore(true) try { const batch = await api.listExercises({ @@ -262,11 +260,14 @@ export default function ExercisePickerModal({ include_archived: true, include_variants: true, limit: PAGE_SIZE, - offset, + cursor_updated_at: + typeof last.updated_at === 'string' + ? last.updated_at + : new Date(last.updated_at).toISOString(), + cursor_id: last.id, }) setList((prev) => [...prev, ...(Array.isArray(batch) ? batch : [])]) setHasMore(batch?.length === PAGE_SIZE) - setOffset((o) => o + (batch?.length ?? 0)) } catch (e) { console.error(e) alert(e.message || 'Mehr laden fehlgeschlagen') diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index de91e4d..456a955 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -177,7 +177,6 @@ function ExercisesListPage() { const [catalogsReady, setCatalogsReady] = useState(false) const [listFetching, setListFetching] = useState(false) const [loadingMore, setLoadingMore] = useState(false) - const [offset, setOffset] = useState(0) const [hasMore, setHasMore] = useState(false) const [searchInput, setSearchInput] = useState('') const [aiSearchInput, setAiSearchInput] = useState('') @@ -604,13 +603,11 @@ function ExercisesListPage() { let cancelled = false const run = async () => { setListFetching(true) - setOffset(0) try { const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 }) if (cancelled) return setExercises(batch) setHasMore(batch.length === PAGE_SIZE) - setOffset(batch.length) } catch (err) { if (!cancelled) { console.error('Failed to load data:', err) @@ -628,12 +625,21 @@ function ExercisesListPage() { const loadMore = async () => { if (loadingMore || !hasMore) return + const last = exercises[exercises.length - 1] + if (!last?.id || last.updated_at == null) return setLoadingMore(true) try { - const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset }) + const batch = await api.listExercises({ + ...queryBase, + limit: PAGE_SIZE, + cursor_updated_at: + typeof last.updated_at === 'string' + ? last.updated_at + : new Date(last.updated_at).toISOString(), + cursor_id: last.id, + }) setExercises((prev) => [...prev, ...batch]) setHasMore(batch.length === PAGE_SIZE) - setOffset((o) => o + batch.length) } catch (err) { alert('Fehler: ' + err.message) } finally {