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: pro‑Slot‑Zeiten 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) }