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.
"""
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)

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

View File

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

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.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}` : ''}`)
}