Bug Fixing Kombi-Übungen - Performance Update 1 (Phase 0-2) #33
|
|
@ -9,6 +9,7 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple
|
from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple
|
||||||
from urllib.parse import quote
|
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")
|
@router.get("/exercises")
|
||||||
def list_exercises(
|
def list_exercises(
|
||||||
focus_area_ids: list[int] = Query(default=[], description="ODER: mind. einer dieser Fokusbereiche"),
|
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),
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
offset: int = Query(default=0, ge=0),
|
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(
|
include_variants: bool = Query(
|
||||||
default=False,
|
default=False,
|
||||||
description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI",
|
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.
|
Liste aller Übungen mit Filtern.
|
||||||
Lightweight Response (ohne M:N Details, nur IDs und Namen).
|
Lightweight Response (ohne M:N Details, nur IDs und Namen).
|
||||||
Optional include_variants für Variantenauswahl in der Trainingsplanung.
|
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
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
|
@ -1981,6 +2022,12 @@ def list_exercises(
|
||||||
where.append("e.search_vector @@ plainto_tsquery('german', %s)")
|
where.append("e.search_vector @@ plainto_tsquery('german', %s)")
|
||||||
params.append(qtext)
|
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 = ""
|
variants_sql = ""
|
||||||
if include_variants:
|
if include_variants:
|
||||||
variants_sql = """,
|
variants_sql = """,
|
||||||
|
|
@ -2046,10 +2093,10 @@ def list_exercises(
|
||||||
LEFT JOIN profiles p ON e.created_by = p.id
|
LEFT JOIN profiles p ON e.created_by = p.id
|
||||||
LEFT JOIN clubs c ON e.club_id = c.id
|
LEFT JOIN clubs c ON e.club_id = c.id
|
||||||
WHERE {' AND '.join(where)}
|
WHERE {' AND '.join(where)}
|
||||||
ORDER BY e.updated_at DESC
|
ORDER BY e.updated_at DESC, e.id DESC
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
"""
|
"""
|
||||||
params.extend([limit, offset])
|
params.extend([limit, 0 if use_keyset else offset])
|
||||||
|
|
||||||
cur.execute(query, params)
|
cur.execute(query, params)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
|
|
|
||||||
82
backend/tests/test_exercises_list_keyset.py
Normal file
82
backend/tests/test_exercises_list_keyset.py
Normal 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
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.114"
|
APP_VERSION = "0.8.115"
|
||||||
BUILD_DATE = "2026-05-12"
|
BUILD_DATE = "2026-05-12"
|
||||||
DB_SCHEMA_VERSION = "20260514060"
|
DB_SCHEMA_VERSION = "20260514060"
|
||||||
|
|
||||||
|
|
@ -21,7 +21,7 @@ MODULE_VERSIONS = {
|
||||||
"groups": "0.1.0",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.0",
|
"skills": "0.1.0",
|
||||||
"methods": "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_units": "0.2.0",
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.9.3", # GET training-units/:id Sektions-Items: combination_slots + Kandidaten-Titel für Druck/Run
|
"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 = [
|
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",
|
"version": "0.8.114",
|
||||||
"date": "2026-05-14",
|
"date": "2026-05-14",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||||
|
|
||||||
**Stand:** 2026-05-14
|
**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**.
|
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`**.
|
- **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`.
|
- **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).
|
- **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).
|
- **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).
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
| Summary-API finalisieren/erweitern falls in P1 nur Teilbereich | B1 |
|
| Summary-API finalisieren/erweitern falls in P1 nur Teilbereich | B1 |
|
||||||
| Optional: erste Keyset-Pagination für eine Liste mit bekanntem Sort-Key | B3 |
|
| 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.
|
**Abnahme:** p95 der optimierten Routen **verbessert** ggü. Phase 0 oder dokumentierte Obergrenze eingehalten.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@ export default function ExercisePickerModal({
|
||||||
const [list, setList] = useState([])
|
const [list, setList] = useState([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
const [offset, setOffset] = useState(0)
|
|
||||||
const [hasMore, setHasMore] = useState(false)
|
const [hasMore, setHasMore] = useState(false)
|
||||||
const [multiPicked, setMultiPicked] = useState([])
|
const [multiPicked, setMultiPicked] = useState([])
|
||||||
const [quickOpen, setQuickOpen] = useState(false)
|
const [quickOpen, setQuickOpen] = useState(false)
|
||||||
|
|
@ -118,7 +117,6 @@ export default function ExercisePickerModal({
|
||||||
setFilters({ ...INITIAL_FILTERS })
|
setFilters({ ...INITIAL_FILTERS })
|
||||||
setFilterOpen(false)
|
setFilterOpen(false)
|
||||||
setList([])
|
setList([])
|
||||||
setOffset(0)
|
|
||||||
setHasMore(false)
|
setHasMore(false)
|
||||||
setMultiPicked([])
|
setMultiPicked([])
|
||||||
setQuickOpen(false)
|
setQuickOpen(false)
|
||||||
|
|
@ -227,7 +225,6 @@ export default function ExercisePickerModal({
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
if (!open || !catalogsReady) return
|
if (!open || !catalogsReady) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setOffset(0)
|
|
||||||
try {
|
try {
|
||||||
const batch = await api.listExercises({
|
const batch = await api.listExercises({
|
||||||
...queryBase,
|
...queryBase,
|
||||||
|
|
@ -238,7 +235,6 @@ export default function ExercisePickerModal({
|
||||||
})
|
})
|
||||||
setList(Array.isArray(batch) ? batch : [])
|
setList(Array.isArray(batch) ? batch : [])
|
||||||
setHasMore(batch?.length === PAGE_SIZE)
|
setHasMore(batch?.length === PAGE_SIZE)
|
||||||
setOffset(batch?.length ?? 0)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
alert(e.message || 'Laden fehlgeschlagen')
|
alert(e.message || 'Laden fehlgeschlagen')
|
||||||
|
|
@ -255,6 +251,8 @@ export default function ExercisePickerModal({
|
||||||
|
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
if (!hasMore || loadingMore || loading) return
|
if (!hasMore || loadingMore || loading) return
|
||||||
|
const last = list[list.length - 1]
|
||||||
|
if (!last?.id || last.updated_at == null) return
|
||||||
setLoadingMore(true)
|
setLoadingMore(true)
|
||||||
try {
|
try {
|
||||||
const batch = await api.listExercises({
|
const batch = await api.listExercises({
|
||||||
|
|
@ -262,11 +260,14 @@ export default function ExercisePickerModal({
|
||||||
include_archived: true,
|
include_archived: true,
|
||||||
include_variants: true,
|
include_variants: true,
|
||||||
limit: PAGE_SIZE,
|
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 : [])])
|
setList((prev) => [...prev, ...(Array.isArray(batch) ? batch : [])])
|
||||||
setHasMore(batch?.length === PAGE_SIZE)
|
setHasMore(batch?.length === PAGE_SIZE)
|
||||||
setOffset((o) => o + (batch?.length ?? 0))
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
alert(e.message || 'Mehr laden fehlgeschlagen')
|
alert(e.message || 'Mehr laden fehlgeschlagen')
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,6 @@ function ExercisesListPage() {
|
||||||
const [catalogsReady, setCatalogsReady] = useState(false)
|
const [catalogsReady, setCatalogsReady] = useState(false)
|
||||||
const [listFetching, setListFetching] = useState(false)
|
const [listFetching, setListFetching] = useState(false)
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
const [offset, setOffset] = useState(0)
|
|
||||||
const [hasMore, setHasMore] = useState(false)
|
const [hasMore, setHasMore] = useState(false)
|
||||||
const [searchInput, setSearchInput] = useState('')
|
const [searchInput, setSearchInput] = useState('')
|
||||||
const [aiSearchInput, setAiSearchInput] = useState('')
|
const [aiSearchInput, setAiSearchInput] = useState('')
|
||||||
|
|
@ -604,13 +603,11 @@ function ExercisesListPage() {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
setListFetching(true)
|
setListFetching(true)
|
||||||
setOffset(0)
|
|
||||||
try {
|
try {
|
||||||
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 })
|
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 })
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setExercises(batch)
|
setExercises(batch)
|
||||||
setHasMore(batch.length === PAGE_SIZE)
|
setHasMore(batch.length === PAGE_SIZE)
|
||||||
setOffset(batch.length)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
console.error('Failed to load data:', err)
|
console.error('Failed to load data:', err)
|
||||||
|
|
@ -628,12 +625,21 @@ function ExercisesListPage() {
|
||||||
|
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
if (loadingMore || !hasMore) return
|
if (loadingMore || !hasMore) return
|
||||||
|
const last = exercises[exercises.length - 1]
|
||||||
|
if (!last?.id || last.updated_at == null) return
|
||||||
setLoadingMore(true)
|
setLoadingMore(true)
|
||||||
try {
|
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])
|
setExercises((prev) => [...prev, ...batch])
|
||||||
setHasMore(batch.length === PAGE_SIZE)
|
setHasMore(batch.length === PAGE_SIZE)
|
||||||
setOffset((o) => o + batch.length)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Fehler: ' + err.message)
|
alert('Fehler: ' + err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user