/** * Hilfen für Trainingsplan-Ansicht, Coach-Modus und API-Payload für PUT /training-units/:id. */ import { cloneJsonSerializablePlanningProfile, phaseRunsFromSections, sectionIndicesForParallelStream, streamsForParallelPhaseOrders, } from './trainingUnitSectionsForm' export function sortedSections(unit) { const raw = unit?.sections if (!Array.isArray(raw)) return [] return [...raw].sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) } export function sortedItems(sec) { const raw = sec?.items if (!Array.isArray(raw)) return [] return [...raw].sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) } export function itemStableKey(it, secOrder, ix) { if (it && it.id != null) return String(it.id) return `${secOrder}-${it?.item_type || 'row'}-${ix}` } export function sumExerciseMinutesInSection(sec) { let t = 0 for (const it of sortedItems(sec)) { if (it.item_type === 'exercise' && it.planned_duration_min != null) { const n = Number(it.planned_duration_min) if (Number.isFinite(n)) t += n } } return t } /** * Lesemodell für Plan & Ablauf / Druck / Coach: Phasenläufe mit Ganzgruppe vs. Split. * Legacy ohne planLoc: ein Block. */ export function buildPlanRunViewModel(unit) { const sections = sortedSections(unit) if (!sections.length) { return { mode: 'empty', runs: [], totalMin: 0, runCumulativeEnds: [] } } const runsMeta = phaseRunsFromSections(sections) const runs = [] let cum = 0 const runCumulativeEnds = [] if (runsMeta.length === 0) { const minutes = sections.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0) cum = minutes runCumulativeEnds.push(cum) runs.push({ kind: 'legacy', phaseOrderIndex: 0, phaseTitle: null, minutes, sections, streams: null, globalOrderSections: sections, }) return { mode: 'legacy', runs, totalMin: minutes, runCumulativeEnds } } for (const r of runsMeta) { const slice = sections.slice(r.start, r.end) if (r.phaseKind === 'whole_group') { const minutes = slice.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0) cum += minutes runCumulativeEnds.push(cum) const phaseTitle = slice[0]?.planLoc?.phaseTitle ?? null runs.push({ kind: 'whole_group', phaseOrderIndex: r.phaseOrderIndex, phaseTitle, minutes, sections: slice, streams: null, globalOrderSections: slice, }) } else { const po = r.phaseOrderIndex const streamOrders = streamsForParallelPhaseOrders(slice, po) const streams = streamOrders.map((so) => { const idxs = sectionIndicesForParallelStream(slice, po, so) const streamSecs = idxs.map((i) => slice[i]) const first = streamSecs[0] const streamTitle = first?.planLoc?.streamTitle ?? null const minutes = streamSecs.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0) return { streamOrder: so, streamTitle, minutes, sections: streamSecs, printStreamId: `p${po}-s${so}`, } }) const minutes = slice.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0) cum += minutes runCumulativeEnds.push(cum) const phaseTitle = slice[0]?.planLoc?.phaseTitle ?? null runs.push({ kind: 'parallel', phaseOrderIndex: po, phaseTitle, minutes, streams, sections: null, globalOrderSections: slice, }) } } return { mode: 'phased', runs, totalMin: cum, runCumulativeEnds } } function coachContextLabelForSection(sec, sectionsList) { const pl = sec?.planLoc if (!pl?.phaseKind) return 'Ablauf' if (pl.phaseKind === 'whole_group') { const pt = pl.phaseTitle != null && String(pl.phaseTitle).trim() ? String(pl.phaseTitle).trim() : null return pt ? `Ganzgruppe · ${pt}` : 'Ganzgruppe' } const pt = pl.phaseTitle != null && String(pl.phaseTitle).trim() ? String(pl.phaseTitle).trim() : `Phase ${pl.phaseOrderIndex ?? 0}` const so = pl.parallelStreamOrderIndex ?? 0 const st = pl.streamTitle != null && String(pl.streamTitle).trim() ? String(pl.streamTitle).trim() : `Gruppe ${so + 1}` return `Parallel · ${pt} · ${st}` } /** Flache Reihenfolge für Coach-Timeline (global wie im Editor, inkl. gemischter Split-Abschnitte). */ export function flattenPlanTimeline(unit) { const model = buildPlanRunViewModel(unit) if (model.mode === 'empty') return [] const sections = sortedSections(unit) const list = [] const pushSectionItems = (sec, coachCtx) => { const si = Math.max(0, sections.indexOf(sec)) const secOrder = sec.order_index ?? si sortedItems(sec).forEach((item, ii) => { list.push({ si, ii, secOrder, flatIndex: list.length, sec, item, coachContext: coachCtx, }) }) } for (const run of model.runs) { for (const sec of run.globalOrderSections) { pushSectionItems(sec, coachContextLabelForSection(sec, sections)) } } return list } export function summarizeTimelineEntry({ item }) { if (!item) return '' if (item.item_type === 'note') { const t = String(item.note_body || '').trim() return t.length > 72 ? `${t.slice(0, 70)}…` : t || 'Notiz' } const title = item.exercise_title || (item.exercise_id ? `Übung #${item.exercise_id}` : 'Übung') const vn = item.exercise_variant_name ? ` · ${item.exercise_variant_name}` : '' return `${title}${vn}` } /** Payload für PUT (schließt bestehendes Unit mit optionalen Overrides pro Abschnitt-Item-ID ab). */ export function sectionsToPutPayload(unit, durationOverridesByItemId = {}) { return sortedSections(unit).map((sec, si) => ({ order_index: sec.order_index ?? si, title: ((sec.title || '').trim() || 'Abschnitt'), guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null, ...(sec.source_template_section_id != null ? { source_template_section_id: sec.source_template_section_id } : {}), items: sortedItems(sec) .map((it, ii) => { if (it.item_type === 'note') { return { item_type: 'note', order_index: it.order_index ?? ii, note_body: it.note_body ?? '', } } const eid = it.exercise_id if (eid === '' || eid == null || Number.isNaN(Number(eid))) { return null } const isCombo = String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination' const vid = isCombo ? null : it.exercise_variant_id let actual = durationOverridesByItemId[String(it.id)]?.actual_duration_min ?? it.actual_duration_min if (actual === '' || actual === undefined) actual = null else actual = typeof actual === 'number' ? actual : parseInt(String(actual), 10) if (actual !== null && !Number.isFinite(actual)) actual = null const row = { item_type: 'exercise', order_index: it.order_index ?? ii, exercise_id: parseInt(String(eid), 10), exercise_variant_id: vid !== '' && vid != null && !Number.isNaN(Number(vid)) ? parseInt(String(vid), 10) : null, planned_duration_min: coalescePositiveInt(it.planned_duration_min), actual_duration_min: actual, notes: trimOrNull(it.notes), modifications: trimOrNull(it.modifications), } 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) { row.planning_method_profile = cleaned } } } return row }) .filter(Boolean), })) } function coalescePositiveInt(v) { if (v === '' || v === null || v === undefined) return null const n = parseInt(String(v), 10) return Number.isFinite(n) ? n : null } function trimOrNull(v) { const s = v != null ? String(v).trim() : '' return s ? s : null }