/** * Hilfen für Trainingsplan-Ansicht, Coach-Modus und API-Payload für PUT /training-units/:id. */ import { buildPlanPayloadForSave, cloneJsonSerializablePlanningProfile, defaultPlanLocWholeGroup, 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 } function maxPhaseOrderIndexFromNestedPhases(phases) { let m = -1 if (!Array.isArray(phases)) return m for (const ph of phases) { const po = Number(ph.order_index ?? ph.orderIndex ?? 0) if (Number.isFinite(po)) m = Math.max(m, po) } return m } 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 } }) const inherited = inheritPlanLocForPhasedSave(merged) const maxPhPo = maxPhaseOrderIndexFromNestedPhases(unit?.phases) return inherited.map((s) => { const sid = s.id != null ? Number(s.id) : NaN if (!Number.isFinite(sid) || byId.has(sid)) return s const pl = s.planLoc if (pl?.phaseKind === 'parallel') { const po = maxPhPo >= 0 ? maxPhPo + 1 : 0 return { ...s, planLoc: { ...defaultPlanLocWholeGroup(po), phaseTitle: pl.phaseTitle ?? null, phaseGuidanceNotes: pl.phaseGuidanceNotes ?? null, }, } } return s }) } /** * 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}` } export const COACH_ENTRY_BRANCH_GATE = 'branch_gate' /** Normalisierte Stream-Wahl pro paralleler Phase (Phase-Index → Stream-Order). */ export function normalizeCoachBranchPicks(raw) { const out = {} if (!raw || typeof raw !== 'object') return out for (const [k, v] of Object.entries(raw)) { const pk = parseInt(String(k), 10) const sv = typeof v === 'number' ? v : parseInt(String(v), 10) if (Number.isFinite(pk) && Number.isFinite(sv)) out[pk] = sv } return out } /** * URL-/Dropdown-Fokus `coachFocus` in Pick-Map mergen (fest gewählter Stream für eine Phase). * @param {object} branchPicks Roh-Picks (z. B. aus Session) * @param {{ phaseOrder: number, streamOrder: number }|null} coachFocusUrl z. B. ?po=&so= */ export function mergeCoachBranchPicksWithUrlFocus(branchPicks, coachFocusUrl) { const m = normalizeCoachBranchPicks(branchPicks) if ( coachFocusUrl != null && Number.isFinite(coachFocusUrl.phaseOrder) && Number.isFinite(coachFocusUrl.streamOrder) ) { m[coachFocusUrl.phaseOrder] = coachFocusUrl.streamOrder } return m } /** Kurzstring für SessionStorage-Schlüssel (Sortierung stabil). */ export function coachBranchPicksStepStorageSuffix(mergedPicks) { const keys = Object.keys(mergedPicks) .map((k) => parseInt(String(k), 10)) .filter((n) => Number.isFinite(n)) .sort((a, b) => a - b) if (!keys.length) return 'full' return keys.map((k) => `p${k}-s${mergedPicks[k]}`).join('_') } export function coachBranchPicksStorageKey(unitId) { return `sj_coach_branches_${unitId}` } /** * Index zum Springen: Co-Trainer-Link atBranch+preferSo — Gate falls noch offen, sonst erste Kachel im Stream. */ export function findCoachTimelineJumpIndexForPhase(timeline, phaseOrder, preferStreamOrder = null) { const po = Number(phaseOrder) if (!Number.isFinite(po) || !Array.isArray(timeline)) return -1 const hint = preferStreamOrder != null && Number.isFinite(Number(preferStreamOrder)) ? Number(preferStreamOrder) : null if (hint != null) { const ixStream = timeline.findIndex( (e) => e.entryKind !== COACH_ENTRY_BRANCH_GATE && e.runMeta?.kind === 'parallel' && e.runMeta.phaseOrderIndex === po && e.runMeta.streamOrder === hint ) if (ixStream >= 0) return ixStream } const ixGate = timeline.findIndex( (e) => e.entryKind === COACH_ENTRY_BRANCH_GATE && e.branchMeta?.phaseOrderIndex === po ) return ixGate } /** Gruppiert die flache Coach-Timeline für den Trainingsrahmen (Überschriften + Einträge). */ export function coachOutlineGroupsFromTimeline(timeline) { const groups = [] for (let ix = 0; ix < timeline.length; ix++) { const ent = timeline[ix] let mergeKey = `ix-${ix}` let heading = 'Ablauf' let sub = '' if (ent.entryKind === COACH_ENTRY_BRANCH_GATE) { const po = ent.branchMeta?.phaseOrderIndex ?? 0 mergeKey = `gate-${po}` heading = 'Split · Gruppe wählen' const pt = ent.branchMeta?.phaseTitle sub = pt != null && String(pt).trim() ? String(pt).trim() : `Parallele Phase ${po}` } else { const rm = ent.runMeta if (rm?.kind === 'whole_group') { mergeKey = `wg-${rm.phaseOrderIndex}` const pl = ent.sec?.planLoc const ptt = pl?.phaseTitle heading = 'Ganzgruppe' sub = ptt != null && String(ptt).trim() ? String(ptt).trim() : '' } else if (rm?.kind === 'parallel' && rm.streamOrder != null) { mergeKey = `par-${rm.phaseOrderIndex}-s${rm.streamOrder}` const pl = ent.sec?.planLoc const ptt = pl?.phaseTitle const stt = pl?.streamTitle const ptl = ptt != null && String(ptt).trim() ? String(ptt).trim() : `Phase ${rm.phaseOrderIndex}` const stl = stt != null && String(stt).trim() ? String(stt).trim() : `Gruppe ${rm.streamOrder + 1}` heading = `Parallel · ${ptl}` sub = stl } else if (rm?.kind === 'legacy') { mergeKey = 'legacy' heading = 'Ablauf' sub = '' } else { mergeKey = `misc-${ix}` } } const prev = groups[groups.length - 1] if (!prev || prev.mergeKey !== mergeKey) { groups.push({ mergeKey, heading, sub, entries: [{ ix, ent }] }) } else { prev.entries.push({ ix, ent }) } } return groups } /** * Flache Coach-Reihenfolge. Pro paralleler Phase ohne Eintrag in branchPicks: ein sichtbarer branch_gate, * damit keine verschränkten Split-Übungen, bis eine Gruppe gewählt wurde. * @param {object} unit * @param {object} branchPicks z. B. { 0: 1 } = Phase 0 → Stream 1 */ export function flattenPlanTimeline(unit, branchPicks = {}) { const sections = sectionsWithPlanLocForDisplay(unit) const model = buildPlanRunViewModelFromSections(sections) if (model.mode === 'empty') return [] const picks = normalizeCoachBranchPicks(branchPicks) const list = [] const pushSectionItems = (sec, coachCtx, runMeta) => { 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, runMeta: runMeta || null, }) }) } for (const run of model.runs) { if (run.kind === 'legacy') { const meta = { kind: 'legacy', phaseOrderIndex: 0, streamOrder: null } for (const sec of run.globalOrderSections) { pushSectionItems(sec, coachContextLabelForSection(sec, sections), meta) } continue } if (run.kind === 'whole_group') { const meta = { kind: 'whole_group', phaseOrderIndex: run.phaseOrderIndex, streamOrder: null } for (const sec of run.globalOrderSections) { pushSectionItems(sec, coachContextLabelForSection(sec, sections), meta) } continue } if (run.kind === 'parallel') { const po = run.phaseOrderIndex const chosen = picks[po] const hasPick = chosen !== undefined && chosen !== null && Number.isFinite(Number(chosen)) if (!hasPick) { list.push({ entryKind: COACH_ENTRY_BRANCH_GATE, si: -1, ii: -1, secOrder: -1, flatIndex: list.length, sec: null, item: null, coachContext: '', branchMeta: { phaseOrderIndex: po, phaseTitle: run.phaseTitle, streams: run.streams || [], }, runMeta: { kind: 'parallel', phaseOrderIndex: po, streamOrder: null }, }) } else { const st = run.streams?.find((x) => x.streamOrder === Number(chosen)) const meta = { kind: 'parallel', phaseOrderIndex: po, streamOrder: Number(chosen) } if (st?.sections?.length) { for (const sec of st.sections) { pushSectionItems(sec, coachContextLabelForSection(sec, sections), meta) } } } } } return list } /** Optionen für Coach-Stream-Auswahl (phases · Gruppe). */ export function listCoachStreamFocusOptions(unit) { const model = buildPlanRunViewModelFromSections(sectionsWithPlanLocForDisplay(unit)) const opts = [] for (const run of model.runs) { if (run.kind !== 'parallel' || !run.streams?.length) continue const phaseLabel = run.phaseTitle != null && String(run.phaseTitle).trim() ? String(run.phaseTitle).trim() : `Phase ${run.phaseOrderIndex}` for (const st of run.streams) { const gl = st.streamTitle != null && String(st.streamTitle).trim() ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}` opts.push({ phaseOrder: run.phaseOrderIndex, streamOrder: st.streamOrder, label: `${phaseLabel} · ${gl}`, valueKey: `${run.phaseOrderIndex}-${st.streamOrder}`, }) } } return opts } /** Alle Ist-Minuten-Overrides aus lokalem Delta-State für PUT (unabhängig von gefilterter Coach-Timeline). */ export function durationOverridesMapFromDeltas(unit, deltas) { const out = {} if (!unit || !deltas || typeof deltas !== 'object') return out const sections = sortedSections(unit) sections.forEach((sec, si) => { const secOrder = sec.order_index ?? si sortedItems(sec).forEach((it, ii) => { if (it.item_type !== 'exercise' || it.id == null) return const k = itemStableKey(it, secOrder, ii) const dv = deltas[k]?.actual_duration_min if (dv !== undefined && dv !== '' && dv !== null && !Number.isNaN(Number(dv))) { out[String(it.id)] = { actual_duration_min: Number(dv) } } }) }) return out } /** PUT-Body für Coach-Speichern: `phases` wenn Plan Phasen hat, sonst `sections` (wie Planungseditor). */ export function buildCoachSavePlanPayload(unit, durationOverridesByItemId = {}) { const withLoc = sectionsWithPlanLocForDisplay(unit) const withDur = withLoc.map((sec) => ({ ...sec, items: sortedItems(sec).map((it) => { if (it.item_type !== 'exercise' || it.id == null) return it const o = durationOverridesByItemId[String(it.id)] const av = o?.actual_duration_min if (av !== undefined && av !== '' && av !== null && Number.isFinite(Number(av))) { return { ...it, actual_duration_min: Number(av) } } return it }), })) return buildPlanPayloadForSave(withDur) } /** * Nach dem letzten Block eines Streams: Rückfrage, wenn die parallele Phase mehrere Gruppen hat. */ export function coachShouldPromptSplitRejoin(unit, lastTimelineEntry) { const rm = lastTimelineEntry?.runMeta if (!rm || rm.kind !== 'parallel' || rm.streamOrder == null) return null const model = buildPlanRunViewModelFromSections(sectionsWithPlanLocForDisplay(unit)) const run = model.runs.find((r) => r.kind === 'parallel' && r.phaseOrderIndex === rm.phaseOrderIndex) if (!run?.streams || run.streams.length <= 1) return null return { phaseOrderIndex: run.phaseOrderIndex, phaseTitle: run.phaseTitle, streams: run.streams, } } /** * Nach dem letzten Block eines gewählten Streams: Rückfrage vor Ganzgruppenphase oder vor dem nächsten Split, * wenn die aktuelle Parallelphase mehrere Streams hat. */ export function coachShouldPromptSplitRejoinTransition(unit, currentEntry, nextEntry) { if (!currentEntry || !nextEntry) return null const cRm = currentEntry.runMeta if (!cRm || cRm.kind !== 'parallel' || cRm.streamOrder == null) return null const intoWholeGroup = nextEntry.runMeta?.kind === 'whole_group' const intoNextSplit = nextEntry.entryKind === COACH_ENTRY_BRANCH_GATE if (!intoWholeGroup && !intoNextSplit) return null return coachShouldPromptSplitRejoin(unit, currentEntry) } export function summarizeTimelineEntry(ent) { if (!ent) return '' if (ent.entryKind === COACH_ENTRY_BRANCH_GATE) { const t = ent.branchMeta?.phaseTitle != null && String(ent.branchMeta.phaseTitle).trim() ? String(ent.branchMeta.phaseTitle).trim() : '' return t ? `Split wählen · ${t}` : 'Split · Gruppe wählen' } const { item } = ent 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 }