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
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:
parent
32ba008660
commit
b06d026dd0
41
backend/migrations/062_exercise_skills_level_rank_index.sql
Normal file
41
backend/migrations/062_exercise_skills_level_rank_index.sql
Normal 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;
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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**).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -22,79 +22,30 @@ 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') {
|
||||
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,
|
||||
|
|
@ -105,11 +56,11 @@ function Dashboard() {
|
|||
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() {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{phase0Err ? (
|
||||
{dashboardKpisErr ? (
|
||||
<p className="dashboard-phase0-kpis__err" role="alert">
|
||||
{phase0Err}
|
||||
{dashboardKpisErr}
|
||||
</p>
|
||||
) : null}
|
||||
{!phase0Err && !phase0Stats ? (
|
||||
{!dashboardKpisErr && !phase0Stats ? (
|
||||
<div className="dashboard-phase0-kpis__loading muted">Zahlen werden geladen…</div>
|
||||
) : null}
|
||||
{!phase0Err && phase0Stats ? (
|
||||
{!dashboardKpisErr && phase0Stats ? (
|
||||
<div className="dashboard-phase0-kpis">
|
||||
<Link className="dashboard-kpi-card" to={draftsHref}>
|
||||
<span className="dashboard-kpi-card__icon" aria-hidden>
|
||||
|
|
@ -203,7 +154,7 @@ function Dashboard() {
|
|||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!phase0Err && phase0Stats?.draftPreview?.length ? (
|
||||
{!dashboardKpisErr && phase0Stats?.draftPreview?.length ? (
|
||||
<div className="card dashboard-draft-preview" style={{ marginTop: '1rem' }}>
|
||||
<h3 className="dashboard-preview-card__title" style={{ marginTop: 0 }}>
|
||||
Entwürfe fertigstellen
|
||||
|
|
@ -248,8 +199,8 @@ function Dashboard() {
|
|||
<div className="dashboard-training-preview-grid">
|
||||
<div className="card dashboard-preview-card">
|
||||
<h3 className="dashboard-preview-card__title">Nächste Termine</h3>
|
||||
{trainingHomeErr ? (
|
||||
<p className="dashboard-preview-card__err">{trainingHomeErr}</p>
|
||||
{dashboardKpisErr ? (
|
||||
<p className="dashboard-preview-card__err">{dashboardKpisErr}</p>
|
||||
) : trainingHome?.upcoming?.length ? (
|
||||
<ul className="dashboard-preview-card__list">
|
||||
{trainingHome.upcoming.map((u) => (
|
||||
|
|
@ -278,8 +229,8 @@ function Dashboard() {
|
|||
|
||||
<div className="card dashboard-preview-card">
|
||||
<h3 className="dashboard-preview-card__title">Hinweise (anstehend)</h3>
|
||||
{trainingHomeErr ? (
|
||||
<p className="dashboard-preview-card__err">{trainingHomeErr}</p>
|
||||
{dashboardKpisErr ? (
|
||||
<p className="dashboard-preview-card__err">{dashboardKpisErr}</p>
|
||||
) : trainingHome?.plannedWithNotes?.length ? (
|
||||
<ul className="dashboard-preview-card__list dashboard-preview-card__list--notes">
|
||||
{trainingHome.plannedWithNotes.map((u) => {
|
||||
|
|
@ -309,8 +260,8 @@ function Dashboard() {
|
|||
|
||||
<div className="card dashboard-preview-card">
|
||||
<h3 className="dashboard-preview-card__title">Offene Rückschau</h3>
|
||||
{trainingHomeErr ? (
|
||||
<p className="dashboard-preview-card__err">{trainingHomeErr}</p>
|
||||
{dashboardKpisErr ? (
|
||||
<p className="dashboard-preview-card__err">{dashboardKpisErr}</p>
|
||||
) : trainingHome?.reviewPending?.length ? (
|
||||
<ul className="dashboard-preview-card__list">
|
||||
{trainingHome.reviewPending.map((u) => (
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
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).
|
||||
|
|
|
|||
56
scripts/load/explain-readpaths.sql
Normal file
56
scripts/load/explain-readpaths.sql
Normal 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;
|
||||
|
|
@ -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).
|
||||
*/
|
||||
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);
|
||||
|
||||
let profilesMe = 0;
|
||||
|
|
@ -197,13 +197,13 @@ test('8. Dashboard API-Budget nach Reload (profiles/me, training-units, dashboar
|
|||
});
|
||||
|
||||
expect(profilesMe).toBe(1);
|
||||
expect(trainingUnits).toBe(2);
|
||||
expect(trainingUnits).toBe(0);
|
||||
expect(dashboardKpis).toBe(1);
|
||||
} finally {
|
||||
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 }) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user