chore(version): update version and changelog for release 0.8.118
All checks were successful
Deploy Development / deploy (push) Successful in 38s
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 57s

- Bumped APP_VERSION to 0.8.118 and updated DB_SCHEMA_VERSION to 20260514062.
- Enhanced the dashboard API with a new endpoint that consolidates training home data, allowing for a single request to retrieve upcoming training sessions, planned sessions with notes, and review pending items.
- Updated the frontend Dashboard component to utilize the new API structure, improving data loading efficiency and user experience.
- Added migration details and changelog entries to reflect the latest changes and improvements.
This commit is contained in:
Lars 2026-05-14 08:53:09 +02:00
parent 32ba008660
commit b06d026dd0
11 changed files with 219 additions and 109 deletions

View File

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

View File

@ -4,6 +4,7 @@ Dashboard: zusammengefasste Kennzahlen (ein Roundtrip statt mehrerer Listen).
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import date
from typing import Any, Dict, List
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@ -14,15 +15,28 @@ from routers.training_planning import list_training_units
router = APIRouter(prefix="/api", tags=["dashboard"]) 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") @router.get("/dashboard/kpis")
def get_dashboard_kpis(tenant: TenantContext = Depends(get_tenant_context)): def get_dashboard_kpis(tenant: TenantContext = Depends(get_tenant_context)):
""" """
Kurzüberblick-KPIs wie bisher drei parallele Client-Aufrufe: Kurzüberblick: Übungs-KPIs + YTD-Einheiten + Trainings-Home (nächste Termine, Vermerke, offene Rückschau)
listExercises (Entwürfe), listExercises (meine), listTrainingUnits (completed im Kalenderjahr). in einem Roundtrip gleiche Filter wie zuvor im Dashboard (mehrere Client-Calls).
""" """
year = date.today().year year = date.today().year
year_start = f"{year}-01-01" year_start = f"{year}-01-01"
year_end = f"{year}-12-31" year_end = f"{year}-12-31"
today = date.today().isoformat()
draft_list = list_exercises_like_get( draft_list = list_exercises_like_get(
tenant, created_by_me=True, status="draft", limit=100 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, limit=250,
tenant=tenant, 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 = [ draft_preview = [
{"id": int(ex["id"]), "title": ex.get("title") or f"Übung #{ex['id']}"} {"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, "mine_capped": len(mine_list) >= 100,
"ytd_completed_count": len(ytd_completed), "ytd_completed_count": len(ytd_completed),
"ytd_capped": len(ytd_completed) >= 250, "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,
},
} }

View File

@ -97,6 +97,7 @@ CASE COALESCE(
WHEN '5' THEN 5 WHEN '5' THEN 5
ELSE NULL END ELSE NULL END
""".strip() """.strip()
# Bei Änderung: Migration 062 idx_exercise_skills_exercise_level_rank (SQL-Ausdruck) synchron halten.
def normalize_exercise_skill_level(value) -> Optional[str]: def normalize_exercise_skill_level(value) -> Optional[str]:

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.117" APP_VERSION = "0.8.118"
BUILD_DATE = "2026-05-12" BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260514061" DB_SCHEMA_VERSION = "20260514062"
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)
@ -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_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.4", # list_training_units: Keyset-Pagination + stabile Sortierung (NULLS LAST + id) "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", "training_modules": "1.0.0",
"import_wiki": "1.0.0", "import_wiki": "1.0.0",
"admin": "1.0.0", "admin": "1.0.0",
@ -36,6 +36,15 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.117",
"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.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**. 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.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). - **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

@ -75,7 +75,11 @@ Messung: Repo-Root → `cd frontend && npm run build` (Vite Production).
| Playwright Gesamtlauf (lokal/CI) | *—* | *nach Messung* | | Playwright Gesamtlauf (lokal/CI) | *—* | *nach Messung* |
| passed / total | 26 / 26 (Ziel) | | | 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` - **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`**. - **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) ## 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 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**).
--- ---

View File

@ -2,11 +2,10 @@
**Aktueller Stand (laufend):** **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 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`; eine `listTrainingUnits`-Abfrage für „Nächste Termine“ + Notiz-Pool; Playwright **Test 8** in `dev-smoke-test.spec.js` sichert API-Budget ab. - **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 (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:** **abgeschlossen** (2026-05-14) — Indizes 058062, Keyset `/api/exercises` + `/api/training-units`, **`/api/dashboard/kpis`** inkl. `training_home`, EXPLAIN-Vorlagen **`scripts/load/explain-readpaths.sql`**.
- **Phase 2 (Teil):** Listen-Indizes **058** (`exercises` Sortierung/`created_by`) und **059** (`training_units` Kalenderliste ohne Blueprint).
- **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget. - **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget.
- **Phase 1:** **verzögertes Erstlade** Org-Inbox per Idle ist umgesetzt. - **Phase 1:** **verzögertes Erstlade** Org-Inbox per Idle ist umgesetzt.
@ -55,19 +54,21 @@
## Phase 2 Backend Lesepfade (Skalierung „viele Nutzer“) ## 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. **Fokus:** DB und API stabil unter parallelen Lesern.
| Task | Bezug | | Task | Bezug | Status |
|------|--------| |------|-------|--------|
| `EXPLAIN` + Index-Tuning für `list_exercises` und nächste schwere Listen | B2 | | `EXPLAIN` + Index-Tuning für `list_exercises` und nächste schwere Listen | B2 | erledigt (Indizes 058060, 062; Vorlagen **[explain-readpaths.sql](../../scripts/load/explain-readpaths.sql)**; Messung Team) |
| Summary-API finalisieren/erweitern falls in P1 nur Teilbereich | B1 | | Summary-API finalisieren/erweitern falls in P1 nur Teilbereich | B1 | erledigt (`GET /api/dashboard/kpis` + **`training_home`**) |
| Optional: erste Keyset-Pagination für eine Liste mit bekanntem Sort-Key | B3 | | 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 **058062**; 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 | | Meilenstein | Inhalt |
|-------------|--------| |-------------|--------|
| **M1** | Phase 0 + 1 abgeschlossen, HANDOVER aktualisiert | | **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 | | **M3** | Phase 3 Referenz-Page + Virtualisierung live |
| **M4** | Phase 4 migrationsbereit für alle neuen Features | | **M4** | Phase 4 migrationsbereit für alle neuen Features |
| **M5** | Phase 5 für Top-Listen abgeschlossen | | **M5** | Phase 5 für Top-Listen abgeschlossen |

View File

@ -22,79 +22,30 @@ function formatCappedCount(n, capped) {
function Dashboard() { function Dashboard() {
const [trainingHome, setTrainingHome] = useState(null) const [trainingHome, setTrainingHome] = useState(null)
const [trainingHomeErr, setTrainingHomeErr] = useState(null)
const [phase0Stats, setPhase0Stats] = useState(null) const [phase0Stats, setPhase0Stats] = useState(null)
const [phase0Err, setPhase0Err] = useState(null) const [dashboardKpisErr, setDashboardKpisErr] = useState(null)
const { user, loading: authLoading } = useAuth() const { user, loading: authLoading } = useAuth()
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
useEffect(() => { useEffect(() => {
if (!user?.id) { if (!user?.id) {
setTrainingHome(null) 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) setPhase0Stats(null)
setPhase0Err(null) setDashboardKpisErr(null)
return undefined return undefined
} }
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
setPhase0Err(null) setDashboardKpisErr(null)
try { try {
const data = await api.getDashboardKpis() const data = await api.getDashboardKpis()
if (!cancelled && data && typeof data === 'object') { 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({ setPhase0Stats({
year: data.year, year: data.year,
draftCount: data.draft_count, draftCount: data.draft_count,
@ -105,11 +56,11 @@ function Dashboard() {
ytdCompletedCount: data.ytd_completed_count ?? 0, ytdCompletedCount: data.ytd_completed_count ?? 0,
ytdCapped: Boolean(data.ytd_capped), ytdCapped: Boolean(data.ytd_capped),
}) })
}
} catch (e) { } catch (e) {
if (!cancelled) { if (!cancelled) {
console.error('Dashboard Übungs-Kennzahlen:', e) console.error('Dashboard KPIs / Trainingsübersicht:', e)
setPhase0Err(e.message || 'Konnte Übungs-Kennzahlen nicht laden') setDashboardKpisErr(e.message || 'Konnte Dashboard-Daten nicht laden')
setTrainingHome(null)
setPhase0Stats(null) setPhase0Stats(null)
} }
} }
@ -161,15 +112,15 @@ function Dashboard() {
</p> </p>
</div> </div>
</div> </div>
{phase0Err ? ( {dashboardKpisErr ? (
<p className="dashboard-phase0-kpis__err" role="alert"> <p className="dashboard-phase0-kpis__err" role="alert">
{phase0Err} {dashboardKpisErr}
</p> </p>
) : null} ) : null}
{!phase0Err && !phase0Stats ? ( {!dashboardKpisErr && !phase0Stats ? (
<div className="dashboard-phase0-kpis__loading muted">Zahlen werden geladen</div> <div className="dashboard-phase0-kpis__loading muted">Zahlen werden geladen</div>
) : null} ) : null}
{!phase0Err && phase0Stats ? ( {!dashboardKpisErr && phase0Stats ? (
<div className="dashboard-phase0-kpis"> <div className="dashboard-phase0-kpis">
<Link className="dashboard-kpi-card" to={draftsHref}> <Link className="dashboard-kpi-card" to={draftsHref}>
<span className="dashboard-kpi-card__icon" aria-hidden> <span className="dashboard-kpi-card__icon" aria-hidden>
@ -203,7 +154,7 @@ function Dashboard() {
</div> </div>
</div> </div>
) : null} ) : null}
{!phase0Err && phase0Stats?.draftPreview?.length ? ( {!dashboardKpisErr && phase0Stats?.draftPreview?.length ? (
<div className="card dashboard-draft-preview" style={{ marginTop: '1rem' }}> <div className="card dashboard-draft-preview" style={{ marginTop: '1rem' }}>
<h3 className="dashboard-preview-card__title" style={{ marginTop: 0 }}> <h3 className="dashboard-preview-card__title" style={{ marginTop: 0 }}>
Entwürfe fertigstellen Entwürfe fertigstellen
@ -248,8 +199,8 @@ function Dashboard() {
<div className="dashboard-training-preview-grid"> <div className="dashboard-training-preview-grid">
<div className="card dashboard-preview-card"> <div className="card dashboard-preview-card">
<h3 className="dashboard-preview-card__title">Nächste Termine</h3> <h3 className="dashboard-preview-card__title">Nächste Termine</h3>
{trainingHomeErr ? ( {dashboardKpisErr ? (
<p className="dashboard-preview-card__err">{trainingHomeErr}</p> <p className="dashboard-preview-card__err">{dashboardKpisErr}</p>
) : trainingHome?.upcoming?.length ? ( ) : trainingHome?.upcoming?.length ? (
<ul className="dashboard-preview-card__list"> <ul className="dashboard-preview-card__list">
{trainingHome.upcoming.map((u) => ( {trainingHome.upcoming.map((u) => (
@ -278,8 +229,8 @@ function Dashboard() {
<div className="card dashboard-preview-card"> <div className="card dashboard-preview-card">
<h3 className="dashboard-preview-card__title">Hinweise (anstehend)</h3> <h3 className="dashboard-preview-card__title">Hinweise (anstehend)</h3>
{trainingHomeErr ? ( {dashboardKpisErr ? (
<p className="dashboard-preview-card__err">{trainingHomeErr}</p> <p className="dashboard-preview-card__err">{dashboardKpisErr}</p>
) : trainingHome?.plannedWithNotes?.length ? ( ) : trainingHome?.plannedWithNotes?.length ? (
<ul className="dashboard-preview-card__list dashboard-preview-card__list--notes"> <ul className="dashboard-preview-card__list dashboard-preview-card__list--notes">
{trainingHome.plannedWithNotes.map((u) => { {trainingHome.plannedWithNotes.map((u) => {
@ -309,8 +260,8 @@ function Dashboard() {
<div className="card dashboard-preview-card"> <div className="card dashboard-preview-card">
<h3 className="dashboard-preview-card__title">Offene Rückschau</h3> <h3 className="dashboard-preview-card__title">Offene Rückschau</h3>
{trainingHomeErr ? ( {dashboardKpisErr ? (
<p className="dashboard-preview-card__err">{trainingHomeErr}</p> <p className="dashboard-preview-card__err">{dashboardKpisErr}</p>
) : trainingHome?.reviewPending?.length ? ( ) : trainingHome?.reviewPending?.length ? (
<ul className="dashboard-preview-card__list"> <ul className="dashboard-preview-card__list">
{trainingHome.reviewPending.map((u) => ( {trainingHome.reviewPending.map((u) => (

View File

@ -28,3 +28,7 @@ BASE_URL=https://dev.shinkan.jinkendo.de k6 run scripts/load/k6-health-baseline.
In der k6-Zusammenfassung `http_req_duration`**p(95)** in [BASELINE_SNAPSHOT.md](../../docs/architecture/BASELINE_SNAPSHOT.md) eintragen. In der k6-Zusammenfassung `http_req_duration`**p(95)** in [BASELINE_SNAPSHOT.md](../../docs/architecture/BASELINE_SNAPSHOT.md) eintragen.
Schwellwerte sind bewusst locker (`p95 < 3s`); bei Fehlschlag Proxy, Netz oder Backend prüfen. Schwellwerte sind bewusst locker (`p95 < 3s`); bei Fehlschlag Proxy, Netz oder Backend prüfen.
## EXPLAIN (Phase 2)
Datei **`explain-readpaths.sql`**: Vorlagen für `EXPLAIN (ANALYZE, BUFFERS)` auf der Ziel-DB (manuell, nicht CI).

View File

@ -0,0 +1,56 @@
-- Phase 2: Vorlagen für EXPLAIN (ANALYZE, BUFFERS) auf Ziel-DB mit realistischem Datenbestand.
-- Ersetzen: :token (Session), ggf. :club_id / :group_id nach Tenant; in psql: \set token '...'
-- Hinweis: Routen sind auth-geschützt — sinnvoll mit Rolle ausführen, die der API entspricht,
-- oder SQL aus Postgres-Logs normalisieren.
-- GET /api/exercises — typische Liste (Filter anpassen)
EXPLAIN (ANALYZE, BUFFERS)
SELECT e.id, e.title
FROM exercises e
WHERE e.status <> 'archived'
AND e.visibility IN ('private', 'club', 'official')
ORDER BY e.updated_at DESC, e.id DESC
LIMIT 50;
-- GET /api/exercises — mit Stufenfilter (nutzt idx_exercise_skills_exercise_level_rank)
EXPLAIN (ANALYZE, BUFFERS)
SELECT e.id, e.title
FROM exercises e
WHERE e.status <> 'archived'
AND EXISTS (
SELECT 1 FROM exercise_skills es
WHERE es.exercise_id = e.id
AND (
CASE COALESCE(
NULLIF(TRIM(LOWER(es.target_level::text)), ''),
NULLIF(TRIM(LOWER(es.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
) BETWEEN 2 AND 4
)
ORDER BY e.updated_at DESC, e.id DESC
LIMIT 50;
-- GET /api/training-units — Kalenderliste (ohne Blueprint)
EXPLAIN (ANALYZE, BUFFERS)
SELECT tu.id, tu.planned_date, tu.planned_time_start
FROM training_units tu
LEFT JOIN training_groups tg ON tu.group_id = tg.id
WHERE tu.framework_slot_id IS NULL
ORDER BY tu.planned_date ASC,
(tu.planned_time_start IS NULL) ASC,
tu.planned_time_start ASC NULLS LAST,
tu.id ASC
LIMIT 40;

View File

@ -159,10 +159,10 @@ test('7. Session-Persistenz nach Reload', async ({ page }) => {
}); });
/** /**
* Refaktor Phase 2 (Dashboard): Kurzüberblick per GET /api/dashboard/kpis; genau zwei GET /api/training-units (Übersicht). * Phase 2 (Dashboard): ein GET /api/dashboard/kpis (KPIs + Trainings-Home); keine direkten GET /api/training-units vom Dashboard.
* Production-ähnlicher Build empfohlen (kein React StrictMode-Doppel-Mount im lokalen Vite-Dev). * Production-ähnlicher Build empfohlen (kein React StrictMode-Doppel-Mount im lokalen Vite-Dev).
*/ */
test('8. Dashboard API-Budget nach Reload (profiles/me, training-units, dashboard/kpis)', async ({ page }) => { test('8. Dashboard API-Budget nach Reload (profiles/me, dashboard/kpis)', async ({ page }) => {
await login(page); await login(page);
let profilesMe = 0; let profilesMe = 0;
@ -197,13 +197,13 @@ test('8. Dashboard API-Budget nach Reload (profiles/me, training-units, dashboar
}); });
expect(profilesMe).toBe(1); expect(profilesMe).toBe(1);
expect(trainingUnits).toBe(2); expect(trainingUnits).toBe(0);
expect(dashboardKpis).toBe(1); expect(dashboardKpis).toBe(1);
} finally { } finally {
page.off('request', onRequest); page.off('request', onRequest);
} }
console.log('✓ Dashboard API-Budget: 1× profiles/me, 2× training-units, 1× dashboard/kpis'); console.log('✓ Dashboard API-Budget: 1× profiles/me, 0× training-units, 1× dashboard/kpis');
}); });
test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => { test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {