/** * Hilfen für Trainingsplan-Ansicht, Coach-Modus und API-Payload für PUT /training-units/:id. */ import { cloneJsonSerializablePlanningProfile, inheritPlanLocForPhasedSave, 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 } /** * GET liefert `planLoc` oft nicht auf flachen `sections`, aber `unit.phases` (verschachtelt). * Baut pro Abschnitt `planLoc` für phaseRuns / Darstellung (camelCase wie im Editor). */ function planLocBySectionIdFromPhases(phases) { const byId = new Map() if (!Array.isArray(phases)) return byId for (const ph of phases) { const po = Number(ph.order_index ?? ph.orderIndex ?? 0) || 0 const pk = String(ph.phase_kind ?? ph.phaseKind ?? '') .toLowerCase() .trim() const phaseTitle = ph.title ?? ph.phaseTitle ?? null const phaseGuidanceNotes = ph.guidance_notes ?? ph.guidanceNotes ?? null if (pk === 'whole_group') { for (const sec of ph.sections || []) { const sid = sec.id != null ? Number(sec.id) : NaN if (!Number.isFinite(sid)) continue byId.set(sid, { phaseKind: 'whole_group', phaseOrderIndex: po, parallelStreamOrderIndex: null, phaseTitle, phaseGuidanceNotes, streamTitle: null, streamNotes: null, streamAssignedTrainerProfileIds: null, }) } } else if (pk === 'parallel') { for (const st of ph.streams || []) { const so = Number(st.order_index ?? st.orderIndex ?? 0) || 0 const streamTitle = st.title ?? st.streamTitle ?? null const streamNotes = st.notes ?? st.streamNotes ?? null const streamAssignedTrainerProfileIds = st.assigned_trainer_profile_ids ?? st.streamAssignedTrainerProfileIds ?? null for (const sec of st.sections || []) { const sid = sec.id != null ? Number(sec.id) : NaN if (!Number.isFinite(sid)) continue byId.set(sid, { phaseKind: 'parallel', phaseOrderIndex: po, parallelStreamOrderIndex: so, phaseTitle, phaseGuidanceNotes, streamTitle, streamNotes, streamAssignedTrainerProfileIds, }) } } } } return byId } export function sectionsWithPlanLocForDisplay(unit) { const sorted = sortedSections(unit) const byId = planLocBySectionIdFromPhases(unit?.phases) const merged = sorted.map((s) => { const sid = s.id != null ? Number(s.id) : NaN if (Number.isFinite(sid) && byId.has(sid)) { return { ...s, planLoc: { ...byId.get(sid) } } } if (s.planLoc && s.planLoc.phaseKind) return s return { ...s } }) return inheritPlanLocForPhasedSave(merged) } /** * Läuft auf bereits angereichter Abschnittsliste (gleiche Objektreferenzen wie in Slices). */ export function buildPlanRunViewModelFromSections(sections) { 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]) .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) 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 } } /** @param {object} unit Trainingseinheit inkl. `sections`, optional `phases` (GET) */ export function buildPlanRunViewModel(unit) { return buildPlanRunViewModelFromSections(sectionsWithPlanLocForDisplay(unit)) } 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 sections = sectionsWithPlanLocForDisplay(unit) const model = buildPlanRunViewModelFromSections(sections) if (model.mode === 'empty') return [] 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 }