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
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:
parent
214f90d39b
commit
749c185e3d
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 1–2;
|
||||
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 =
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user