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 } /** Abschnitte an den angegebenen globalen Indizes entfernen (mindestens ein Abschnitt bleibt). */ export function reorderWithoutIndices(prev, removeGlobalIndices) { const set = new Set(removeGlobalIndices || []) const next = (prev || []).filter((_, i) => !set.has(i)) return next.length ? next : [defaultSection()] } /** * Ob in den Abschnitten eines Stream-Buckets planerisch etwas steht (Übungen, Text-Anmerkungen). * Trennlinien-Marker (---) zählen nicht als Inhalt. */ export function parallelStreamBucketHasContent(sections, globalIndices, separatorBody = '---') { for (const gi of globalIndices || []) { const sec = (sections || [])[gi] if (!sec) continue for (const it of sec.items || []) { if ((it.item_type || '') === 'note') { const b = (it.note_body || '').trim() if (b && b !== separatorBody) return true } else { if (it.exercise_id) return true if ((it.exercise_title || '').trim()) return true } } } return false } /** Parallele Phase auflösen: alle Abschnitte dieser Phase werden Ganzgruppe (gleicher phaseOrderIndex). */ export function dissolveParallelPhaseToWholeGroup(sections, phaseOrderIndex) { const po = Number(phaseOrderIndex) || 0 return (sections || []).map((s) => { const L = s?.planLoc if (L?.phaseKind !== 'parallel' || (L.phaseOrderIndex ?? 0) !== po) return s return { ...s, planLoc: { ...defaultPlanLocWholeGroup(po), phaseTitle: L.phaseTitle ?? null, phaseGuidanceNotes: L.phaseGuidanceNotes ?? null, }, } }) } 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 phaseIndexToInt(v, fallback = 0) { if (v === null || v === undefined || v === '') return fallback const n = typeof v === 'number' ? v : Number(v) return Number.isFinite(n) ? n : fallback } /** * planLoc für Phasen-PUT kanonisieren (Großschreibung, numerische Indizes). * Verhindert u. a. abgebrochene Phasen-Runs bei "0" !== 0 und falsche whole_group-Zweige. */ function canonicalPlanLocForPhasedSave(pl) { if (!pl || typeof pl !== 'object') return null const rawKind = String(pl.phaseKind || '').toLowerCase().trim() let kind = rawKind === 'parallel' || rawKind === 'whole_group' ? rawKind : null if ( !kind && pl.parallelStreamOrderIndex != null && pl.parallelStreamOrderIndex !== '' ) { kind = 'parallel' } if (!kind) return null const phaseOrderIndex = phaseIndexToInt(pl.phaseOrderIndex, 0) let parallelStreamOrderIndex = null if (kind === 'parallel') { parallelStreamOrderIndex = phaseIndexToInt(pl.parallelStreamOrderIndex, 0) } return { ...pl, phaseKind: kind, phaseOrderIndex, parallelStreamOrderIndex, } } export function inheritPlanLocForPhasedSave(sections) { let prev = { ...defaultPlanLocWholeGroup(0) } return (sections || []).map((s) => { const canon = canonicalPlanLocForPhasedSave(s?.planLoc) if (canon) { prev = { ...canon } return { ...s, planLoc: prev } } return { ...s, planLoc: { ...prev } } }) } /** Phasen-„Runs“ in der flachen Abschnittsliste (Reihenfolge wie beim Speichern). */ export function phaseRunsFromSections(sections) { const norm = inheritPlanLocForPhasedSave(sections) const runs = [] let i = 0 while (i < norm.length) { const loc0 = norm[i]?.planLoc if (!loc0?.phaseKind) { i += 1 continue } const pOi = loc0.phaseOrderIndex ?? 0 const pk = loc0.phaseKind === 'parallel' ? 'parallel' : 'whole_group' const start = i while (i < norm.length) { const L = norm[i]?.planLoc if (!L?.phaseKind) break const pk2 = L.phaseKind === 'parallel' ? 'parallel' : 'whole_group' if ((L.phaseOrderIndex ?? 0) !== pOi || pk2 !== pk) break i += 1 } runs.push({ phaseKind: pk, phaseOrderIndex: pOi, start, end: i }) } return runs } /** Vertauscht zwei unmittelbar benachbarte Runs (upperRunIndex = erste der beiden). */ export function swapAdjacentPhaseRuns(prev, upperRunIndex) { const runs = phaseRunsFromSections(prev) const a = upperRunIndex const b = upperRunIndex + 1 if (a < 0 || b >= runs.length) return prev const rgA = runs[a] const rgB = runs[b] const head = prev.slice(0, rgA.start) const blA = prev.slice(rgA.start, rgA.end) const blB = prev.slice(rgB.start, rgB.end) const tail = prev.slice(rgB.end) return [...head, ...blB, ...blA, ...tail] } export function movePhaseRunUpByPhaseOrder(prev, phaseOrderIndex) { const po = Number(phaseOrderIndex) || 0 const runs = phaseRunsFromSections(prev) const rIdx = runs.findIndex((r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po) if (rIdx <= 0) return prev return swapAdjacentPhaseRuns(prev, rIdx - 1) } export function movePhaseRunDownByPhaseOrder(prev, phaseOrderIndex) { const po = Number(phaseOrderIndex) || 0 const runs = phaseRunsFromSections(prev) const rIdx = runs.findIndex((r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po) if (rIdx < 0 || rIdx >= runs.length - 1) return prev return swapAdjacentPhaseRuns(prev, rIdx) } /** * Abschnitt verschieben und planLoc an der Einfügestelle an Nachbarn anpassen. * Regel: Einfügen vor Abschnitt X übernimmt X.planLoc; am Listenende nach Parallel → neue Ganzgruppen-Phase. */ export function reorderBlocksImmutableWithPlanLoc(prev, fromI, toBeforeIdx) { const arr = [...prev] if (fromI < 0 || fromI >= arr.length) return prev const [moved] = arr.splice(fromI, 1) let insertAt = toBeforeIdx if (fromI < toBeforeIdx) insertAt = toBeforeIdx - 1 insertAt = Math.max(0, Math.min(insertAt, arr.length)) const below = insertAt < arr.length ? arr[insertAt] : undefined const above = insertAt > 0 ? arr[insertAt - 1] : undefined let planLocNext = null const belowKind = below?.planLoc?.phaseKind const aboveKind = above?.planLoc?.phaseKind const movedKind = moved?.planLoc?.phaseKind const belowPo = below?.planLoc?.phaseOrderIndex ?? 0 const movedPo = moved?.planLoc?.phaseOrderIndex ?? 0 if (belowKind === 'parallel' && aboveKind === 'whole_group') { if (movedKind !== 'parallel') { planLocNext = { ...above.planLoc } } else if (movedPo !== belowPo) { planLocNext = { ...below.planLoc } } } else if (belowKind === 'parallel' && !above) { if (movedKind !== 'parallel') { planLocNext = defaultPlanLocWholeGroup(0) } } if (!planLocNext && belowKind) { planLocNext = { ...below.planLoc } } if (!planLocNext && insertAt === arr.length) { if (!above) { planLocNext = defaultPlanLocWholeGroup(0) } else if (above.planLoc?.phaseKind === 'parallel') { const mx = maxPhaseOrderIndexFromSections(arr) planLocNext = defaultPlanLocWholeGroup(mx + 1) } else if (above.planLoc?.phaseKind === 'whole_group') { planLocNext = { ...above.planLoc } } } if (!planLocNext && above?.planLoc?.phaseKind === 'whole_group') { planLocNext = { ...above.planLoc } } if (!planLocNext && above?.planLoc?.phaseKind === 'parallel') { const mx = maxPhaseOrderIndexFromSections(arr) planLocNext = defaultPlanLocWholeGroup(mx + 1) } let nextMoved = { ...moved } if (planLocNext) { nextMoved = { ...moved, planLoc: planLocNext } } else { nextMoved = stripPlanLoc(moved) } arr.splice(insertAt, 0, nextMoved) return arr } /** * Abschnitt direkt vor den Parallel-Lauf setzen (immer Ganzgruppe oberhalb der Split-Phase). */ export function reorderSectionBeforeParallelRunAsWholeGroup(prev, fromI, phaseOrderIndex) { const po = Number(phaseOrderIndex) || 0 const idxs = indicesOfParallelPhase(prev, po) if (!idxs.length || fromI < 0 || fromI >= (prev?.length ?? 0)) return prev const fg = idxs[0] const arr = [...prev] const [moved] = arr.splice(fromI, 1) let insertAt = fg if (fromI < insertAt) insertAt -= 1 insertAt = Math.max(0, Math.min(insertAt, arr.length)) const above = insertAt > 0 ? arr[insertAt - 1] : undefined let planLocNext if (above?.planLoc?.phaseKind === 'whole_group') { planLocNext = { ...above.planLoc } } else if (!above) { planLocNext = defaultPlanLocWholeGroup(0) } else { const mx = maxPhaseOrderIndexFromSections(arr) planLocNext = defaultPlanLocWholeGroup(mx + 1) } arr.splice(insertAt, 0, { ...moved, planLoc: planLocNext }) return arr } /** Abschnitt als neuen ersten Eintrag der Parallel-Phase (gleiche Phasen-/Stream-Metadaten wie bisheriger Kopf). */ export function reorderSectionAsFirstInParallelPhase(prev, fromI, phaseOrderIndex) { const po = Number(phaseOrderIndex) || 0 const idxs = indicesOfParallelPhase(prev, po) if (!idxs.length || fromI < 0 || fromI >= (prev?.length ?? 0)) return prev let fg = idxs[0] const arr = [...prev] const headTpl = { ...arr[fg].planLoc } const [moved] = arr.splice(fromI, 1) if (fromI < fg) fg -= 1 fg = Math.max(0, Math.min(fg, arr.length)) arr.splice(fg, 0, { ...moved, planLoc: { ...headTpl } }) return arr } /** Abschnitt als ersten Eintrag eines parallelen Streams setzen (planLoc wie erster Abschnitt dieses Streams, bzw. leerer Stream wie reorderBlockIntoParallelStreamEnd). */ export function reorderSectionAsFirstInParallelStream(prev, fromI, phaseOrderIndex, streamOrderIndex) { const po = Number(phaseOrderIndex) || 0 const so = Number(streamOrderIndex) || 0 const len = prev?.length ?? 0 if (fromI < 0 || fromI >= len) return prev const arr = [...prev] const [moved] = arr.splice(fromI, 1) const streamIdx = sectionIndicesForParallelStream(arr, po, so) let insertAt let headTpl let skipFromIAdjust = false if (streamIdx.length) { const first = Math.min(...streamIdx) headTpl = { ...arr[first].planLoc } insertAt = first } else { const phaseIdx = indicesOfParallelPhase(arr, po) if (!phaseIdx.length) { const ml = moved?.planLoc if (ml?.phaseKind !== 'parallel' || (ml.phaseOrderIndex ?? 0) !== po) return prev headTpl = { ...ml, parallelStreamOrderIndex: so, streamTitle: null, streamNotes: null, streamAssignedTrainerProfileIds: null, } insertAt = Math.min(fromI, arr.length) skipFromIAdjust = true } else { const ref = arr[phaseIdx[phaseIdx.length - 1]] headTpl = { ...ref.planLoc, parallelStreamOrderIndex: so, streamTitle: null, streamNotes: null, streamAssignedTrainerProfileIds: null, } insertAt = phaseIdx[phaseIdx.length - 1] + 1 } } if (!skipFromIAdjust && fromI < insertAt) insertAt -= 1 insertAt = Math.max(0, Math.min(insertAt, arr.length)) arr.splice(insertAt, 0, { ...moved, planLoc: { ...headTpl } }) return arr } /** * Abschnitt ans Ende eines parallelen Streams setzen (planLoc wie dieser Stream). * Leerer Stream: Einfügen hinter den letzten Abschnitt der zugehörigen parallelen Phase, planLoc vom Referenz-Abschnitt mit angepasstem streamIndex. */ export function reorderBlockIntoParallelStreamEnd(prev, fromI, phaseOrderIndex, streamOrderIndex) { const po = Number(phaseOrderIndex) || 0 const so = Number(streamOrderIndex) || 0 const len = prev?.length ?? 0 if (fromI < 0 || fromI >= len) return prev const arr = [...prev] const [moved] = arr.splice(fromI, 1) const streamIdx = sectionIndicesForParallelStream(arr, po, so) let insertAt let planLocTemplate if (streamIdx.length) { const last = Math.max(...streamIdx) planLocTemplate = { ...arr[last].planLoc } insertAt = last + 1 } else { const phaseIdx = indicesOfParallelPhase(arr, po) if (!phaseIdx.length) return prev const ref = arr[phaseIdx[phaseIdx.length - 1]] planLocTemplate = { ...ref.planLoc, parallelStreamOrderIndex: so, streamTitle: null, streamNotes: null, streamAssignedTrainerProfileIds: null, } insertAt = phaseIdx[phaseIdx.length - 1] + 1 } insertAt = Math.max(0, Math.min(insertAt, arr.length)) arr.splice(insertAt, 0, { ...moved, planLoc: { ...planLocTemplate } }) return arr } /** Globales insertBeforeIndex, um einen Abschnitt hinter den letzten des Streams einzufügen (z. B. Rahmen-Slots). */ export function globalInsertBeforeIndexForParallelStreamEnd(sections, phaseOrderIndex, streamOrderIndex) { const arr = sections || [] const po = Number(phaseOrderIndex) || 0 const so = Number(streamOrderIndex) || 0 const streamIdx = sectionIndicesForParallelStream(arr, po, so) if (streamIdx.length) return Math.max(...streamIdx) + 1 const phaseIdx = indicesOfParallelPhase(arr, po) if (!phaseIdx.length) return arr.length return Math.max(...phaseIdx) + 1 } /** Alle globalen Indizes einer parallelen Phase (alle Streams), sortiert. */ export function indicesOfParallelPhase(sections, phaseOrderIndex) { const po = Number(phaseOrderIndex) || 0 const out = [] ;(sections || []).forEach((s, i) => { const L = s?.planLoc if (L?.phaseKind === 'parallel' && (L.phaseOrderIndex ?? 0) === po) out.push(i) }) return out.sort((a, b) => a - b) } /** Gesamten Parallel-Block an neue Position (insertBefore globale Liste) schieben. */ export function moveParallelPhaseRunToInsertBefore(prev, phaseOrderIndex, toBeforeIdx) { const po = Number(phaseOrderIndex) || 0 const indices = indicesOfParallelPhase(prev, po) if (!indices.length) return prev const indexSet = new Set(indices) const blocks = indices.map((i) => prev[i]) const without = prev.filter((_, i) => !indexSet.has(i)) let ins = toBeforeIdx for (const i of indices) { if (i < toBeforeIdx) ins -= 1 } ins = Math.max(0, Math.min(ins, without.length)) return [...without.slice(0, ins), ...blocks, ...without.slice(ins)] } /** * Nach Drag&Drop: wenn aus einer Parallelphase noch ≤1 Stream übrig ist (vorher ≥2), Rückfrage wie beim Stream-Löschen. */ export function afterSectionReorderParallelGuard(prev, next) { const seenPo = new Set() for (const s of prev || []) { const L = s?.planLoc if (L?.phaseKind !== 'parallel') continue seenPo.add(L.phaseOrderIndex ?? 0) } let out = next for (const po of seenPo) { const prevN = streamsForParallelPhaseOrders(prev, po).length if (prevN < 2) continue const nowN = streamsForParallelPhaseOrders(out, po).length if (nowN <= 1) { if ( window.confirm( 'In dieser parallelen Phase ist nur noch eine Gruppe übrig. Parallelaufbau auflösen und alle zugehörigen Abschnitte als gemeinsame Ganzgruppen-Phase führen?' ) ) { out = dissolveParallelPhaseToWholeGroup(out, po) } } } return out } 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 && canonicalPlanLocForPhasedSave(s.planLoc) != null) 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) }