feat(version): bump to 0.8.110 and enhance combination exercise features
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / build-frontend (push) Waiting to run
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Has been cancelled

- 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 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-13 14:24:55 +02:00
parent 5dc93d9a8c
commit a8942a9e4e
10 changed files with 475 additions and 84 deletions

View File

@ -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

View File

@ -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

View File

@ -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: SerienStandard 1 + ArchetypMap 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",

View File

@ -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 */

View 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">KombinationsPlan</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 StationsZeit im Profil ggf. nur globale Vorgaben oder Freitext.
</div>
)}
</div>
</li>
)
})}
</ol>
</div>
</div>
)
}

View File

@ -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 ? (
<>
<div
style={{
marginBottom: '12px',
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
<CombinationPlanBracket
methodArchetype={exercise.method_archetype || ''}
methodProfile={comboMethodProfileEffective}
combinationSlots={
Array.isArray(exercise.combination_slots) ? exercise.combination_slots : []
}
methodArchetype={exercise.method_archetype || ''}
methodProfile={comboMethodProfileEffective}
compactPlanningView
omitGlobalKeyValueBlock={false}
planningAdjusted={
peekExtras?.planning_method_profile != null &&
typeof peekExtras.planning_method_profile === 'object' &&
!Array.isArray(peekExtras.planning_method_profile)
}
/>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1rem 0' }} />
</>

View File

@ -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({
</p>
{comboPlanningResolvedSlots.length > 0 ? (
<div style={{ marginBottom: 16 }}>
<CombinationCoachSlots
combinationSlots={comboPlanningResolvedSlots}
<CombinationPlanBracket
methodArchetype={(comboPlanningModalItem.catalog_method_archetype || '').trim()}
methodProfile={comboPlanningEffectiveProfile}
compactPlanningView
omitGlobalKeyValueBlock
combinationSlots={comboPlanningResolvedSlots}
planningAdjusted={
comboPlanningModalItem.planning_method_profile != null &&
typeof comboPlanningModalItem.planning_method_profile === 'object' &&
!Array.isArray(comboPlanningModalItem.planning_method_profile)
}
/>
</div>
) : (

View File

@ -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 (
<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>
)}
{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 && (
<div className="no-print" style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', marginTop: '0.55rem', alignItems: 'center' }}>
<button
@ -318,7 +345,16 @@ export default function TrainingUnitRunPage() {
onClick={() =>
setPeekCtx({
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,
})
}
>

View File

@ -170,6 +170,45 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
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.
*/

View File

@ -1,7 +1,7 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.94"
export const BUILD_DATE = "2026-05-11"
export const APP_VERSION = "0.8.110"
export const BUILD_DATE = "2026-05-12"
export const PAGE_VERSIONS = {
LoginPage: "1.0.2",
@ -16,7 +16,7 @@ export const PAGE_VERSIONS = {
TrainingPlanningPage: "1.4.0",
TrainingFrameworkProgramsListPage: "1.1.0",
TrainingFrameworkProgramEditPage: "1.5.0",
TrainingUnitRunPage: "1.1.0",
TrainingUnitRunPage: "1.2.0",
TrainingCoachPage: "1.0.0",
AdminCatalogsPage: "2.2.0",
TrainerContextsPage: "1.0.0",