chore(version): update version and changelog for release 0.8.115
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 1m28s

- 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.
This commit is contained in:
Lars 2026-05-14 08:24:47 +02:00
parent 14cf8a1a53
commit 789b640ad0
7 changed files with 161 additions and 18 deletions

View File

@ -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 (Tiebreak 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()

View File

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

View File

@ -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",

View File

@ -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 **4ag** — u.a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).

View File

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

View File

@ -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')

View File

@ -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 {