chore(version): update version and changelog for release 0.8.139
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m11s

- Bumped APP_VERSION to 0.8.139 and updated the changelog to reflect recent changes.
- Enhanced the Training Planning Module to support new phase handling, including improved labeling for sections in the editor.
- Updated the API payload structure to accommodate parallel streams and phases, ensuring better integration with the frontend components.
- Refactored utility functions for improved clarity and maintainability in handling training unit sections and phases.
This commit is contained in:
Lars 2026-05-15 07:12:43 +02:00
parent 214f90d39b
commit 749c185e3d
4 changed files with 254 additions and 67 deletions

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.138"
APP_VERSION = "0.8.139"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260515063"
@ -36,6 +36,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.139",
"date": "2026-05-14",
"changes": [
"Frontend Trainingsplanung: GET phases → Editor mit planLoc pro Abschnitt; Speichern sendet PUT phases bei Breakout-Einheiten (sonst weiter sections); Modul-Dialog zeigt Phase/Stream in der Abschnittsauswahl.",
],
},
{
"version": "0.8.138",
"date": "2026-05-14",

View File

@ -5,6 +5,18 @@ import { trainingVisibilityShortDE } from '../../utils/trainingPlanningPageHelpe
/**
* Dialog: Trainingsmodul in die Abschnitte einer Einheit einfügen (Bibliothekskopie).
*/
function formatModuleApplySectionOptionLabel(section, flatIndex) {
const title = (section?.title || '').trim() || `Abschnitt ${flatIndex + 1}`
const pl = section?.planLoc
if (pl?.phaseKind === 'parallel') {
const st = (pl.streamTitle || '').trim()
const streamHint = st ? `${st}` : ''
return `${title}${streamHint} (Phase ${pl.phaseOrderIndex ?? 0}, Stream ${pl.parallelStreamOrderIndex ?? 0})`
}
return title
}
export default function TrainingPlanningModuleApplyModal({
open,
busy,
@ -101,7 +113,7 @@ export default function TrainingPlanningModuleApplyModal({
>
{(sections || []).map((s, i) => (
<option key={`sec-opt-u-${i}`} value={String(i)}>
{(s.title || `Abschnitt ${i + 1}`).trim()}
{formatModuleApplySectionOptionLabel(s, i)}
</option>
))}
</select>
@ -148,7 +160,7 @@ export default function TrainingPlanningModuleApplyModal({
>
{(sections || []).map((s, i) => (
<option key={`sec-opt-${i}`} value={String(i)}>
{(s.title || `Abschnitt ${i + 1}`).trim()}
{formatModuleApplySectionOptionLabel(s, i)}
</option>
))}
</select>

View File

@ -11,13 +11,12 @@ import TrainingPlanningFrameworkImportModal from './TrainingPlanningFrameworkImp
import TrainingPlanningModuleApplyModal from './TrainingPlanningModuleApplyModal'
import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal'
import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal'
/* Parallele Trainingsstreams (Breakout): Planungs-API bleibt flache sections/items bis Schema-Paket 12;
Payload-Aufbau buildSectionsPayload (trainingUnitSectionsForm); Backend _replace_unit_sections. */
/* Parallele Streams: Editor bleibt flache Abschnittsliste; `planLoc` pro Abschnitt steuert PUT `phases` vs. Legacy `sections`. */
import {
defaultSection,
normalizeUnitToForm,
enrichSectionsWithVariants,
buildSectionsPayload,
buildPlanPayloadForSave,
hydrateExercisePlanningRow,
insertTrainingModuleIntoPlanningSections,
} from '../../utils/trainingUnitSectionsForm'
@ -959,7 +958,7 @@ function TrainingPlanningPageRoot() {
return
}
try {
const sectionsPayload = buildSectionsPayload(formData.sections)
const planPart = buildPlanPayloadForSave(formData.sections)
const payload = {
planned_date: formData.planned_date,
planned_time_start: formData.planned_time_start || null,
@ -972,7 +971,7 @@ function TrainingPlanningPageRoot() {
status: formData.status || 'planned',
notes: formData.notes || null,
trainer_notes: formData.trainer_notes || null,
sections: sectionsPayload
...planPart,
}
if (editingUnit) {
payload.debrief_completed =

View File

@ -156,65 +156,132 @@ function parseOptionalSourceTrainingModuleIdForPayload(v) {
return Number.isFinite(n) && n >= 1 ? n : null
}
function sortByOrderIndex(arr) {
return [...(arr || [])].sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
}
/** Katalog-Abschnitt (GET) → Editor-Zeilen inkl. Kombi/Modul-Meta — wird von Legacy `sections` und von `phases` genutzt. */
function formItemsFromApiItems(items) {
return (items || []).map((it) => {
if (it.item_type === 'note') {
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
const rowNote = {
item_type: 'note',
note_body: it.note_body || '',
source_training_module_id: '',
source_module_title: '',
}
if (sm != null) {
rowNote.source_training_module_id = sm
rowNote.source_module_title = (
it.source_module_title ||
it.source_training_module_title ||
''
).trim()
}
return rowNote
}
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
const ek = String(it.exercise_kind || 'simple').toLowerCase().trim()
const isCombo = ek === 'combination'
return {
item_type: 'exercise',
exercise_id: it.exercise_id,
exercise_kind: isCombo ? 'combination' : 'simple',
exercise_variant_id: isCombo ? '' : it.exercise_variant_id ?? '',
exercise_title: it.exercise_title || '',
variants: [],
planned_duration_min:
it.planned_duration_min !== null && it.planned_duration_min !== undefined
? String(it.planned_duration_min)
: '',
actual_duration_min:
it.actual_duration_min !== null && it.actual_duration_min !== undefined
? String(it.actual_duration_min)
: '',
notes: it.notes ?? '',
modifications: it.modifications ?? '',
catalog_method_archetype: String(it.catalog_method_archetype ?? '').trim(),
catalog_method_profile: normalizeCatalogMethodProfile(it.catalog_method_profile),
planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile),
...(smEx != null
? {
source_training_module_id: smEx,
source_module_title: (
it.source_module_title ||
it.source_training_module_title ||
''
).trim(),
}
: {}),
}
})
}
/** GET `phases` → flache Editor-Abschnitte mit `planLoc` (für PUT `phases` Roundtrip). */
function normalizePhasesToFormSections(fullUnit) {
const phases = sortByOrderIndex(fullUnit.phases || [])
const out = []
for (const ph of phases) {
const pOi = ph.order_index ?? 0
const pk = String(ph.phase_kind || 'whole_group').toLowerCase().trim()
const basePhaseLoc = {
phaseTitle: ph.title ?? null,
phaseGuidanceNotes: ph.guidance_notes ?? null,
}
if (pk === 'parallel') {
for (const st of sortByOrderIndex(ph.streams || [])) {
const sOi = st.order_index ?? 0
const streamLoc = {
phaseKind: 'parallel',
phaseOrderIndex: pOi,
parallelStreamOrderIndex: sOi,
...basePhaseLoc,
streamTitle: st.title ?? null,
streamNotes: st.notes ?? null,
streamAssignedTrainerProfileIds: st.assigned_trainer_profile_ids ?? null,
}
for (const sec of sortByOrderIndex(st.sections || [])) {
out.push({
title: sec.title,
guidance_notes: sec.guidance_notes || '',
items: formItemsFromApiItems(sec.items),
planLoc: { ...streamLoc },
})
}
}
} else {
const loc = {
phaseKind: 'whole_group',
phaseOrderIndex: pOi,
parallelStreamOrderIndex: null,
...basePhaseLoc,
streamTitle: null,
streamNotes: null,
streamAssignedTrainerProfileIds: null,
}
for (const sec of sortByOrderIndex(ph.sections || [])) {
out.push({
title: sec.title,
guidance_notes: sec.guidance_notes || '',
items: formItemsFromApiItems(sec.items),
planLoc: { ...loc },
})
}
}
}
return out.length ? out : [defaultSection()]
}
export function normalizeUnitToForm(fullUnit) {
if (Array.isArray(fullUnit?.phases) && fullUnit.phases.length > 0) {
return normalizePhasesToFormSections(fullUnit)
}
if (fullUnit.sections && fullUnit.sections.length) {
return fullUnit.sections.map((sec) => ({
title: sec.title,
guidance_notes: sec.guidance_notes || '',
items: (sec.items || []).map((it) => {
if (it.item_type === 'note') {
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
const rowNote = {
item_type: 'note',
note_body: it.note_body || '',
source_training_module_id: '',
source_module_title: '',
}
if (sm != null) {
rowNote.source_training_module_id = sm
rowNote.source_module_title = (
it.source_module_title ||
it.source_training_module_title ||
''
).trim()
}
return rowNote
}
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
const ek = String(it.exercise_kind || 'simple').toLowerCase().trim()
const isCombo = ek === 'combination'
return {
item_type: 'exercise',
exercise_id: it.exercise_id,
exercise_kind: isCombo ? 'combination' : 'simple',
exercise_variant_id: isCombo ? '' : it.exercise_variant_id ?? '',
exercise_title: it.exercise_title || '',
variants: [],
planned_duration_min:
it.planned_duration_min !== null && it.planned_duration_min !== undefined
? String(it.planned_duration_min)
: '',
actual_duration_min:
it.actual_duration_min !== null && it.actual_duration_min !== undefined
? String(it.actual_duration_min)
: '',
notes: it.notes ?? '',
modifications: it.modifications ?? '',
catalog_method_archetype: String(it.catalog_method_archetype ?? '').trim(),
catalog_method_profile: normalizeCatalogMethodProfile(it.catalog_method_profile),
planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile),
...(smEx != null
? {
source_training_module_id: smEx,
source_module_title: (
it.source_module_title ||
it.source_training_module_title ||
''
).trim(),
}
: {}),
}
}),
items: formItemsFromApiItems(sec.items),
}))
}
if (fullUnit.exercises && fullUnit.exercises.length) {
@ -398,10 +465,9 @@ export function parseMin(v) {
return Number.isFinite(n) ? n : null
}
/** PUT /api/training-units/:id `sections` — flache order_index pro Einheit; Parallelität bricht am DB-UNIQUE (training_unit_id, order_index) bis Migration. */
export function buildSectionsPayload(sections) {
return sections.map((sec, si) => ({
order_index: si,
export function buildOneSectionPayload(sec, orderIndex) {
return {
order_index: orderIndex,
title: (sec.title || '').trim() || 'Abschnitt',
guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null,
items: (sec.items || [])
@ -446,7 +512,110 @@ export function buildSectionsPayload(sections) {
return rowEx
})
.filter(Boolean),
}))
}
}
/** PUT /api/training-units: Legacy `sections` (eine whole_group-Phase) wenn kein `planLoc` gesetzt ist. */
export function buildSectionsPayload(sections) {
return sections.map((sec, si) => buildOneSectionPayload(sec, si))
}
function stripPlanLoc(sec) {
if (!sec || typeof sec !== 'object') return sec
const { planLoc: _pl, ...rest } = sec
return rest
}
function inheritPlanLocForPhasedSave(sections) {
let prev = {
phaseKind: 'whole_group',
phaseOrderIndex: 0,
parallelStreamOrderIndex: null,
phaseTitle: null,
phaseGuidanceNotes: null,
streamTitle: null,
streamNotes: null,
streamAssignedTrainerProfileIds: null,
}
return sections.map((s) => {
if (s?.planLoc && s.planLoc.phaseKind) {
prev = { ...s.planLoc }
return { ...s, planLoc: prev }
}
return { ...s, planLoc: { ...prev } }
})
}
function buildPhasesPayloadFromFlat(sections) {
const norm = inheritPlanLocForPhasedSave(sections)
const phases = []
let i = 0
while (i < norm.length) {
const loc0 = norm[i].planLoc
const pOi = loc0.phaseOrderIndex ?? 0
const pk = loc0.phaseKind === 'parallel' ? 'parallel' : 'whole_group'
const run = []
while (i < norm.length) {
const L = norm[i].planLoc
const pk2 = L.phaseKind === 'parallel' ? 'parallel' : 'whole_group'
if ((L.phaseOrderIndex ?? 0) !== pOi || pk2 !== pk) break
run.push(norm[i])
i += 1
}
const head = run[0].planLoc
if (pk === 'whole_group') {
phases.push({
phase_kind: 'whole_group',
order_index: pOi,
title: head.phaseTitle ?? null,
guidance_notes: head.phaseGuidanceNotes ?? null,
sections: run.map((s, idx) => buildOneSectionPayload(stripPlanLoc(s), idx)),
})
} else {
const byStream = new Map()
for (const s of run) {
const soi = s.planLoc.parallelStreamOrderIndex ?? 0
if (!byStream.has(soi)) byStream.set(soi, [])
byStream.get(soi).push(s)
}
const streamOrder = [...byStream.keys()].sort((a, b) => a - b)
const streams = streamOrder.map((soi) => {
const bucket = byStream.get(soi)
const h = bucket[0].planLoc
const st = {
order_index: soi,
title: h.streamTitle ?? null,
notes: h.streamNotes ?? null,
sections: bucket.map((s, idx) => buildOneSectionPayload(stripPlanLoc(s), idx)),
}
const asst = h.streamAssignedTrainerProfileIds
if (asst !== null && asst !== undefined) st.assigned_trainer_profile_ids = asst
return st
})
phases.push({
phase_kind: 'parallel',
order_index: pOi,
title: head.phaseTitle ?? null,
guidance_notes: head.phaseGuidanceNotes ?? null,
streams,
})
}
}
return { phases }
}
/**
* Speichern einer Einheit: flache `sections` oder verschachtelte `phases`, sobald `planLoc` gesetzt ist.
*/
export function buildPlanPayloadForSave(sections) {
const list = Array.isArray(sections) ? sections : []
const anyPhased = list.some((s) => s && s.planLoc && s.planLoc.phaseKind)
if (!anyPhased) {
return { sections: buildSectionsPayload(list) }
}
return buildPhasesPayloadFromFlat(list)
}
/** Fügt die Positionen eines Moduls in lokale Abschnitte ein (wie eine Übung, ohne Zwischenspeichern der Einheit). */