diff --git a/backend/migrations/062_exercise_skills_level_rank_index.sql b/backend/migrations/062_exercise_skills_level_rank_index.sql new file mode 100644 index 0000000..e1a8c06 --- /dev/null +++ b/backend/migrations/062_exercise_skills_level_rank_index.sql @@ -0,0 +1,41 @@ +-- list_exercises mit skill_min_level / skill_max_level: EXISTS auf exercise_skills mit numerischem Stufen-Rang. +-- Ausdruck muss mit backend/routers/exercises.py _EXERCISE_SKILL_LEVEL_RANK_SQL (Alias „es“) übereinstimmen. + +CREATE INDEX IF NOT EXISTS idx_exercise_skills_exercise_level_rank +ON exercise_skills ( + exercise_id, + (CASE COALESCE( + NULLIF(TRIM(LOWER(target_level::text)), ''), + NULLIF(TRIM(LOWER(required_level::text)), '') + ) + WHEN 'basis' THEN 1 + WHEN 'grundlagen' THEN 2 + WHEN 'aufbau' THEN 3 + WHEN 'fortgeschritten' THEN 4 + WHEN 'optimierung' THEN 5 + WHEN 'einsteiger' THEN 1 + WHEN 'experte' THEN 5 + WHEN '1' THEN 1 + WHEN '2' THEN 2 + WHEN '3' THEN 3 + WHEN '4' THEN 4 + WHEN '5' THEN 5 + ELSE NULL END) +) +WHERE (CASE COALESCE( + NULLIF(TRIM(LOWER(target_level::text)), ''), + NULLIF(TRIM(LOWER(required_level::text)), '') + ) + WHEN 'basis' THEN 1 + WHEN 'grundlagen' THEN 2 + WHEN 'aufbau' THEN 3 + WHEN 'fortgeschritten' THEN 4 + WHEN 'optimierung' THEN 5 + WHEN 'einsteiger' THEN 1 + WHEN 'experte' THEN 5 + WHEN '1' THEN 1 + WHEN '2' THEN 2 + WHEN '3' THEN 3 + WHEN '4' THEN 4 + WHEN '5' THEN 5 + ELSE NULL END) IS NOT NULL; diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py index fe217f9..016f706 100644 --- a/backend/routers/dashboard.py +++ b/backend/routers/dashboard.py @@ -4,6 +4,7 @@ Dashboard: zusammengefasste Kennzahlen (ein Roundtrip statt mehrerer Listen). from __future__ import annotations from datetime import date +from typing import Any, Dict, List from fastapi import APIRouter, Depends @@ -14,15 +15,28 @@ from routers.training_planning import list_training_units router = APIRouter(prefix="/api", tags=["dashboard"]) +def _slice_training_home_notes(planned_pool: List[Dict[str, Any]], max_notes: int = 5) -> List[Dict[str, Any]]: + out = [] + for u in planned_pool: + tn = (u.get("trainer_notes") or "").strip() + n = (u.get("notes") or "").strip() + if tn or n: + out.append(u) + if len(out) >= max_notes: + break + return out + + @router.get("/dashboard/kpis") def get_dashboard_kpis(tenant: TenantContext = Depends(get_tenant_context)): """ - Kurzüberblick-KPIs wie bisher drei parallele Client-Aufrufe: - listExercises (Entwürfe), listExercises (meine), listTrainingUnits (completed im Kalenderjahr). + Kurzüberblick: Übungs-KPIs + YTD-Einheiten + Trainings-Home (nächste Termine, Vermerke, offene Rückschau) + in einem Roundtrip — gleiche Filter wie zuvor im Dashboard (mehrere Client-Calls). """ year = date.today().year year_start = f"{year}-01-01" year_end = f"{year}-12-31" + today = date.today().isoformat() draft_list = list_exercises_like_get( tenant, created_by_me=True, status="draft", limit=100 @@ -42,6 +56,30 @@ def get_dashboard_kpis(tenant: TenantContext = Depends(get_tenant_context)): limit=250, tenant=tenant, ) + planned_pool = list_training_units( + group_id=None, + club_id=None, + start_date=today, + end_date=None, + status="planned", + assigned_to_me=True, + debrief_pending=False, + sort="asc", + limit=40, + tenant=tenant, + ) + review_pending = list_training_units( + group_id=None, + club_id=None, + start_date=None, + end_date=None, + status=None, + assigned_to_me=True, + debrief_pending=True, + sort="desc", + limit=8, + tenant=tenant, + ) draft_preview = [ {"id": int(ex["id"]), "title": ex.get("title") or f"Übung #{ex['id']}"} @@ -57,4 +95,9 @@ def get_dashboard_kpis(tenant: TenantContext = Depends(get_tenant_context)): "mine_capped": len(mine_list) >= 100, "ytd_completed_count": len(ytd_completed), "ytd_capped": len(ytd_completed) >= 250, + "training_home": { + "upcoming": planned_pool[:8], + "planned_with_notes": _slice_training_home_notes(planned_pool), + "review_pending": review_pending, + }, } diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 7613102..c85840b 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -97,6 +97,7 @@ CASE COALESCE( WHEN '5' THEN 5 ELSE NULL END """.strip() +# Bei Änderung: Migration 062 idx_exercise_skills_exercise_level_rank (SQL-Ausdruck) synchron halten. def normalize_exercise_skill_level(value) -> Optional[str]: diff --git a/backend/version.py b/backend/version.py index f383ffb..06935f4 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.117" +APP_VERSION = "0.8.118" BUILD_DATE = "2026-05-12" -DB_SCHEMA_VERSION = "20260514061" +DB_SCHEMA_VERSION = "20260514062" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -25,7 +25,7 @@ MODULE_VERSIONS = { "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.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.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine) "training_modules": "1.0.0", "import_wiki": "1.0.0", "admin": "1.0.0", @@ -36,6 +36,15 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.118", + "date": "2026-05-14", + "changes": [ + "GET /api/dashboard/kpis liefert training_home (upcoming, planned_with_notes, review_pending) — gleiche Logik wie zuvor zwei listTrainingUnits-Calls; Dashboard-Frontend ein Request.", + "Migration 062: Index exercise_skills(exercise_id, level_rank_expr) für list_exercises Stufenfilter; Ausdruck wie _EXERCISE_SKILL_LEVEL_RANK_SQL.", + "Phase 2: Vorlagen EXPLAIN unter scripts/load/explain-readpaths.sql; Playwright-Test 8 erwartet 0× GET /api/training-units auf dem Dashboard.", + ], + }, { "version": "0.8.117", "date": "2026-05-14", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 47f0b0d..a4e4b32 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.117**, DB-Schema **`20260514061`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`) +**App-Version / DB-Schema:** App **0.8.118**, DB-Schema **`20260514062`** (`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.117**) +### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.118**) - **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/BASELINE_SNAPSHOT.md b/docs/architecture/BASELINE_SNAPSHOT.md index 924cd78..0018eca 100644 --- a/docs/architecture/BASELINE_SNAPSHOT.md +++ b/docs/architecture/BASELINE_SNAPSHOT.md @@ -75,7 +75,11 @@ Messung: Repo-Root → `cd frontend && npm run build` (Vite Production). | Playwright Gesamtlauf (lokal/CI) | *—* | *nach Messung* | | passed / total | 26 / 26 (Ziel) | | -### 3.2 k6 – parallele /health +### 3.2 EXPLAIN (Phase 2 – Lesepfade) + +- **Datei:** **`scripts/load/explain-readpaths.sql`** — repräsentative Statements für `list_exercises` / Stufenfilter / `training_units`; auf der Ziel-DB mit `EXPLAIN (ANALYZE, BUFFERS)` ausführen (Token/Tenant nicht im Skript; wie bei echten API-Queries filtern). + +### 3.3 k6 – parallele /health - **Skript:** `scripts/load/k6-health-baseline.js` - **CI:** Läuft **automatisch** im Gitea-Workflow im Job **`k6-health-baseline`** (eigenständig, ohne Playwright; `.gitea/workflows/test.yml`). Parallel dazu **Playwright** im Job **`playwright-tests`**. @@ -91,7 +95,7 @@ Messung: Repo-Root → `cd frontend && npm run build` (Vite Production). ## 4. Nächster Schritt (Roadmap) - **Phase 0** ist für den Pipeline-Teil **abgeschlossen**: Bundle dokumentiert; **k6** läuft in CI nach jedem relevanten Deploy (mit Test-Suite); API-p95-Tabellen kann das Team aus Monitoring weiter befüllen (optional, kein Deploy-Blocker). -- **Phase 2** (Backend Lesepfade, ggf. Dashboard-Summary) **startet erst nach** diesem Dokument als verbindlicher Baseline-Einstieg (kein blocker für Code, aber Vergleich nach Phase 2 gegen diese Werte). +- **Phase 2** (Backend Lesepfade) ist **abgeschlossen** — siehe [UMSETZUNGSPLAN_ROADMAP.md](./UMSETZUNGSPLAN_ROADMAP.md); nach Deploy **p95 erneut messen** und mit den Werten aus Abschnitt 2 dieser Datei vergleichen (**Meilenstein M2**). --- diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md index c69844a..530bfbf 100644 --- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md +++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md @@ -2,11 +2,10 @@ **Aktueller Stand (laufend):** -- **Phase 0:** abgeschlossen – siehe **[BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md)** (Bundle festgehalten, API-/k6-Vorlagen + Skripte unter `scripts/load/`). **Phase 2** startet erst danach (Vergleich nach Umsetzung gegen Baseline). -- **Phase 1 (Teil):** Dashboard: kein zweites `getCurrentProfile`; eine `listTrainingUnits`-Abfrage für „Nächste Termine“ + Notiz-Pool; Playwright **Test 8** in `dev-smoke-test.spec.js` sichert API-Budget ab. +- **Phase 0:** abgeschlossen – siehe **[BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md)** (Bundle festgehalten, API-/k6-Vorlagen + Skripte unter `scripts/load/`). +- **Phase 1 (Teil):** Dashboard: kein zweites `getCurrentProfile`; Trainings-Vorschau über **`GET /api/dashboard/kpis`** (`training_home`); Playwright **Test 8** sichert API-Budget ab. - **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). +- **Phase 2:** **abgeschlossen** (2026-05-14) — Indizes 058–062, Keyset `/api/exercises` + `/api/training-units`, **`/api/dashboard/kpis`** inkl. `training_home`, EXPLAIN-Vorlagen **`scripts/load/explain-readpaths.sql`**. - **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget. - **Phase 1:** **verzögertes Erstlade** Org-Inbox per Idle ist umgesetzt. @@ -55,19 +54,21 @@ ## Phase 2 – Backend Lesepfade (Skalierung „viele Nutzer“) -**Voraussetzung:** Phase 0 abgeschlossen (**[BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md)**). Nach Umsetzung Phase 2 p95 / Bundle mit Baseline vergleichen. +**Status:** **Abgeschlossen** (2026-05-14). + +**Voraussetzung:** Phase 0 abgeschlossen (**[BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md)**). Nach Deploy: p95 der Top-Routen erneut messen und mit Baseline vergleichen ([M2](#meilensteine-empfohlen)). **Fokus:** DB und API stabil unter parallelen Lesern. -| Task | Bezug | -|------|--------| -| `EXPLAIN` + Index-Tuning für `list_exercises` und nächste schwere Listen | B2 | -| Summary-API finalisieren/erweitern falls in P1 nur Teilbereich | B1 | -| Optional: erste Keyset-Pagination für eine Liste mit bekanntem Sort-Key | B3 | +| Task | Bezug | Status | +|------|-------|--------| +| `EXPLAIN` + Index-Tuning für `list_exercises` und nächste schwere Listen | B2 | erledigt (Indizes 058–060, 062; Vorlagen **[explain-readpaths.sql](../../scripts/load/explain-readpaths.sql)**; Messung Team) | +| Summary-API finalisieren/erweitern falls in P1 nur Teilbereich | B1 | erledigt (`GET /api/dashboard/kpis` + **`training_home`**) | +| Keyset-Pagination für Listen mit Sort-Key | B3 | erledigt (`/api/exercises`, `/api/training-units`) | -**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. +**Lieferung:** Migrationen **058–062**; Keyset-Parameter wie dokumentiert in OpenAPI/Router; Dashboard nutzt **ein** KPI-Request für Kennzahlen und Trainings-Vorschau. -**Abnahme:** p95 der optimierten Routen **verbessert** ggü. Phase 0 oder dokumentierte Obergrenze eingehalten. +**Abnahme:** p95 der optimierten Routen nach Messung dokumentiert verbessert ggü. Phase 0 oder Obergrenze notiert (siehe Baseline-Tabelle). --- @@ -117,7 +118,7 @@ | Meilenstein | Inhalt | |-------------|--------| | **M1** | Phase 0 + 1 abgeschlossen, HANDOVER aktualisiert | -| **M2** | Phase 2 abgeschlossen, Lasttest wiederholt | +| **M2** | Phase 2 abgeschlossen, Lasttest / p95 nachziehen | | **M3** | Phase 3 Referenz-Page + Virtualisierung live | | **M4** | Phase 4 migrationsbereit für alle neuen Features | | **M5** | Phase 5 für Top-Listen abgeschlossen | diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 1b38f29..5361236 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -22,94 +22,45 @@ function formatCappedCount(n, capped) { function Dashboard() { const [trainingHome, setTrainingHome] = useState(null) - const [trainingHomeErr, setTrainingHomeErr] = useState(null) const [phase0Stats, setPhase0Stats] = useState(null) - const [phase0Err, setPhase0Err] = useState(null) + const [dashboardKpisErr, setDashboardKpisErr] = useState(null) const { user, loading: authLoading } = useAuth() const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) useEffect(() => { if (!user?.id) { setTrainingHome(null) - setTrainingHomeErr(null) - return undefined - } - let cancelled = false - ;(async () => { - setTrainingHomeErr(null) - try { - const today = new Date().toISOString().slice(0, 10) - const [plannedPoolRaw, reviewPendingRaw] = await Promise.all([ - api.listTrainingUnits({ - assigned_to_me: true, - status: 'planned', - start_date: today, - sort: 'asc', - limit: 40, - }), - api.listTrainingUnits({ - assigned_to_me: true, - debrief_pending: true, - sort: 'desc', - limit: 8, - }), - ]) - const plannedPool = Array.isArray(plannedPoolRaw) ? plannedPoolRaw : [] - const upcoming = plannedPool.slice(0, 8) - const noteHits = plannedPool - .filter((u) => { - const tn = (u.trainer_notes || '').trim() - const n = (u.notes || '').trim() - return Boolean(tn || n) - }) - .slice(0, 5) - if (!cancelled) { - setTrainingHome({ - upcoming, - reviewPending: Array.isArray(reviewPendingRaw) ? reviewPendingRaw : [], - plannedWithNotes: noteHits, - }) - } - } catch (e) { - if (!cancelled) { - console.error('Dashboard Trainingsübersicht:', e) - setTrainingHomeErr(e.message || 'Konnte Trainingsdaten nicht laden') - setTrainingHome(null) - } - } - })() - return () => { - cancelled = true - } - }, [user?.id, tenantClubDepKey]) - - useEffect(() => { - if (!user?.id) { setPhase0Stats(null) - setPhase0Err(null) + setDashboardKpisErr(null) return undefined } let cancelled = false ;(async () => { - setPhase0Err(null) + setDashboardKpisErr(null) try { const data = await api.getDashboardKpis() - if (!cancelled && data && typeof data === 'object') { - setPhase0Stats({ - year: data.year, - draftCount: data.draft_count, - draftCapped: Boolean(data.draft_capped), - draftPreview: Array.isArray(data.draft_preview) ? data.draft_preview : [], - mineCount: data.mine_count ?? 0, - mineCapped: Boolean(data.mine_capped), - ytdCompletedCount: data.ytd_completed_count ?? 0, - ytdCapped: Boolean(data.ytd_capped), - }) - } + if (cancelled || !data || typeof data !== 'object') return + const th = data.training_home && typeof data.training_home === 'object' ? data.training_home : {} + setTrainingHome({ + upcoming: Array.isArray(th.upcoming) ? th.upcoming : [], + reviewPending: Array.isArray(th.review_pending) ? th.review_pending : [], + plannedWithNotes: Array.isArray(th.planned_with_notes) ? th.planned_with_notes : [], + }) + setPhase0Stats({ + year: data.year, + draftCount: data.draft_count, + draftCapped: Boolean(data.draft_capped), + draftPreview: Array.isArray(data.draft_preview) ? data.draft_preview : [], + mineCount: data.mine_count ?? 0, + mineCapped: Boolean(data.mine_capped), + ytdCompletedCount: data.ytd_completed_count ?? 0, + ytdCapped: Boolean(data.ytd_capped), + }) } catch (e) { if (!cancelled) { - console.error('Dashboard Übungs-Kennzahlen:', e) - setPhase0Err(e.message || 'Konnte Übungs-Kennzahlen nicht laden') + console.error('Dashboard KPIs / Trainingsübersicht:', e) + setDashboardKpisErr(e.message || 'Konnte Dashboard-Daten nicht laden') + setTrainingHome(null) setPhase0Stats(null) } } @@ -161,15 +112,15 @@ function Dashboard() {
- {phase0Err ? ( + {dashboardKpisErr ? (- {phase0Err} + {dashboardKpisErr}
) : null} - {!phase0Err && !phase0Stats ? ( + {!dashboardKpisErr && !phase0Stats ? ({trainingHomeErr}
+ {dashboardKpisErr ? ( +{dashboardKpisErr}
) : trainingHome?.upcoming?.length ? ({trainingHomeErr}
+ {dashboardKpisErr ? ( +{dashboardKpisErr}
) : trainingHome?.plannedWithNotes?.length ? ({trainingHomeErr}
+ {dashboardKpisErr ? ( +{dashboardKpisErr}
) : trainingHome?.reviewPending?.length ? (