diff --git a/backend/migrations/061_training_units_keyset_indexes.sql b/backend/migrations/061_training_units_keyset_indexes.sql new file mode 100644 index 0000000..98dba11 --- /dev/null +++ b/backend/migrations/061_training_units_keyset_indexes.sql @@ -0,0 +1,22 @@ +-- GET /api/training-units: Keyset über (planned_date, planned_time_start NULLS LAST per Sort, id) +-- Ersetzt den reinen Datum/Uhrzeit-Teilindex 059 durch zwei Richtungen mit Tie-Break id. + +DROP INDEX IF EXISTS idx_training_units_scheduled_order; + +CREATE INDEX IF NOT EXISTS idx_training_units_list_keyset_desc +ON training_units ( + planned_date DESC, + (planned_time_start IS NULL) ASC, + planned_time_start DESC NULLS LAST, + id DESC +) +WHERE framework_slot_id IS NULL; + +CREATE INDEX IF NOT EXISTS idx_training_units_list_keyset_asc +ON training_units ( + planned_date ASC, + (planned_time_start IS NULL) ASC, + planned_time_start ASC NULLS LAST, + id ASC +) +WHERE framework_slot_id IS NULL; diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 49e492f..d2c635b 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -4,8 +4,8 @@ und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung). Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin. """ -from datetime import date, timedelta -from typing import Any, Dict, List, Optional +from datetime import date, datetime, time as dt_time, timedelta +from typing import Any, Dict, List, Optional, Tuple from fastapi import APIRouter, Depends, HTTPException, Query from psycopg2.extras import Json as PsycopgJson @@ -42,6 +42,78 @@ def _optional_positive_int(val, field_name: str) -> Optional[int]: return i +def _parse_cursor_planned_date(raw: Optional[str]) -> date: + s = (raw or "").strip() + if not s: + raise HTTPException(status_code=400, detail="cursor_planned_date ungültig (YYYY-MM-DD)") + try: + return date.fromisoformat(s[:10]) + except ValueError: + raise HTTPException(status_code=400, detail="cursor_planned_date ungültig (YYYY-MM-DD)") + + +def _parse_cursor_planned_time_optional(raw: Optional[str]) -> Optional[dt_time]: + s = (raw or "").strip() + if not s: + return None + for fmt in ("%H:%M:%S", "%H:%M"): + try: + return datetime.strptime(s, fmt).time() + except ValueError: + continue + raise HTTPException( + status_code=400, + detail="cursor_planned_time ungültig (HH:MM oder HH:MM:SS)", + ) + + +def _training_units_keyset_sql( + order_dir: str, + cursor_date: date, + cursor_time_null: bool, + cursor_time: Optional[dt_time], + cursor_id: int, +) -> Tuple[str, List[Any]]: + """WHERE-Zusatz für Keyset; sort=asc|desc muss zu order_dir passen.""" + d = cursor_date + cid = cursor_id + if order_dir == "ASC": + if cursor_time_null: + frag = ( + "(tu.planned_date > %s OR (tu.planned_date = %s AND " + "tu.planned_time_start IS NULL AND tu.id > %s))" + ) + return frag, [d, d, cid] + assert cursor_time is not None + ct = cursor_time + frag = ( + "(tu.planned_date > %s OR (tu.planned_date = %s AND (" + "(tu.planned_time_start IS NOT NULL AND (tu.planned_time_start > %s OR " + "(tu.planned_time_start = %s AND tu.id > %s))) OR " + "(tu.planned_time_start IS NULL)" + ")))" + ) + return frag, [d, d, ct, ct, cid] + if order_dir == "DESC": + if cursor_time_null: + frag = ( + "(tu.planned_date < %s OR (tu.planned_date = %s AND " + "tu.planned_time_start IS NULL AND tu.id < %s))" + ) + return frag, [d, d, cid] + assert cursor_time is not None + ct = cursor_time + frag = ( + "(tu.planned_date < %s OR (tu.planned_date = %s AND (" + "(tu.planned_time_start IS NOT NULL AND (tu.planned_time_start < %s OR " + "(tu.planned_time_start = %s AND tu.id < %s))) OR " + "(tu.planned_time_start IS NULL)" + ")))" + ) + return frag, [d, d, ct, ct, cid] + raise HTTPException(status_code=400, detail="sort: nur asc oder desc") + + def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id: Optional[int]): if not exercise_id: if variant_id: @@ -1254,6 +1326,19 @@ def list_training_units( ), sort: str = Query(default="desc"), limit: Optional[int] = Query(default=None), + cursor_planned_date: Optional[str] = Query( + default=None, + description="Keyset: YYYY-MM-DD der letzten Zeile (mit cursor_id)", + ), + cursor_planned_time: Optional[str] = Query( + default=None, + description="Keyset: HH:MM oder HH:MM:SS; weglassen/leer wenn planned_time_start NULL", + ), + cursor_id: Optional[int] = Query( + default=None, + ge=1, + description="Keyset: id der letzten Zeile (mit cursor_planned_date)", + ), tenant: TenantContext = Depends(get_tenant_context), ): profile_id = tenant.profile_id @@ -1264,6 +1349,40 @@ def list_training_units( if gid and cid: raise HTTPException(status_code=400, detail="Nur eines der Parameter group_id oder club_id angeben") + order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC" + lim: Optional[int] = None + if limit is not None: + try: + lim = int(limit) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="limit ungültig") + if lim < 1: + raise HTTPException(status_code=400, detail="limit ungültig") + lim = min(lim, 250) + + c_id_q = cursor_id + c_date_raw = (cursor_planned_date or "").strip() or None + time_nonempty = (cursor_planned_time or "").strip() != "" + has_cursor_partial = ( + (c_id_q is not None) != (c_date_raw is not None) or (time_nonempty and c_id_q is None) + ) + if has_cursor_partial: + raise HTTPException( + status_code=400, + detail="cursor_planned_date und cursor_id müssen zusammen gesetzt werden", + ) + use_keyset = c_id_q is not None + if use_keyset and lim is None: + raise HTTPException(status_code=400, detail="Keyset: Parameter limit ist erforderlich") + cursor_d: Optional[date] = None + cursor_t: Optional[dt_time] = None + cursor_t_null = False + if use_keyset: + assert c_id_q is not None and c_date_raw is not None + cursor_d = _parse_cursor_planned_date(c_date_raw) + cursor_t = _parse_cursor_planned_time_optional(cursor_planned_time) + cursor_t_null = cursor_t is None + with get_db() as conn: cur = get_cursor(conn) @@ -1286,17 +1405,6 @@ def list_training_units( if not (ok_staff or ok_org or ok_member): raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe") - order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC" - lim: Optional[int] = None - if limit is not None: - try: - lim = int(limit) - except (TypeError, ValueError): - raise HTTPException(status_code=400, detail="limit ungültig") - if lim < 1: - raise HTTPException(status_code=400, detail="limit ungültig") - lim = min(lim, 250) - query = """ SELECT tu.*, tg.name as group_name, @@ -1379,10 +1487,25 @@ def list_training_units( where.append("tu.status = %s") params.append(status) + if use_keyset: + assert cursor_d is not None and c_id_q is not None + ks_sql, ks_params = _training_units_keyset_sql( + order_dir, + cursor_d, + cursor_t_null, + cursor_t, + int(c_id_q), + ) + where.append(ks_sql) + params.extend(ks_params) + if where: query += " WHERE " + " AND ".join(where) - query += f" ORDER BY tu.planned_date {order_dir}, tu.planned_time_start {order_dir} NULLS LAST" + query += ( + f" ORDER BY tu.planned_date {order_dir}, (tu.planned_time_start IS NULL) ASC, " + f"tu.planned_time_start {order_dir} NULLS LAST, tu.id {order_dir}" + ) if lim is not None: query += " LIMIT %s" params.append(lim) diff --git a/backend/tests/test_training_units_list_keyset.py b/backend/tests/test_training_units_list_keyset.py new file mode 100644 index 0000000..0a5257a --- /dev/null +++ b/backend/tests/test_training_units_list_keyset.py @@ -0,0 +1,108 @@ +"""GET /api/training-units: 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 _tenant() -> TenantContext: + return TenantContext( + profile_id=1, + global_role="trainer", + effective_club_id=None, + club_ids=frozenset(), + memberships=[], + ) + + +def test_list_training_units_keyset_incomplete_returns_400(client: TestClient) -> None: + app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"} + app.dependency_overrides[get_tenant_context] = _tenant + r = client.get( + "/api/training-units", + params={"cursor_id": "42"}, + headers={"X-Auth-Token": "test"}, + ) + assert r.status_code == 400 + assert "cursor_planned_date" in r.json().get("detail", "").lower() + + +def test_list_training_units_keyset_without_limit_returns_400(client: TestClient) -> None: + app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"} + app.dependency_overrides[get_tenant_context] = _tenant + r = client.get( + "/api/training-units", + params={ + "cursor_id": "1", + "cursor_planned_date": "2026-05-10", + }, + headers={"X-Auth-Token": "test"}, + ) + assert r.status_code == 400 + assert "limit" in r.json().get("detail", "").lower() + + +def test_list_training_units_keyset_bad_date_returns_400(client: TestClient) -> None: + app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"} + app.dependency_overrides[get_tenant_context] = _tenant + r = client.get( + "/api/training-units", + params={ + "cursor_id": "1", + "cursor_planned_date": "not-a-date", + "limit": "10", + }, + headers={"X-Auth-Token": "test"}, + ) + assert r.status_code == 400 + + +def test_list_training_units_keyset_bad_time_returns_400(client: TestClient) -> None: + app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"} + app.dependency_overrides[get_tenant_context] = _tenant + r = client.get( + "/api/training-units", + params={ + "cursor_id": "1", + "cursor_planned_date": "2026-05-10", + "cursor_planned_time": "25:99", + "limit": "10", + }, + headers={"X-Auth-Token": "test"}, + ) + assert r.status_code == 400 + assert "cursor_planned_time" in r.json().get("detail", "").lower() + + +def test_list_training_units_keyset_time_without_id_returns_400(client: TestClient) -> None: + app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"} + app.dependency_overrides[get_tenant_context] = _tenant + r = client.get( + "/api/training-units", + params={ + "cursor_planned_time": "18:00", + "limit": "10", + }, + headers={"X-Auth-Token": "test"}, + ) + assert r.status_code == 400 diff --git a/backend/version.py b/backend/version.py index 084702b..f383ffb 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.116" +APP_VERSION = "0.8.117" BUILD_DATE = "2026-05-12" -DB_SCHEMA_VERSION = "20260514060" +DB_SCHEMA_VERSION = "20260514061" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -22,9 +22,9 @@ MODULE_VERSIONS = { "skills": "0.1.0", "methods": "0.1.0", "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.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak "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.4", # list_training_units: Keyset-Pagination + stabile Sortierung (NULLS LAST + id) "dashboard": "1.0.0", # GET /api/dashboard/kpis — Aggregat Entwürfe / meine Übungen / YTD completed "training_modules": "1.0.0", "import_wiki": "1.0.0", @@ -36,6 +36,15 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.117", + "date": "2026-05-14", + "changes": [ + "GET /api/training-units: optionale Keyset-Pagination (cursor_planned_date YYYY-MM-DD, cursor_id, optional cursor_planned_time bei gesetzter Startzeit; bei Keyset ist limit erforderlich). Sortierung um stabile Tie-Breaks ergänzt: (planned_time_start IS NULL), id.", + "Migration 061: Teilindizes training_units für ASC/DESC-Keyset inkl. id (ersetzt idx_training_units_scheduled_order).", + "frontend api.listTrainingUnits: Query-Parameter für Cursor durchreichen.", + ], + }, { "version": "0.8.116", "date": "2026-05-14", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 91a18df..47f0b0d 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.116**, DB-Schema **`20260514060`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`) +**App-Version / DB-Schema:** App **0.8.117**, DB-Schema **`20260514061`** (`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.116**) +### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.117**) - **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 1be7704..c69844a 100644 --- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md +++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md @@ -7,7 +7,8 @@ - **Phase 1 (Teil):** Org-Inbox: **ein** gemeinsamer Ladepfad `fetchOrgInboxSnapshot` für Mount-`useEffect` und `refreshOrgInbox` (gleiche Requests, weniger Drift-Risiko; Verhalten unverändert). - **Phase 1 / 2 (Teil):** Dashboard-KPIs: **`GET /api/dashboard/kpis`** (ein Roundtrip); Playwright-Test 8 angepasst. - **Phase 2 (Teil):** Listen-Indizes **058** (`exercises` Sortierung/`created_by`) und **059** (`training_units` Kalenderliste ohne Blueprint). -- **Offen Phase 1:** Inbox nur noch Feinschliff (TTL); **verzögertes Erstlade** per Idle (weniger parallele Requests beim Dashboard-Start). +- **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget. +- **Phase 1:** **verzögertes Erstlade** Org-Inbox per Idle ist umgesetzt. **Ziel:** Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**. **Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md). @@ -64,7 +65,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); **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). +**Teil erledigt (2026-05-14):** Migration **058** (`exercises`: globale `updated_at`-Sortierung / `created_by`+`updated_at`), **059** (Teilindex Kalenderliste; wird durch **061** ersetzt/erweitert), **060** (`exercises`: Partial-Indizes `official`/`club` inkl. Archiv-Filter; Junction `is_primary` für List-Subqueries); **061** (`training_units`: zwei Teilindizes ASC/DESC inkl. `id` für Keyset); **Keyset** für `GET /api/exercises` (`cursor_updated_at` + `cursor_id`, UI „Mehr laden“ in Liste + Picker) und **Keyset** für `GET /api/training-units` (`cursor_planned_date` + `cursor_id`, optional `cursor_planned_time`; bei Keyset ist `limit` Pflicht). Rest: `EXPLAIN` unter Produktionsvolumen, Fähigkeits-Level-Filter nur bei Bedarf (ggf. Ausdrucks-Index); Frontend-„Mehr laden“ für lange Trainingslisten (Planung) optional. **Abnahme:** p95 der optimierten Routen **verbessert** ggü. Phase 0 oder dokumentierte Obergrenze eingehalten. diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index b58f622..5a46420 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -1348,6 +1348,11 @@ export async function listTrainingUnits(filters = {}) { if (filters.assigned_to_me === true) q.set('assigned_to_me', 'true') if (filters.sort) q.set('sort', String(filters.sort)) if (filters.limit != null && filters.limit !== '') q.set('limit', String(filters.limit)) + if (filters.cursor_planned_date) q.set('cursor_planned_date', String(filters.cursor_planned_date)) + if (filters.cursor_planned_time != null && filters.cursor_planned_time !== '') { + q.set('cursor_planned_time', String(filters.cursor_planned_time)) + } + if (filters.cursor_id != null && filters.cursor_id !== '') q.set('cursor_id', String(filters.cursor_id)) const qs = q.toString() return request(`/api/training-units${qs ? `?${qs}` : ''}`) }