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

- 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:
Lars 2026-05-14 08:44:59 +02:00
parent 657fcc241a
commit 32ba008660
7 changed files with 290 additions and 22 deletions

View 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;

View File

@ -4,8 +4,8 @@ und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung).
Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin. Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin.
""" """
from datetime import date, timedelta from datetime import date, datetime, time as dt_time, timedelta
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from psycopg2.extras import Json as PsycopgJson from psycopg2.extras import Json as PsycopgJson
@ -42,6 +42,78 @@ def _optional_positive_int(val, field_name: str) -> Optional[int]:
return i 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]): def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id: Optional[int]):
if not exercise_id: if not exercise_id:
if variant_id: if variant_id:
@ -1254,6 +1326,19 @@ def list_training_units(
), ),
sort: str = Query(default="desc"), sort: str = Query(default="desc"),
limit: Optional[int] = Query(default=None), 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), tenant: TenantContext = Depends(get_tenant_context),
): ):
profile_id = tenant.profile_id profile_id = tenant.profile_id
@ -1264,6 +1349,40 @@ def list_training_units(
if gid and cid: if gid and cid:
raise HTTPException(status_code=400, detail="Nur eines der Parameter group_id oder club_id angeben") 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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -1286,17 +1405,6 @@ def list_training_units(
if not (ok_staff or ok_org or ok_member): if not (ok_staff or ok_org or ok_member):
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe") 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 = """ query = """
SELECT tu.*, SELECT tu.*,
tg.name as group_name, tg.name as group_name,
@ -1379,10 +1487,25 @@ def list_training_units(
where.append("tu.status = %s") where.append("tu.status = %s")
params.append(status) 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: if where:
query += " WHERE " + " AND ".join(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: if lim is not None:
query += " LIMIT %s" query += " LIMIT %s"
params.append(lim) params.append(lim)

View 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

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.116" APP_VERSION = "0.8.117"
BUILD_DATE = "2026-05-12" BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260514060" DB_SCHEMA_VERSION = "20260514061"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -22,9 +22,9 @@ MODULE_VERSIONS = {
"skills": "0.1.0", "skills": "0.1.0",
"methods": "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 "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", "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 "dashboard": "1.0.0", # GET /api/dashboard/kpis — Aggregat Entwürfe / meine Übungen / YTD completed
"training_modules": "1.0.0", "training_modules": "1.0.0",
"import_wiki": "1.0.0", "import_wiki": "1.0.0",
@ -36,6 +36,15 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.116",
"date": "2026-05-14", "date": "2026-05-14",

View File

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

@ -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 (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 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). - **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**. **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). **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 | | 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); **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. **Abnahme:** p95 der optimierten Routen **verbessert** ggü. Phase 0 oder dokumentierte Obergrenze eingehalten.

View File

@ -1348,6 +1348,11 @@ export async function listTrainingUnits(filters = {}) {
if (filters.assigned_to_me === true) q.set('assigned_to_me', 'true') if (filters.assigned_to_me === true) q.set('assigned_to_me', 'true')
if (filters.sort) q.set('sort', String(filters.sort)) if (filters.sort) q.set('sort', String(filters.sort))
if (filters.limit != null && filters.limit !== '') q.set('limit', String(filters.limit)) 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() const qs = q.toString()
return request(`/api/training-units${qs ? `?${qs}` : ''}`) return request(`/api/training-units${qs ? `?${qs}` : ''}`)
} }