shinkan-jinkendo/frontend/src/utils/trainingPlanUtils.js

247 lines
8.1 KiB
JavaScript
Raw Normal View History

/**
* 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
}