Module und Kombinationsübnungen in Version 0.8 #31
|
|
@ -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:
|
def enrich_exercise_detail(exercise_id: int, cur) -> dict:
|
||||||
"""
|
"""
|
||||||
Lädt alle M:N Relations für eine Übung und gibt ein vollständiges
|
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"] = []
|
exercise["combination_slots"] = []
|
||||||
if exercise["exercise_kind"] == "combination":
|
if exercise["exercise_kind"] == "combination":
|
||||||
cur.execute(
|
exercise["combination_slots"] = load_combination_slots_for_exercise(cur, exercise_id)
|
||||||
"""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
|
|
||||||
|
|
||||||
return exercise
|
return exercise
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ from club_tenancy import (
|
||||||
)
|
)
|
||||||
from routers.training_modules import load_training_module_for_apply
|
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"])
|
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"] = {}
|
it["catalog_method_profile"] = {}
|
||||||
else:
|
else:
|
||||||
it["catalog_method_profile"] = dict(cmp_raw)
|
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)
|
secs.append(sec)
|
||||||
return secs
|
return secs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.109"
|
APP_VERSION = "0.8.110"
|
||||||
BUILD_DATE = "2026-05-12"
|
BUILD_DATE = "2026-05-12"
|
||||||
DB_SCHEMA_VERSION = "20260512057"
|
DB_SCHEMA_VERSION = "20260512057"
|
||||||
|
|
||||||
|
|
@ -21,10 +21,10 @@ MODULE_VERSIONS = {
|
||||||
"groups": "0.1.0",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.0",
|
"skills": "0.1.0",
|
||||||
"methods": "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_units": "0.2.0",
|
||||||
"training_programs": "0.1.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",
|
"training_modules": "1.0.0",
|
||||||
"import_wiki": "1.0.0",
|
"import_wiki": "1.0.0",
|
||||||
"admin": "1.0.0",
|
"admin": "1.0.0",
|
||||||
|
|
@ -35,6 +35,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.109",
|
||||||
"date": "2026-05-12",
|
"date": "2026-05-12",
|
||||||
|
|
|
||||||
|
|
@ -6210,6 +6210,178 @@ a.analysis-split__nav-item {
|
||||||
line-height: 1.48;
|
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 {
|
@media print {
|
||||||
.desktop-sidebar,
|
.desktop-sidebar,
|
||||||
.bottom-nav,
|
.bottom-nav,
|
||||||
|
|
@ -6236,6 +6408,24 @@ a.analysis-split__nav-item {
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
page-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 */
|
/* Coach — volle Übung, Nur-Mittelbereich scrollt; Steuerung oben/unten sichtbar */
|
||||||
|
|
|
||||||
128
frontend/src/components/CombinationPlanBracket.jsx
Normal file
128
frontend/src/components/CombinationPlanBracket.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="combo-plan-bracket">
|
||||||
|
<div className="combo-plan-bracket__accent" aria-hidden />
|
||||||
|
<div className="combo-plan-bracket__body">
|
||||||
|
<header className="combo-plan-bracket__head">
|
||||||
|
<div className="combo-plan-bracket__head-main">
|
||||||
|
<span className="combo-plan-bracket__kicker">Kombinations‑Plan</span>
|
||||||
|
<span className="combo-plan-bracket__archetype">
|
||||||
|
{archLabel || arch || 'Archetyp'}
|
||||||
|
{arch && archLabel && archLabel !== arch ? (
|
||||||
|
<span className="combo-plan-bracket__archetype-id"> ({arch})</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
{planningAdjusted ? (
|
||||||
|
<span className="combo-plan-bracket__badge">Planung angepasst</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{coachHint ? <p className="combo-plan-bracket__hint">{coachHint}</p> : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{globals.length > 0 ? (
|
||||||
|
<section className="combo-plan-bracket__globals" aria-label="Globale Eckdaten">
|
||||||
|
<div className="combo-plan-bracket__globals-title">Runden · Zeiten · Pausen (global)</div>
|
||||||
|
<ul className="combo-plan-bracket__chip-row">
|
||||||
|
{globals.map((g) => (
|
||||||
|
<li key={g.key} className="combo-plan-bracket__chip" title={g.detailLabel}>
|
||||||
|
<span className="combo-plan-bracket__chip-cap">{g.caption}</span>
|
||||||
|
<span className="combo-plan-bracket__chip-val">{g.value}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<p className="combo-plan-bracket__globals-empty">
|
||||||
|
Keine globalen Zahlenfelder gesetzt — Steuerung erfolgt nur je Station oder über den Freitext der Kombination.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ol className="combo-plan-bracket__stations">
|
||||||
|
{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 (
|
||||||
|
<li key={`slot-${stationIx}-${si}`} className="combo-plan-bracket__station">
|
||||||
|
<div className="combo-plan-bracket__station-index" title={`slot_index ${stationIx}`}>
|
||||||
|
S{stationIx}
|
||||||
|
</div>
|
||||||
|
<div className="combo-plan-bracket__station-main">
|
||||||
|
<div className="combo-plan-bracket__station-title">{stationTitle}</div>
|
||||||
|
<div className="combo-plan-bracket__station-exercises">{names || '(keine Einzelübung)'}</div>
|
||||||
|
{timing ? (
|
||||||
|
<div className="combo-plan-bracket__station-timing">
|
||||||
|
<span className="combo-plan-bracket__timing-label">Zeit / Steuerung</span>
|
||||||
|
<span>{timing}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="combo-plan-bracket__station-timing combo-plan-bracket__station-timing--muted">
|
||||||
|
Keine eigene Stations‑Zeit im Profil — ggf. nur globale Vorgaben oder Freitext.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,7 @@ import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
|
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
|
||||||
import CombinationCoachSlots from './CombinationCoachSlots'
|
import CombinationPlanBracket from './CombinationPlanBracket'
|
||||||
import { combinationArchetypeLabel } from '../constants/combinationArchetypes'
|
|
||||||
import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
|
import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
|
||||||
|
|
||||||
function TagMini({ exercise }) {
|
function TagMini({ exercise }) {
|
||||||
|
|
@ -100,7 +99,7 @@ export default function ExercisePeekModal({
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="exercise-peek-title"
|
aria-labelledby="exercise-peek-title"
|
||||||
style={{
|
style={{
|
||||||
maxWidth: sheetWide ? 'min(760px, 96vw)' : '620px',
|
maxWidth: sheetWide ? 'min(840px, 96vw)' : '620px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxHeight: '88vh',
|
maxHeight: '88vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -128,42 +127,17 @@ export default function ExercisePeekModal({
|
||||||
<>
|
<>
|
||||||
{isCombination ? (
|
{isCombination ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<CombinationPlanBracket
|
||||||
style={{
|
methodArchetype={exercise.method_archetype || ''}
|
||||||
marginBottom: '12px',
|
methodProfile={comboMethodProfileEffective}
|
||||||
padding: '8px 10px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
fontSize: '0.82rem',
|
|
||||||
color: 'var(--text2)',
|
|
||||||
lineHeight: 1.45,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong style={{ color: 'var(--text1)' }}>Kombination</strong>
|
|
||||||
<span style={{ marginLeft: 8 }}>
|
|
||||||
{(() => {
|
|
||||||
const ak = String(exercise.method_archetype || '').trim()
|
|
||||||
const lbl = ak ? combinationArchetypeLabel(ak) : null
|
|
||||||
return lbl || ak || 'Archetyp nicht gesetzt'
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
{peekExtras?.planning_method_profile != null &&
|
|
||||||
typeof peekExtras.planning_method_profile === 'object' &&
|
|
||||||
!Array.isArray(peekExtras.planning_method_profile) ? (
|
|
||||||
<span style={{ marginLeft: 8, color: 'var(--accent-dark)', fontWeight: 600 }}>
|
|
||||||
· Planung angepasst
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<CombinationCoachSlots
|
|
||||||
combinationSlots={
|
combinationSlots={
|
||||||
Array.isArray(exercise.combination_slots) ? exercise.combination_slots : []
|
Array.isArray(exercise.combination_slots) ? exercise.combination_slots : []
|
||||||
}
|
}
|
||||||
methodArchetype={exercise.method_archetype || ''}
|
planningAdjusted={
|
||||||
methodProfile={comboMethodProfileEffective}
|
peekExtras?.planning_method_profile != null &&
|
||||||
compactPlanningView
|
typeof peekExtras.planning_method_profile === 'object' &&
|
||||||
omitGlobalKeyValueBlock={false}
|
!Array.isArray(peekExtras.planning_method_profile)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1rem 0' }} />
|
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1rem 0' }} />
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { GripVertical, Pencil } from 'lucide-react'
|
import { GripVertical, Pencil } from 'lucide-react'
|
||||||
import CombinationMethodProfileEditor from './CombinationMethodProfileEditor'
|
import CombinationMethodProfileEditor from './CombinationMethodProfileEditor'
|
||||||
import CombinationCoachSlots from './CombinationCoachSlots'
|
import CombinationPlanBracket from './CombinationPlanBracket'
|
||||||
import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
|
import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
|
||||||
import { combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
|
import { combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
|
||||||
import {
|
import {
|
||||||
|
|
@ -1611,12 +1611,15 @@ export default function TrainingUnitSectionsEditor({
|
||||||
</p>
|
</p>
|
||||||
{comboPlanningResolvedSlots.length > 0 ? (
|
{comboPlanningResolvedSlots.length > 0 ? (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<CombinationCoachSlots
|
<CombinationPlanBracket
|
||||||
combinationSlots={comboPlanningResolvedSlots}
|
|
||||||
methodArchetype={(comboPlanningModalItem.catalog_method_archetype || '').trim()}
|
methodArchetype={(comboPlanningModalItem.catalog_method_archetype || '').trim()}
|
||||||
methodProfile={comboPlanningEffectiveProfile}
|
methodProfile={comboPlanningEffectiveProfile}
|
||||||
compactPlanningView
|
combinationSlots={comboPlanningResolvedSlots}
|
||||||
omitGlobalKeyValueBlock
|
planningAdjusted={
|
||||||
|
comboPlanningModalItem.planning_method_profile != null &&
|
||||||
|
typeof comboPlanningModalItem.planning_method_profile === 'object' &&
|
||||||
|
!Array.isArray(comboPlanningModalItem.planning_method_profile)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import ExercisePeekModal from '../components/ExercisePeekModal'
|
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||||
|
import CombinationPlanBracket from '../components/CombinationPlanBracket'
|
||||||
import { itemStableKey, sortedSections, sortedItems } from '../utils/trainingPlanUtils'
|
import { itemStableKey, sortedSections, sortedItems } from '../utils/trainingPlanUtils'
|
||||||
|
import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
|
||||||
|
|
||||||
function storageKey(unitId) {
|
function storageKey(unitId) {
|
||||||
return `sj_training_run_checked_${unitId}`
|
return `sj_training_run_checked_${unitId}`
|
||||||
|
|
@ -146,6 +148,7 @@ export default function TrainingUnitRunPage() {
|
||||||
open={peekCtx != null}
|
open={peekCtx != null}
|
||||||
exerciseId={peekCtx?.exerciseId}
|
exerciseId={peekCtx?.exerciseId}
|
||||||
variantId={peekCtx?.variantId ?? undefined}
|
variantId={peekCtx?.variantId ?? undefined}
|
||||||
|
peekExtras={peekCtx?.peekExtras ?? undefined}
|
||||||
onClose={() => setPeekCtx(null)}
|
onClose={() => setPeekCtx(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -271,7 +274,15 @@ export default function TrainingUnitRunPage() {
|
||||||
const plan = formatMin(it.planned_duration_min)
|
const plan = formatMin(it.planned_duration_min)
|
||||||
const extras = []
|
const extras = []
|
||||||
if (it.exercise_focus_area) extras.push(it.exercise_focus_area)
|
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 (
|
return (
|
||||||
<li key={ck} className={`training-run-item training-run-item--exercise${done ? ' training-run-item--done' : ''}`}>
|
<li key={ck} className={`training-run-item training-run-item--exercise${done ? ' training-run-item--done' : ''}`}>
|
||||||
|
|
@ -309,6 +320,22 @@ export default function TrainingUnitRunPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isComboRow && it.exercise_id ? (
|
||||||
|
<div className="training-run-combo-embed">
|
||||||
|
<CombinationPlanBracket
|
||||||
|
methodArchetype={String(it.catalog_method_archetype || '').trim()}
|
||||||
|
methodProfile={comboEffectiveProfile || {}}
|
||||||
|
combinationSlots={
|
||||||
|
Array.isArray(it.combination_slots) ? it.combination_slots : []
|
||||||
|
}
|
||||||
|
planningAdjusted={
|
||||||
|
it.planning_method_profile != null &&
|
||||||
|
typeof it.planning_method_profile === 'object' &&
|
||||||
|
!Array.isArray(it.planning_method_profile)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{it.exercise_id && (
|
{it.exercise_id && (
|
||||||
<div className="no-print" style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', marginTop: '0.55rem', alignItems: 'center' }}>
|
<div className="no-print" style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', marginTop: '0.55rem', alignItems: 'center' }}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -318,7 +345,16 @@ export default function TrainingUnitRunPage() {
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setPeekCtx({
|
setPeekCtx({
|
||||||
exerciseId: it.exercise_id,
|
exerciseId: it.exercise_id,
|
||||||
variantId: it.exercise_variant_id != null ? Number(it.exercise_variant_id) : null,
|
variantId:
|
||||||
|
it.exercise_variant_id != null
|
||||||
|
? Number(it.exercise_variant_id)
|
||||||
|
: null,
|
||||||
|
peekExtras: isComboRow
|
||||||
|
? {
|
||||||
|
catalog_method_profile: it.catalog_method_profile,
|
||||||
|
planning_method_profile: it.planning_method_profile,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,45 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
|
||||||
free_method_block: [],
|
free_method_block: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function shortenComboGuiCaption(label) {
|
||||||
|
const t = (label || '').trim()
|
||||||
|
if (!t) return ''
|
||||||
|
const cut = t.split('(')[0].trim()
|
||||||
|
return cut.length > 52 ? `${cut.slice(0, 50)}…` : cut
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Globale Archetyp-Felder aus method_profile für Lesetext (Vorschau, Druck).
|
||||||
|
* Ignoriert slot_profiles_v1 (kommt separat je Station).
|
||||||
|
*/
|
||||||
|
export function describeGlobalComboProfile(archetypeKey, profileObj) {
|
||||||
|
const arch = typeof archetypeKey === 'string' ? archetypeKey.trim() : ''
|
||||||
|
if (!profileObj || typeof profileObj !== 'object' || Array.isArray(profileObj)) return []
|
||||||
|
const defs = METHOD_PROFILE_GUI_FIELDS[arch] || []
|
||||||
|
const rows = []
|
||||||
|
for (const def of defs) {
|
||||||
|
const val = profileObj[def.key]
|
||||||
|
if (val === undefined || val === null || val === '') continue
|
||||||
|
if (def.kind === 'bool') {
|
||||||
|
const on = val === true || val === 'true' || val === 1 || val === '1'
|
||||||
|
rows.push({
|
||||||
|
key: def.key,
|
||||||
|
caption: shortenComboGuiCaption(def.label),
|
||||||
|
detailLabel: def.label,
|
||||||
|
value: on ? 'Ja' : 'Nein',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
rows.push({
|
||||||
|
key: def.key,
|
||||||
|
caption: shortenComboGuiCaption(def.label),
|
||||||
|
detailLabel: def.label,
|
||||||
|
value: String(val),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aktualisiert method_profile unter Beibehaltung nicht-GUI Schlüssel.
|
* Aktualisiert method_profile unter Beibehaltung nicht-GUI Schlüssel.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Shinkan Jinkendo Frontend Version
|
// Shinkan Jinkendo Frontend Version
|
||||||
|
|
||||||
export const APP_VERSION = "0.8.94"
|
export const APP_VERSION = "0.8.110"
|
||||||
export const BUILD_DATE = "2026-05-11"
|
export const BUILD_DATE = "2026-05-12"
|
||||||
|
|
||||||
export const PAGE_VERSIONS = {
|
export const PAGE_VERSIONS = {
|
||||||
LoginPage: "1.0.2",
|
LoginPage: "1.0.2",
|
||||||
|
|
@ -16,7 +16,7 @@ export const PAGE_VERSIONS = {
|
||||||
TrainingPlanningPage: "1.4.0",
|
TrainingPlanningPage: "1.4.0",
|
||||||
TrainingFrameworkProgramsListPage: "1.1.0",
|
TrainingFrameworkProgramsListPage: "1.1.0",
|
||||||
TrainingFrameworkProgramEditPage: "1.5.0",
|
TrainingFrameworkProgramEditPage: "1.5.0",
|
||||||
TrainingUnitRunPage: "1.1.0",
|
TrainingUnitRunPage: "1.2.0",
|
||||||
TrainingCoachPage: "1.0.0",
|
TrainingCoachPage: "1.0.0",
|
||||||
AdminCatalogsPage: "2.2.0",
|
AdminCatalogsPage: "2.2.0",
|
||||||
TrainerContextsPage: "1.0.0",
|
TrainerContextsPage: "1.0.0",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user