From a8942a9e4ec3271a31c66226bb1709de9203c63a Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 13 May 2026 14:24:55 +0200 Subject: [PATCH] feat(version): bump to 0.8.110 and enhance combination exercise features - Updated app version to 0.8.110, reflecting recent improvements in combination exercise handling. - Introduced `load_combination_slots_for_exercise` function to streamline fetching combination slots for exercises. - Enhanced `TrainingPlanningPage` and `ExercisePeekModal` to utilize the new combination slots functionality, improving user experience. - Updated changelog to document the latest changes and feature enhancements. Co-Authored-By: Claude Sonnet 4.6 --- backend/routers/exercises.py | 73 +++---- backend/routers/training_planning.py | 10 + backend/version.py | 14 +- frontend/src/app.css | 190 ++++++++++++++++++ .../src/components/CombinationPlanBracket.jsx | 128 ++++++++++++ frontend/src/components/ExercisePeekModal.jsx | 46 +---- .../components/TrainingUnitSectionsEditor.jsx | 13 +- frontend/src/pages/TrainingUnitRunPage.jsx | 40 +++- .../src/utils/combinationMethodProfileUi.js | 39 ++++ frontend/src/version.js | 6 +- 10 files changed, 475 insertions(+), 84 deletions(-) create mode 100644 frontend/src/components/CombinationPlanBracket.jsx 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 ( +
+
+
+
+
+ Kombinations‑Plan + + {archLabel || arch || 'Archetyp'} + {arch && archLabel && archLabel !== arch ? ( + ({arch}) + ) : null} + + {planningAdjusted ? ( + Planung angepasst + ) : null} +
+ {coachHint ?

{coachHint}

: null} +
+ + {globals.length > 0 ? ( +
+
Runden · Zeiten · Pausen (global)
+
    + {globals.map((g) => ( +
  • + {g.caption} + {g.value} +
  • + ))} +
+
+ ) : ( +

+ Keine globalen Zahlenfelder gesetzt — Steuerung erfolgt nur je Station oder über den Freitext der Kombination. +

+ )} + +
    + {slotsSorted.map((slot, si) => { + const siRaw = slot.slot_index + const ixParsed = + siRaw === '' || siRaw == null ? si : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10) + const stationIx = Number.isFinite(ixParsed) ? ixParsed : si + const stationTitle = ((slot.title || '').trim() || `Station ${stationIx}`).trim() + const names = candidateLine(slot) + const timing = summarizeSlotProfileBrief(timingByIx.get(stationIx)) + + return ( +
  1. +
    + S{stationIx} +
    +
    +
    {stationTitle}
    +
    {names || '(keine Einzelübung)'}
    + {timing ? ( +
    + Zeit / Steuerung + {timing} +
    + ) : ( +
    + Keine eigene Stations‑Zeit im Profil — ggf. nur globale Vorgaben oder Freitext. +
    + )} +
    +
  2. + ) + })} +
+
+
+ ) +} diff --git a/frontend/src/components/ExercisePeekModal.jsx b/frontend/src/components/ExercisePeekModal.jsx index b87613b..e3efdb8 100644 --- a/frontend/src/components/ExercisePeekModal.jsx +++ b/frontend/src/components/ExercisePeekModal.jsx @@ -5,8 +5,7 @@ import React, { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import api from '../utils/api' import ExerciseRichTextBlock from './ExerciseRichTextBlock' -import CombinationCoachSlots from './CombinationCoachSlots' -import { combinationArchetypeLabel } from '../constants/combinationArchetypes' +import CombinationPlanBracket from './CombinationPlanBracket' import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile' function TagMini({ exercise }) { @@ -100,7 +99,7 @@ export default function ExercisePeekModal({ aria-modal="true" aria-labelledby="exercise-peek-title" style={{ - maxWidth: sheetWide ? 'min(760px, 96vw)' : '620px', + maxWidth: sheetWide ? 'min(840px, 96vw)' : '620px', width: '100%', maxHeight: '88vh', display: 'flex', @@ -128,42 +127,17 @@ export default function ExercisePeekModal({ <> {isCombination ? ( <> -
- Kombination - - {(() => { - const ak = String(exercise.method_archetype || '').trim() - const lbl = ak ? combinationArchetypeLabel(ak) : null - return lbl || ak || 'Archetyp nicht gesetzt' - })()} - - {peekExtras?.planning_method_profile != null && - typeof peekExtras.planning_method_profile === 'object' && - !Array.isArray(peekExtras.planning_method_profile) ? ( - - · Planung angepasst - - ) : null} -
-
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx index 42ba42c..2cb0a0c 100644 --- a/frontend/src/components/TrainingUnitSectionsEditor.jsx +++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx @@ -1,7 +1,7 @@ import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react' import { GripVertical, Pencil } from 'lucide-react' import CombinationMethodProfileEditor from './CombinationMethodProfileEditor' -import CombinationCoachSlots from './CombinationCoachSlots' +import CombinationPlanBracket from './CombinationPlanBracket' import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile' import { combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes' import { @@ -1611,12 +1611,15 @@ export default function TrainingUnitSectionsEditor({

{comboPlanningResolvedSlots.length > 0 ? (
-
) : ( diff --git a/frontend/src/pages/TrainingUnitRunPage.jsx b/frontend/src/pages/TrainingUnitRunPage.jsx index a8c7341..5a7f4e1 100644 --- a/frontend/src/pages/TrainingUnitRunPage.jsx +++ b/frontend/src/pages/TrainingUnitRunPage.jsx @@ -5,7 +5,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import api from '../utils/api' import ExercisePeekModal from '../components/ExercisePeekModal' +import CombinationPlanBracket from '../components/CombinationPlanBracket' import { itemStableKey, sortedSections, sortedItems } from '../utils/trainingPlanUtils' +import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile' function storageKey(unitId) { return `sj_training_run_checked_${unitId}` @@ -146,6 +148,7 @@ export default function TrainingUnitRunPage() { open={peekCtx != null} exerciseId={peekCtx?.exerciseId} variantId={peekCtx?.variantId ?? undefined} + peekExtras={peekCtx?.peekExtras ?? undefined} onClose={() => setPeekCtx(null)} /> @@ -271,7 +274,15 @@ export default function TrainingUnitRunPage() { const plan = formatMin(it.planned_duration_min) const extras = [] if (it.exercise_focus_area) extras.push(it.exercise_focus_area) - const metaParts = [...extras, plan].filter(Boolean) + const exKind = String(it.exercise_kind || 'simple').toLowerCase().trim() + const isComboRow = exKind === 'combination' + const metaParts = [...extras, isComboRow ? 'Kombination' : null, plan].filter(Boolean) + const comboEffectiveProfile = isComboRow + ? effectiveComboMethodProfile( + it.catalog_method_profile || {}, + it.planning_method_profile ?? null, + ) + : null return (
  • @@ -309,6 +320,22 @@ export default function TrainingUnitRunPage() { )}
  • )} + {isComboRow && it.exercise_id ? ( +
    + +
    + ) : null} {it.exercise_id && (