chore(version): update version and changelog for release 0.8.117
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m2s
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m2s
- Bumped APP_VERSION to 0.8.117 and updated DB_SCHEMA_VERSION to 20260514061. - Enhanced the training units API with optional keyset pagination, allowing for more efficient data retrieval. - Updated the changelog to reflect the new features and improvements, including changes to the frontend API integration for training units. - Adjusted documentation to align with the new app version and its corresponding changes.
This commit is contained in:
parent
657fcc241a
commit
32ba008660
22
backend/migrations/061_training_units_keyset_indexes.sql
Normal file
22
backend/migrations/061_training_units_keyset_indexes.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
108
backend/tests/test_training_units_list_keyset.py
Normal file
108
backend/tests/test_training_units_list_keyset.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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}` : ''}`)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user