All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m11s
Test Suite / pytest-backend (pull_request) Successful in 34s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 12s
Test Suite / k6 /health Baseline (pull_request) Successful in 34s
Test Suite / playwright-tests (pull_request) Successful in 1m20s
- Introduced a new utility function to determine when to prompt for a split rejoin transition between phases, improving user guidance during training sessions. - Updated TrainingCoachPage to incorporate this logic, enhancing the flow of navigation through training timelines. - Refactored button actions to provide clearer options for users when managing group transitions, optimizing the user experience during training.
623 lines
22 KiB
JavaScript
623 lines
22 KiB
JavaScript
/**
|
|
* 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
|
|
}
|