shinkan-jinkendo/frontend/src/utils/trainingUnitSectionsForm.js
Lars 0a203aaf75
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m10s
Enhance TrainingUnitSectionsEditor with parallel phase management features
- Introduced logic to limit the number of streams per parallel phase, ensuring compliance with the defined maximum.
- Added utility functions for managing stream indices and visual representation of streams.
- Implemented section movement within parallel streams, allowing for reordering while maintaining stream integrity.
- Updated UI components to reflect changes in stream handling, including disabling buttons when limits are reached.
- Enhanced state management for parallel stream tabs, improving user experience in navigating between streams.
2026-05-15 07:37:51 +02:00

851 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import api from './api'
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
export function defaultSection(title = 'Hauptteil') {
return { title, guidance_notes: '', items: [] }
}
/** Standard-`planLoc` für eine Ganzgruppen-Phase (Editor-Breakout-UI). */
export function defaultPlanLocWholeGroup(phaseOrderIndex = 0) {
return {
phaseKind: 'whole_group',
phaseOrderIndex,
parallelStreamOrderIndex: null,
phaseTitle: null,
phaseGuidanceNotes: null,
streamTitle: null,
streamNotes: null,
streamAssignedTrainerProfileIds: null,
}
}
/** Standard-`planLoc` für einen Stream innerhalb einer parallelen Phase. */
export function defaultPlanLocParallel(phaseOrderIndex, streamOrderIndex) {
return {
phaseKind: 'parallel',
phaseOrderIndex,
parallelStreamOrderIndex: streamOrderIndex,
phaseTitle: null,
phaseGuidanceNotes: null,
streamTitle: null,
streamNotes: null,
streamAssignedTrainerProfileIds: null,
}
}
export function planLocKey(pl) {
if (!pl || !pl.phaseKind) return ''
if (pl.phaseKind === 'whole_group') return `wg:${pl.phaseOrderIndex ?? 0}`
return `par:${pl.phaseOrderIndex ?? 0}:${pl.parallelStreamOrderIndex ?? 0}`
}
export function maxPhaseOrderIndexFromSections(sections) {
let m = -1
for (const s of sections || []) {
const pl = s?.planLoc
if (!pl || typeof pl.phaseOrderIndex !== 'number') continue
if (pl.phaseOrderIndex > m) m = pl.phaseOrderIndex
}
return m
}
/**
* Eindeutige Ziele für die Zuordnung eines Abschnitts (Dropdown).
* `template` ist ein vollständiges planLoc-Objekt zum Kopieren.
*/
export function buildPlanTargetOptions(sections) {
const map = new Map()
for (const s of sections || []) {
const pl = s?.planLoc
if (!pl?.phaseKind) continue
if (pl.phaseKind === 'whole_group') {
const po = pl.phaseOrderIndex ?? 0
const k = `wg:${po}`
if (!map.has(k)) {
const title = pl.phaseTitle != null && String(pl.phaseTitle).trim() ? String(pl.phaseTitle).trim() : ''
map.set(k, {
key: k,
label: title || `Ganzgruppe · Phase ${po}`,
template: { ...pl },
})
}
} else {
const po = pl.phaseOrderIndex ?? 0
const so = pl.parallelStreamOrderIndex ?? 0
const k = `par:${po}:${so}`
if (!map.has(k)) {
const st = pl.streamTitle != null && String(pl.streamTitle).trim() ? String(pl.streamTitle).trim() : ''
map.set(k, {
key: k,
label: st || `Parallel · Phase ${po} · Stream ${so}`,
template: { ...pl },
})
}
}
}
return [...map.values()].sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }))
}
/** Max. Streams pro paralleler Phase (UI + API-Schutz). */
export const MAX_PARALLEL_STREAMS_PER_PHASE = 5
/** Farben pro Stream-Index (max. 5 unterschiedliche Farbzyklen). */
export function parallelStreamVisual(streamOrderIndex) {
const n = Math.max(0, Number(streamOrderIndex) || 0)
const hues = [200, 135, 38, 285, 22]
const h = hues[n % hues.length]
return {
border: `hsl(${h} 50% 36%)`,
soft: `hsl(${h} 36% 94%)`,
tabBg: `hsl(${h} 34% 92%)`,
tabBgActive: `hsl(${h} 40% 82%)`,
}
}
export function streamTabLabelFromIndices(sections, globalIndices) {
const first = globalIndices?.[0]
if (first === undefined || !sections?.[first]) return 'Stream'
const pl = sections[first].planLoc
const t = pl?.streamTitle != null && String(pl.streamTitle).trim() ? String(pl.streamTitle).trim() : ''
if (t) return t
const so = pl?.parallelStreamOrderIndex ?? 0
return `Stream ${so + 1}`
}
/** Sortierte Stream-Indizes innerhalb einer parallelen Phase (für Reiter). */
export function streamsForParallelPhaseOrders(sections, phaseOrderIndex) {
const set = new Set()
const po = Number(phaseOrderIndex) || 0
for (const s of sections || []) {
const L = s?.planLoc
if (L?.phaseKind === 'parallel' && (L.phaseOrderIndex ?? 0) === po) {
set.add(L.parallelStreamOrderIndex ?? 0)
}
}
return [...set].sort((a, b) => a - b)
}
/** Globale Abschnitts-Indizes eines Streams. */
export function sectionIndicesForParallelStream(sections, phaseOrderIndex, streamOrderIndex) {
const out = []
const po = Number(phaseOrderIndex) || 0
const so = Number(streamOrderIndex) || 0
;(sections || []).forEach((s, i) => {
const L = s?.planLoc
if (L?.phaseKind === 'parallel' && (L.phaseOrderIndex ?? 0) === po && (L.parallelStreamOrderIndex ?? 0) === so) {
out.push(i)
}
})
return out
}
/** Reihenfolge innerhalb eines Stream-Buckets (globale Indizes) ändern. */
export function reorderWithinBucketIndices(prev, bucketGlobalIndicesSorted, oldPos, newPos) {
const sortedIdx = [...bucketGlobalIndicesSorted].sort((a, b) => a - b)
if (oldPos === newPos || oldPos < 0 || newPos < 0 || oldPos >= sortedIdx.length || newPos >= sortedIdx.length) {
return prev
}
const values = sortedIdx.map((gi) => prev[gi])
const arr = [...values]
const [x] = arr.splice(oldPos, 1)
arr.splice(newPos, 0, x)
const next = [...prev]
sortedIdx.forEach((gi, k) => {
next[gi] = arr[k]
})
return next
}
function normalizeCatalogMethodProfile(cp) {
if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp }
return {}
}
/** NULL = Planung folgt Katalogprofil der Übung (Reihenfolge beibehalten: zuerst String-JSON auflösen). */
function normalizePlanningMethodProfile(pm) {
if (pm == null) return null
if (typeof pm === 'string') {
const t = pm.trim()
if (!t || t === 'null') return null
try {
const p = JSON.parse(t)
if (p && typeof p === 'object' && !Array.isArray(p)) return { ...p }
return null
} catch {
return null
}
}
if (typeof pm === 'object' && !Array.isArray(pm)) return { ...pm }
return null
}
/** Reines JSON für PUT /training-units (vermeidet nicht serialisierbare Werte → 500). */
export function cloneJsonSerializablePlanningProfile(obj) {
if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) return null
try {
return JSON.parse(JSON.stringify(obj))
} catch {
return {}
}
}
export function exerciseRow() {
return {
item_type: 'exercise',
exercise_id: '',
exercise_variant_id: '',
exercise_kind: 'simple',
exercise_title: '',
variants: [],
planned_duration_min: '',
actual_duration_min: '',
notes: '',
modifications: '',
source_training_module_id: '',
source_module_title: '',
catalog_method_archetype: '',
catalog_method_profile: {},
planning_method_profile: null,
}
}
export async function hydrateExercisePlanningRow(exercise) {
let variants = Array.isArray(exercise?.variants) ? exercise.variants : []
let title = exercise?.title || ''
let exerciseKind = exercise?.exercise_kind
const id = exercise?.id
if (!id) return null
let meta = {}
let full
async function ensureFull() {
if (full !== undefined) return full
try {
full = await api.getExercise(id)
} catch {
full = null
}
return full
}
if (!variants.length) {
await ensureFull()
if (full) {
variants = Array.isArray(full.variants) ? full.variants : []
title = full.title || title
if (exerciseKind == null) exerciseKind = full.exercise_kind
meta = {
exercise_visibility: full.visibility || 'private',
exercise_club_id: full.club_id ?? null,
exercise_created_by: full.created_by ?? null,
exercise_status: full.status || 'draft',
catalog_method_archetype: typeof full.method_archetype === 'string' ? full.method_archetype.trim() : '',
catalog_method_profile: normalizeCatalogMethodProfile(full.method_profile),
}
}
} else {
meta = {
exercise_visibility: exercise?.visibility ?? null,
exercise_club_id: exercise?.club_id ?? null,
exercise_created_by: exercise?.created_by ?? null,
exercise_status: exercise?.status ?? null,
}
if (
meta.exercise_visibility == null ||
meta.exercise_created_by == null ||
exerciseKind == null
) {
await ensureFull()
if (full) {
if (meta.exercise_visibility == null) meta.exercise_visibility = full.visibility || 'private'
if (meta.exercise_club_id == null) meta.exercise_club_id = full.club_id ?? null
if (meta.exercise_created_by == null) meta.exercise_created_by = full.created_by ?? null
if (meta.exercise_status == null) meta.exercise_status = full.status || 'draft'
if (exerciseKind == null) exerciseKind = full.exercise_kind
if (!variants.length) variants = Array.isArray(full.variants) ? full.variants : []
}
}
meta.exercise_visibility = meta.exercise_visibility || 'private'
meta.exercise_status = meta.exercise_status || 'draft'
}
const row = exerciseRow()
row.exercise_id = id
row.exercise_variant_id = ''
row.exercise_title = title
row.exercise_kind =
String(exerciseKind || 'simple').toLowerCase().trim() === 'combination' ? 'combination' : 'simple'
if (row.exercise_kind === 'combination') {
row.variants = []
row.exercise_variant_id = ''
} else {
row.variants = variants
}
Object.assign(row, meta)
if (row.exercise_kind === 'combination') {
if (full === undefined) await ensureFull()
if (full) {
row.catalog_method_archetype =
typeof full.method_archetype === 'string' ? full.method_archetype.trim() : ''
row.catalog_method_profile = normalizeCatalogMethodProfile(full.method_profile)
row.combination_slots = Array.isArray(full.combination_slots) ? full.combination_slots : []
}
}
row.planning_method_profile = null
return row
}
export function noteRow() {
return { item_type: 'note', note_body: '', source_training_module_id: '', source_module_title: '' }
}
/** Zur Serialisierung in die Planungs-API (persistente Modul-Herkunft). */
function parseOptionalSourceTrainingModuleIdForPayload(v) {
if (v === null || v === undefined || v === '') return null
const n = typeof v === 'number' ? v : parseInt(String(v).trim(), 10)
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: formItemsFromApiItems(sec.items),
}))
}
if (fullUnit.exercises && fullUnit.exercises.length) {
return [
{
title: 'Übungen',
guidance_notes: '',
items: fullUnit.exercises.map((ex) => {
const ek = String(ex.exercise_kind || 'simple').toLowerCase().trim()
const isCombo = ek === 'combination'
return {
item_type: 'exercise',
exercise_kind: ek,
exercise_id: ex.exercise_id,
exercise_variant_id: isCombo ? '' : (ex.exercise_variant_id ?? ''),
exercise_title: ex.exercise_title || '',
variants: [],
planned_duration_min:
ex.planned_duration_min !== null && ex.planned_duration_min !== undefined
? String(ex.planned_duration_min)
: '',
actual_duration_min:
ex.actual_duration_min !== null && ex.actual_duration_min !== undefined
? String(ex.actual_duration_min)
: '',
notes: ex.notes ?? '',
modifications: ex.modifications ?? '',
catalog_method_archetype: String(ex.catalog_method_archetype ?? '').trim(),
catalog_method_profile: normalizeCatalogMethodProfile(ex.catalog_method_profile),
planning_method_profile: normalizePlanningMethodProfile(ex.planning_method_profile),
}
}),
},
]
}
return [defaultSection()]
}
export async function enrichSectionsWithVariants(sections) {
if (!sections?.length) return sections
const ids = []
for (const sec of sections) {
for (const it of sec.items || []) {
if (it.item_type === 'note') continue
if (it.exercise_id) ids.push(it.exercise_id)
}
}
const unique = [...new Set(ids)]
const cache = new Map()
await Promise.all(
unique.map(async (id) => {
try {
const ex = await api.getExercise(id)
const ek = String(ex.exercise_kind || 'simple').toLowerCase().trim()
cache.set(id, {
title: ex.title || '',
exercise_kind: ek,
variants: Array.isArray(ex.variants) ? ex.variants : [],
visibility: ex.visibility || 'private',
club_id: ex.club_id ?? null,
created_by: ex.created_by ?? null,
status: ex.status || 'draft',
method_archetype: typeof ex.method_archetype === 'string' ? ex.method_archetype.trim() : '',
method_profile: normalizeCatalogMethodProfile(ex.method_profile),
combination_slots: ek === 'combination' && Array.isArray(ex.combination_slots) ? ex.combination_slots : [],
})
} catch {
cache.set(id, {
title: '',
exercise_kind: 'simple',
variants: [],
visibility: 'private',
club_id: null,
created_by: null,
status: 'draft',
method_archetype: '',
method_profile: {},
combination_slots: [],
})
}
})
)
const titleById = new Map()
for (const id of unique) {
const row = cache.get(id)
const t = (row?.title || '').trim()
if (t) titleById.set(Number(id), t)
}
const comboCandidateExtra = new Set()
for (const id of unique) {
const row = cache.get(id)
if (String(row?.exercise_kind || '').toLowerCase().trim() !== 'combination') continue
for (const slot of row.combination_slots || []) {
for (const raw of slot.candidate_exercise_ids || []) {
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
if (Number.isFinite(n) && !titleById.has(n)) comboCandidateExtra.add(n)
}
}
}
await Promise.all(
[...comboCandidateExtra].map(async (cid) => {
try {
const ex = await api.getExercise(cid)
titleById.set(cid, ((ex.title || '').trim() || `Übung #${cid}`))
} catch {
titleById.set(cid, `Übung #${cid}`)
}
}),
)
function comboMemberTitleByIdForSlots(slots) {
const o = {}
for (const slot of slots || []) {
for (const raw of slot.candidate_exercise_ids || []) {
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
if (!Number.isFinite(n)) continue
const key = String(n)
if (!o[key]) o[key] = titleById.get(n) || `Übung #${n}`
}
}
return o
}
return sections.map((sec) => ({
...sec,
items: (sec.items || []).map((it) => {
if (it.item_type === 'note') return it
if (!it.exercise_id) return it
const c = cache.get(it.exercise_id)
if (!c) return it
const ek = String(c.exercise_kind || 'simple').toLowerCase().trim()
const isCombo = ek === 'combination'
const itemCatalog = normalizeCatalogMethodProfile(it.catalog_method_profile)
const catalog_method_profile =
Object.keys(itemCatalog).length > 0
? itemCatalog
: normalizeCatalogMethodProfile(c.method_profile)
const rowArche = String(it.catalog_method_archetype ?? '').trim()
const catalog_method_archetype = rowArche || String(c.method_archetype ?? '').trim()
return {
...it,
catalog_method_archetype,
catalog_method_profile,
planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile),
exercise_kind: isCombo ? 'combination' : 'simple',
exercise_title: it.exercise_title || c.title,
exercise_variant_id: isCombo ? '' : it.exercise_variant_id,
variants:
isCombo ? [] : Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants,
exercise_visibility: c.visibility,
exercise_club_id: c.club_id,
exercise_created_by: c.created_by,
exercise_status: c.status,
...(isCombo ? { combination_slots: c.combination_slots || [], combo_member_title_by_id: comboMemberTitleByIdForSlots(c.combination_slots || []) } : {}),
}
}),
}))
}
/**
* Outline für CombinationMethodProfileEditor: proSlotZeiten nur sichtbar, wenn Stationen übergeben werden.
*/
export function comboSlotsOutlineForProfileEditor(combinationSlots) {
if (!Array.isArray(combinationSlots) || combinationSlots.length === 0) return null
const sorted = sortCombinationSlotsForDisplay(combinationSlots)
return sorted.map((s, i) => {
const rawIx = s.slot_index
const si =
rawIx === '' || rawIx == null ? null : typeof rawIx === 'number' ? rawIx : parseInt(String(rawIx), 10)
return {
slot_index: Number.isFinite(si) ? si : i,
title: (s.title != null ? String(s.title) : '').trim(),
}
})
}
export function parseMin(v) {
if (v === '' || v === null || v === undefined) return null
const n = parseInt(String(v), 10)
return Number.isFinite(n) ? n : null
}
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 || [])
.map((it, ii) => {
if (it.item_type === 'note') {
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
const row = {
item_type: 'note',
order_index: ii,
note_body: it.note_body ?? '',
}
if (sm != null) row.source_training_module_id = sm
return row
}
if (it.exercise_id === '' || it.exercise_id == null || Number.isNaN(Number(it.exercise_id))) {
return null
}
const isCombo = String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
const vid = isCombo ? null : it.exercise_variant_id
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
const rowEx = {
item_type: 'exercise',
order_index: ii,
exercise_id: parseInt(it.exercise_id, 10),
exercise_variant_id:
vid !== '' && vid != null && !Number.isNaN(Number(vid)) ? parseInt(vid, 10) : null,
planned_duration_min: parseMin(it.planned_duration_min),
actual_duration_min: parseMin(it.actual_duration_min),
notes: it.notes?.trim() ? it.notes.trim() : null,
modifications: it.modifications?.trim() ? it.modifications.trim() : null,
}
if (isCombo) {
const pmp = it.planning_method_profile
if (pmp != null && typeof pmp === 'object' && !Array.isArray(pmp)) {
const cleaned = cloneJsonSerializablePlanningProfile(pmp)
if (cleaned && Object.keys(cleaned).length > 0) {
rowEx.planning_method_profile = cleaned
}
}
}
if (smEx != null) rowEx.source_training_module_id = smEx
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). */
export async function insertTrainingModuleIntoPlanningSections({
sections,
moduleDetail,
sectionIndex,
insertBeforeItemIndex,
}) {
const secIx = typeof sectionIndex === 'number' ? sectionIndex : parseInt(String(sectionIndex), 10)
if (
!Array.isArray(sections) ||
!Number.isFinite(secIx) ||
secIx < 0 ||
secIx >= sections.length ||
!moduleDetail ||
typeof moduleDetail !== 'object'
) {
return sections
}
const prev = [...(sections[secIx].items || [])]
let beforeIx
if (insertBeforeItemIndex === null || insertBeforeItemIndex === undefined || insertBeforeItemIndex === 'end') {
beforeIx = prev.length
} else if (insertBeforeItemIndex === 'start') {
beforeIx = 0
} else {
const n = typeof insertBeforeItemIndex === 'number' ? insertBeforeItemIndex : parseInt(String(insertBeforeItemIndex), 10)
beforeIx = Number.isFinite(n) ? Math.min(Math.max(n, 0), prev.length) : prev.length
}
const midRaw = moduleDetail.id
const midNum = typeof midRaw === 'number' ? midRaw : parseInt(String(midRaw), 10)
if (!Number.isFinite(midNum) || midNum < 1) return sections
const modTitle = (moduleDetail.title || '').trim() || `Modul #${midNum}`
const modItems = [...(moduleDetail.items || [])].sort(
(a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)
)
const appendRows = []
for (const mi of modItems) {
if (mi.item_type === 'note') {
appendRows.push({
item_type: 'note',
note_body: mi.note_body || '',
source_training_module_id: midNum,
source_module_title: modTitle,
})
continue
}
if (!mi.exercise_id) continue
const hydrated = await hydrateExercisePlanningRow({ id: mi.exercise_id })
if (!hydrated) continue
hydrated.source_training_module_id = midNum
hydrated.source_module_title = modTitle
if (
hydrated.exercise_kind !== 'combination' &&
mi.exercise_variant_id
) {
hydrated.exercise_variant_id = String(mi.exercise_variant_id)
}
hydrated.planned_duration_min =
mi.planned_duration_min !== null && mi.planned_duration_min !== undefined
? String(mi.planned_duration_min)
: ''
hydrated.notes = mi.notes ?? ''
appendRows.push(hydrated)
}
const mergedItems = [...prev.slice(0, beforeIx), ...appendRows, ...prev.slice(beforeIx)]
return sections.map((sec, idx) => (idx === secIx ? { ...sec, items: mergedItems } : sec))
}
export function sectionPlannedMinutes(sec) {
return (sec.items || []).reduce((sum, it) => {
if (it.item_type !== 'exercise') return sum
const m = parseMin(it.planned_duration_min)
return sum + (m || 0)
}, 0)
}