diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 5509383..1cdae79 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -974,6 +974,43 @@ def assert_exercise_not_combination(cur, exercise_id: int) -> None: ) +def load_combination_slots_for_exercise(cur, exercise_id: int) -> List[dict]: + """Stationsliste einer Kombinationsübung (gleiches Format wie GET /api/exercises/:id).""" + cur.execute( + """SELECT id, slot_index, title FROM combination_exercise_slots + WHERE exercise_id = %s ORDER BY slot_index ASC, id ASC""", + (exercise_id,), + ) + slot_rows = [r2d(r) for r in cur.fetchall()] + slots_out: List[dict] = [] + for sr in slot_rows: + slot_pk = sr["id"] + cur.execute( + """SELECT candidate_exercise_id FROM combination_slot_candidates + WHERE slot_id = %s ORDER BY sort_order ASC, id ASC""", + (slot_pk,), + ) + crows = cur.fetchall() + cids = [int(r2d(c)["candidate_exercise_id"]) for c in crows] + cand_meta: Dict[int, Optional[str]] = {} + if cids: + ph = ",".join(["%s"] * len(cids)) + cur.execute( + f"SELECT id, title FROM exercises WHERE id IN ({ph})", + tuple(cids), + ) + cand_meta = {int(r2d(x)["id"]): r2d(x).get("title") for x in cur.fetchall()} + slots_out.append( + { + "slot_index": sr["slot_index"], + "title": sr.get("title"), + "candidate_exercise_ids": cids, + "candidates": [{"exercise_id": cid, "title": cand_meta.get(cid)} for cid in cids], + } + ) + return slots_out + + def enrich_exercise_detail(exercise_id: int, cur) -> dict: """ Lädt alle M:N Relations für eine Übung und gibt ein vollständiges @@ -1102,41 +1139,7 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict: exercise["combination_slots"] = [] if exercise["exercise_kind"] == "combination": - cur.execute( - """SELECT id, slot_index, title FROM combination_exercise_slots - WHERE exercise_id = %s ORDER BY slot_index ASC, id ASC""", - (exercise_id,), - ) - slot_rows = [r2d(r) for r in cur.fetchall()] - slots_out: List[dict] = [] - for sr in slot_rows: - slot_pk = sr["id"] - cur.execute( - """SELECT candidate_exercise_id FROM combination_slot_candidates - WHERE slot_id = %s ORDER BY sort_order ASC, id ASC""", - (slot_pk,), - ) - crows = cur.fetchall() - cids = [int(r2d(c)["candidate_exercise_id"]) for c in crows] - cand_meta: Dict[int, Optional[str]] = {} - if cids: - ph = ",".join(["%s"] * len(cids)) - cur.execute( - f"SELECT id, title FROM exercises WHERE id IN ({ph})", - tuple(cids), - ) - cand_meta = {int(r2d(x)["id"]): r2d(x).get("title") for x in cur.fetchall()} - slots_out.append( - { - "slot_index": sr["slot_index"], - "title": sr.get("title"), - "candidate_exercise_ids": cids, - "candidates": [ - {"exercise_id": cid, "title": cand_meta.get(cid)} for cid in cids - ], - } - ) - exercise["combination_slots"] = slots_out + exercise["combination_slots"] = load_combination_slots_for_exercise(cur, exercise_id) return exercise diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index ff0172e..baac6d3 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -19,6 +19,8 @@ from club_tenancy import ( ) from routers.training_modules import load_training_module_for_apply +from routers.exercises import load_combination_slots_for_exercise + router = APIRouter(prefix="/api", tags=["training_planning"]) @@ -493,6 +495,14 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]: it["catalog_method_profile"] = {} else: it["catalog_method_profile"] = dict(cmp_raw) + ek = str(it.get("exercise_kind") or "simple").strip().lower() + if ek == "combination" and it.get("exercise_id"): + try: + it["combination_slots"] = load_combination_slots_for_exercise(cur, int(it["exercise_id"])) + except (TypeError, ValueError): + it["combination_slots"] = [] + else: + it["combination_slots"] = [] secs.append(sec) return secs diff --git a/backend/version.py b/backend/version.py index 98c30eb..b011ef4 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.109" +APP_VERSION = "0.8.110" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260512057" @@ -21,10 +21,10 @@ MODULE_VERSIONS = { "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.27.2", # Kombi: Serien‑Standard 1 + Archetyp‑Map ARCHETYPE_DEFAULT_REP_SERIES_COUNT; Payload rep_series_count ab 1 + "exercises": "2.27.3", # load_combination_slots_for_exercise (gemeinsam mit GET Übung); Hydrate für Planung "training_units": "0.2.0", "training_programs": "0.1.0", - "planning": "0.9.2", # Kombi: planning_method_profile auf Sektions-Items (Migration 057); Form-Payload + Coach-PUT + "planning": "0.9.3", # GET training-units/:id Sektions-Items: combination_slots + Kandidaten-Titel für Druck/Run "training_modules": "1.0.0", "import_wiki": "1.0.0", "admin": "1.0.0", @@ -35,6 +35,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.110", + "date": "2026-05-12", + "changes": [ + "GET /api/training-units/:id: Bei Kombinationsübungen werden `combination_slots` inkl. Kandidaten-Titel mitgeliefert (für Plan & Ablauf / Druck).", + "Hilfsfunktion `load_combination_slots_for_exercise` im exercises-Router; GET Übung nutzt dieselbe Ladelogik.", + ], + }, { "version": "0.8.109", "date": "2026-05-12", diff --git a/frontend/src/app.css b/frontend/src/app.css index ff76e8b..3f479fc 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -6210,6 +6210,178 @@ a.analysis-split__nav-item { line-height: 1.48; } +/* Kombinationsplan — Klammer (Vorschau, Plan & Ablauf, Druck) */ +.combo-plan-bracket { + display: flex; + gap: 0; + align-items: stretch; + margin: 0.35rem 0 0; + border-radius: 12px; + overflow: hidden; + border: 1px solid var(--border); + background: var(--surface); +} +.combo-plan-bracket__accent { + width: 6px; + flex-shrink: 0; + background: linear-gradient(180deg, var(--accent) 0%, var(--accent-dark) 100%); +} +.combo-plan-bracket__body { + flex: 1; + min-width: 0; + padding: 10px 12px 12px; +} +.combo-plan-bracket__head { + margin-bottom: 10px; +} +.combo-plan-bracket__head-main { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px 12px; +} +.combo-plan-bracket__kicker { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text3); +} +.combo-plan-bracket__archetype { + font-size: 0.95rem; + font-weight: 700; + color: var(--text1); +} +.combo-plan-bracket__archetype-id { + font-weight: 500; + font-size: 0.78rem; + color: var(--text3); +} +.combo-plan-bracket__badge { + font-size: 0.72rem; + font-weight: 700; + padding: 2px 8px; + border-radius: 999px; + background: var(--accent-soft, hsla(160, 42%, 90%, 1)); + color: var(--accent-dark); +} +.combo-plan-bracket__hint { + margin: 6px 0 0; + font-size: 0.78rem; + line-height: 1.42; + color: var(--text2); +} +.combo-plan-bracket__globals { + margin-bottom: 12px; +} +.combo-plan-bracket__globals-title { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text3); + margin-bottom: 8px; +} +.combo-plan-bracket__globals-empty { + margin: 0 0 12px; + font-size: 0.78rem; + color: var(--text3); + line-height: 1.4; +} +.combo-plan-bracket__chip-row { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.combo-plan-bracket__chip { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 6.5rem; + max-width: 14rem; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface2); +} +.combo-plan-bracket__chip-cap { + font-size: 0.68rem; + color: var(--text3); + line-height: 1.25; +} +.combo-plan-bracket__chip-val { + font-size: 0.88rem; + font-weight: 700; + color: var(--text1); +} +.combo-plan-bracket__stations { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.combo-plan-bracket__station { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 10px 10px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface2); +} +.combo-plan-bracket__station-index { + flex-shrink: 0; + min-width: 2.25rem; + height: 2.25rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + font-size: 0.72rem; + font-weight: 800; + background: var(--surface); + border: 1px solid var(--border); + color: var(--accent-dark); +} +.combo-plan-bracket__station-main { + flex: 1; + min-width: 0; +} +.combo-plan-bracket__station-title { + font-weight: 700; + font-size: 0.9rem; + color: var(--text1); + margin-bottom: 4px; +} +.combo-plan-bracket__station-exercises { + font-size: 0.84rem; + color: var(--text2); + line-height: 1.38; + margin-bottom: 6px; +} +.combo-plan-bracket__station-timing { + font-size: 0.78rem; + line-height: 1.42; + color: var(--text1); +} +.combo-plan-bracket__station-timing--muted { + color: var(--text3); + font-style: italic; +} +.combo-plan-bracket__timing-label { + font-weight: 700; + color: var(--text3); + margin-right: 6px; +} +.training-run-combo-embed { + margin-top: 0.65rem; +} + @media print { .desktop-sidebar, .bottom-nav, @@ -6236,6 +6408,24 @@ a.analysis-split__nav-item { break-inside: avoid; page-break-inside: avoid; } + .combo-plan-bracket { + border-color: #222 !important; + background: #fff !important; + break-inside: avoid; + page-break-inside: avoid; + } + .combo-plan-bracket__accent { + background: #085041 !important; + } + .combo-plan-bracket__chip, + .combo-plan-bracket__station { + border-color: #444 !important; + background: #f4f6f8 !important; + } + .combo-plan-bracket__station-index { + border-color: #444 !important; + color: #06352a !important; + } } /* Coach — volle Übung, Nur-Mittelbereich scrollt; Steuerung oben/unten sichtbar */ diff --git a/frontend/src/components/CombinationPlanBracket.jsx b/frontend/src/components/CombinationPlanBracket.jsx new file mode 100644 index 0000000..043146a --- /dev/null +++ b/frontend/src/components/CombinationPlanBracket.jsx @@ -0,0 +1,128 @@ +/** + * Kombination: konsolidierte Darstellung globales Profil + Stationen mit Zeiten (Vorschau, Plan-Ansicht, Druck). + */ +import React, { useMemo } from 'react' +import { + archetypeCoachHint, + combinationArchetypeLabel, + sortCombinationSlotsForDisplay, +} from '../constants/combinationArchetypes' +import { describeGlobalComboProfile, readSlotProfilesV1, summarizeSlotProfileBrief } from '../utils/combinationMethodProfileUi' + +function candidateLine(slot) { + const cands = slot.candidates + if (Array.isArray(cands) && cands.length > 0) { + return cands + .map((c) => + ((c.title || '').trim() || (c.exercise_id != null ? `Übung #${c.exercise_id}` : '')).trim(), + ) + .filter(Boolean) + .join(' ↔ ') + } + const ids = slot.candidate_exercise_ids || [] + return ids + .map((raw) => { + const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10) + return Number.isFinite(n) ? `Übung #${n}` : '' + }) + .filter(Boolean) + .join(' ↔ ') +} + +export default function CombinationPlanBracket({ + methodArchetype, + methodProfile, + combinationSlots, + planningAdjusted = false, +}) { + const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : '' + const archLabel = arch ? combinationArchetypeLabel(arch) : null + const globals = describeGlobalComboProfile(arch, methodProfile || {}) + const slotsSorted = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots || []), [combinationSlots]) + const timingByIx = useMemo(() => { + const mp = methodProfile || {} + const rows = readSlotProfilesV1(mp) + const m = new Map() + for (const r of rows) { + m.set(Number(r.slot_index), r) + } + return m + }, [methodProfile]) + + const coachHint = arch ? archetypeCoachHint(arch) : '' + + return ( +
{coachHint}
: null} ++ Keine globalen Zahlenfelder gesetzt — Steuerung erfolgt nur je Station oder über den Freitext der Kombination. +
+ )} + +