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 (
+
+
+
+
+
+ {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 (
+ -
+
+ S{stationIx}
+
+
+
{stationTitle}
+
{names || '(keine Einzelübung)'}
+ {timing ? (
+
+ Zeit / Steuerung
+ {timing}
+
+ ) : (
+
+ Keine eigene Stations‑Zeit im Profil — ggf. nur globale Vorgaben oder Freitext.
+
+ )}
+
+
+ )
+ })}
+
+
+
+ )
+}
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 && (