2026-05-05 13:39:30 +02:00
|
|
|
|
import api from './api'
|
2026-05-13 09:58:59 +02:00
|
|
|
|
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
|
2026-05-05 13:39:30 +02:00
|
|
|
|
|
|
|
|
|
|
export function defaultSection(title = 'Hauptteil') {
|
|
|
|
|
|
return { title, guidance_notes: '', items: [] }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 07:25:59 +02:00
|
|
|
|
/** Standard-`planLoc` für eine Ganzgruppen-Phase (Editor-Breakout-UI). */
|
|
|
|
|
|
export function defaultPlanLocWholeGroup(phaseOrderIndex = 0) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
phaseKind: 'whole_group',
|
|
|
|
|
|
phaseOrderIndex,
|
|
|
|
|
|
parallelStreamOrderIndex: null,
|
|
|
|
|
|
phaseTitle: null,
|
|
|
|
|
|
phaseGuidanceNotes: null,
|
|
|
|
|
|
streamTitle: null,
|
|
|
|
|
|
streamNotes: null,
|
|
|
|
|
|
streamAssignedTrainerProfileIds: null,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Standard-`planLoc` für einen Stream innerhalb einer parallelen Phase. */
|
|
|
|
|
|
export function defaultPlanLocParallel(phaseOrderIndex, streamOrderIndex) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
phaseKind: 'parallel',
|
|
|
|
|
|
phaseOrderIndex,
|
|
|
|
|
|
parallelStreamOrderIndex: streamOrderIndex,
|
|
|
|
|
|
phaseTitle: null,
|
|
|
|
|
|
phaseGuidanceNotes: null,
|
|
|
|
|
|
streamTitle: null,
|
|
|
|
|
|
streamNotes: null,
|
|
|
|
|
|
streamAssignedTrainerProfileIds: null,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function planLocKey(pl) {
|
|
|
|
|
|
if (!pl || !pl.phaseKind) return ''
|
|
|
|
|
|
if (pl.phaseKind === 'whole_group') return `wg:${pl.phaseOrderIndex ?? 0}`
|
|
|
|
|
|
return `par:${pl.phaseOrderIndex ?? 0}:${pl.parallelStreamOrderIndex ?? 0}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function maxPhaseOrderIndexFromSections(sections) {
|
|
|
|
|
|
let m = -1
|
|
|
|
|
|
for (const s of sections || []) {
|
|
|
|
|
|
const pl = s?.planLoc
|
|
|
|
|
|
if (!pl || typeof pl.phaseOrderIndex !== 'number') continue
|
|
|
|
|
|
if (pl.phaseOrderIndex > m) m = pl.phaseOrderIndex
|
|
|
|
|
|
}
|
|
|
|
|
|
return m
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Eindeutige Ziele für die Zuordnung eines Abschnitts (Dropdown).
|
|
|
|
|
|
* `template` ist ein vollständiges planLoc-Objekt zum Kopieren.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function buildPlanTargetOptions(sections) {
|
|
|
|
|
|
const map = new Map()
|
|
|
|
|
|
for (const s of sections || []) {
|
|
|
|
|
|
const pl = s?.planLoc
|
|
|
|
|
|
if (!pl?.phaseKind) continue
|
|
|
|
|
|
if (pl.phaseKind === 'whole_group') {
|
|
|
|
|
|
const po = pl.phaseOrderIndex ?? 0
|
|
|
|
|
|
const k = `wg:${po}`
|
|
|
|
|
|
if (!map.has(k)) {
|
|
|
|
|
|
const title = pl.phaseTitle != null && String(pl.phaseTitle).trim() ? String(pl.phaseTitle).trim() : ''
|
|
|
|
|
|
map.set(k, {
|
|
|
|
|
|
key: k,
|
|
|
|
|
|
label: title || `Ganzgruppe · Phase ${po}`,
|
|
|
|
|
|
template: { ...pl },
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const po = pl.phaseOrderIndex ?? 0
|
|
|
|
|
|
const so = pl.parallelStreamOrderIndex ?? 0
|
|
|
|
|
|
const k = `par:${po}:${so}`
|
|
|
|
|
|
if (!map.has(k)) {
|
|
|
|
|
|
const st = pl.streamTitle != null && String(pl.streamTitle).trim() ? String(pl.streamTitle).trim() : ''
|
|
|
|
|
|
map.set(k, {
|
|
|
|
|
|
key: k,
|
|
|
|
|
|
label: st || `Parallel · Phase ${po} · Stream ${so}`,
|
|
|
|
|
|
template: { ...pl },
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return [...map.values()].sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 07:37:51 +02:00
|
|
|
|
/** Max. Streams pro paralleler Phase (UI + API-Schutz). */
|
|
|
|
|
|
export const MAX_PARALLEL_STREAMS_PER_PHASE = 5
|
|
|
|
|
|
|
|
|
|
|
|
/** Farben pro Stream-Index (max. 5 unterschiedliche Farbzyklen). */
|
|
|
|
|
|
export function parallelStreamVisual(streamOrderIndex) {
|
|
|
|
|
|
const n = Math.max(0, Number(streamOrderIndex) || 0)
|
|
|
|
|
|
const hues = [200, 135, 38, 285, 22]
|
|
|
|
|
|
const h = hues[n % hues.length]
|
|
|
|
|
|
return {
|
|
|
|
|
|
border: `hsl(${h} 50% 36%)`,
|
|
|
|
|
|
soft: `hsl(${h} 36% 94%)`,
|
|
|
|
|
|
tabBg: `hsl(${h} 34% 92%)`,
|
|
|
|
|
|
tabBgActive: `hsl(${h} 40% 82%)`,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function streamTabLabelFromIndices(sections, globalIndices) {
|
|
|
|
|
|
const first = globalIndices?.[0]
|
|
|
|
|
|
if (first === undefined || !sections?.[first]) return 'Stream'
|
|
|
|
|
|
const pl = sections[first].planLoc
|
|
|
|
|
|
const t = pl?.streamTitle != null && String(pl.streamTitle).trim() ? String(pl.streamTitle).trim() : ''
|
|
|
|
|
|
if (t) return t
|
|
|
|
|
|
const so = pl?.parallelStreamOrderIndex ?? 0
|
|
|
|
|
|
return `Stream ${so + 1}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Sortierte Stream-Indizes innerhalb einer parallelen Phase (für Reiter). */
|
|
|
|
|
|
export function streamsForParallelPhaseOrders(sections, phaseOrderIndex) {
|
|
|
|
|
|
const set = new Set()
|
|
|
|
|
|
const po = Number(phaseOrderIndex) || 0
|
|
|
|
|
|
for (const s of sections || []) {
|
|
|
|
|
|
const L = s?.planLoc
|
|
|
|
|
|
if (L?.phaseKind === 'parallel' && (L.phaseOrderIndex ?? 0) === po) {
|
|
|
|
|
|
set.add(L.parallelStreamOrderIndex ?? 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return [...set].sort((a, b) => a - b)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Globale Abschnitts-Indizes eines Streams. */
|
|
|
|
|
|
export function sectionIndicesForParallelStream(sections, phaseOrderIndex, streamOrderIndex) {
|
|
|
|
|
|
const out = []
|
|
|
|
|
|
const po = Number(phaseOrderIndex) || 0
|
|
|
|
|
|
const so = Number(streamOrderIndex) || 0
|
|
|
|
|
|
;(sections || []).forEach((s, i) => {
|
|
|
|
|
|
const L = s?.planLoc
|
|
|
|
|
|
if (L?.phaseKind === 'parallel' && (L.phaseOrderIndex ?? 0) === po && (L.parallelStreamOrderIndex ?? 0) === so) {
|
|
|
|
|
|
out.push(i)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 07:50:16 +02:00
|
|
|
|
/** Abschnitte an den angegebenen globalen Indizes entfernen (mindestens ein Abschnitt bleibt). */
|
|
|
|
|
|
export function reorderWithoutIndices(prev, removeGlobalIndices) {
|
|
|
|
|
|
const set = new Set(removeGlobalIndices || [])
|
|
|
|
|
|
const next = (prev || []).filter((_, i) => !set.has(i))
|
|
|
|
|
|
return next.length ? next : [defaultSection()]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Ob in den Abschnitten eines Stream-Buckets planerisch etwas steht (Übungen, Text-Anmerkungen).
|
|
|
|
|
|
* Trennlinien-Marker (---) zählen nicht als Inhalt.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function parallelStreamBucketHasContent(sections, globalIndices, separatorBody = '---') {
|
|
|
|
|
|
for (const gi of globalIndices || []) {
|
|
|
|
|
|
const sec = (sections || [])[gi]
|
|
|
|
|
|
if (!sec) continue
|
|
|
|
|
|
for (const it of sec.items || []) {
|
|
|
|
|
|
if ((it.item_type || '') === 'note') {
|
|
|
|
|
|
const b = (it.note_body || '').trim()
|
|
|
|
|
|
if (b && b !== separatorBody) return true
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (it.exercise_id) return true
|
|
|
|
|
|
if ((it.exercise_title || '').trim()) return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Parallele Phase auflösen: alle Abschnitte dieser Phase werden Ganzgruppe (gleicher phaseOrderIndex). */
|
|
|
|
|
|
export function dissolveParallelPhaseToWholeGroup(sections, phaseOrderIndex) {
|
|
|
|
|
|
const po = Number(phaseOrderIndex) || 0
|
|
|
|
|
|
return (sections || []).map((s) => {
|
|
|
|
|
|
const L = s?.planLoc
|
|
|
|
|
|
if (L?.phaseKind !== 'parallel' || (L.phaseOrderIndex ?? 0) !== po) return s
|
|
|
|
|
|
return {
|
|
|
|
|
|
...s,
|
|
|
|
|
|
planLoc: {
|
|
|
|
|
|
...defaultPlanLocWholeGroup(po),
|
|
|
|
|
|
phaseTitle: L.phaseTitle ?? null,
|
|
|
|
|
|
phaseGuidanceNotes: L.phaseGuidanceNotes ?? null,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 07:37:51 +02:00
|
|
|
|
export function reorderWithinBucketIndices(prev, bucketGlobalIndicesSorted, oldPos, newPos) {
|
|
|
|
|
|
const sortedIdx = [...bucketGlobalIndicesSorted].sort((a, b) => a - b)
|
|
|
|
|
|
if (oldPos === newPos || oldPos < 0 || newPos < 0 || oldPos >= sortedIdx.length || newPos >= sortedIdx.length) {
|
|
|
|
|
|
return prev
|
|
|
|
|
|
}
|
|
|
|
|
|
const values = sortedIdx.map((gi) => prev[gi])
|
|
|
|
|
|
const arr = [...values]
|
|
|
|
|
|
const [x] = arr.splice(oldPos, 1)
|
|
|
|
|
|
arr.splice(newPos, 0, x)
|
|
|
|
|
|
const next = [...prev]
|
|
|
|
|
|
sortedIdx.forEach((gi, k) => {
|
|
|
|
|
|
next[gi] = arr[k]
|
|
|
|
|
|
})
|
|
|
|
|
|
return next
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 07:21:33 +02:00
|
|
|
|
function normalizeCatalogMethodProfile(cp) {
|
|
|
|
|
|
if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp }
|
|
|
|
|
|
return {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 15:28:37 +02:00
|
|
|
|
/** NULL = Planung folgt Katalogprofil der Übung (Reihenfolge beibehalten: zuerst String-JSON auflösen). */
|
2026-05-13 07:21:33 +02:00
|
|
|
|
function normalizePlanningMethodProfile(pm) {
|
|
|
|
|
|
if (pm == null) return null
|
2026-05-13 15:28:37 +02:00
|
|
|
|
if (typeof pm === 'string') {
|
|
|
|
|
|
const t = pm.trim()
|
|
|
|
|
|
if (!t || t === 'null') return null
|
|
|
|
|
|
try {
|
|
|
|
|
|
const p = JSON.parse(t)
|
|
|
|
|
|
if (p && typeof p === 'object' && !Array.isArray(p)) return { ...p }
|
|
|
|
|
|
return null
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-13 07:21:33 +02:00
|
|
|
|
if (typeof pm === 'object' && !Array.isArray(pm)) return { ...pm }
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 15:28:37 +02:00
|
|
|
|
/** Reines JSON für PUT /training-units (vermeidet nicht serialisierbare Werte → 500). */
|
|
|
|
|
|
export function cloneJsonSerializablePlanningProfile(obj) {
|
|
|
|
|
|
if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) return null
|
|
|
|
|
|
try {
|
|
|
|
|
|
return JSON.parse(JSON.stringify(obj))
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 13:39:30 +02:00
|
|
|
|
export function exerciseRow() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
item_type: 'exercise',
|
|
|
|
|
|
exercise_id: '',
|
|
|
|
|
|
exercise_variant_id: '',
|
2026-05-13 06:30:53 +02:00
|
|
|
|
exercise_kind: 'simple',
|
2026-05-05 13:39:30 +02:00
|
|
|
|
exercise_title: '',
|
|
|
|
|
|
variants: [],
|
|
|
|
|
|
planned_duration_min: '',
|
|
|
|
|
|
actual_duration_min: '',
|
|
|
|
|
|
notes: '',
|
|
|
|
|
|
modifications: '',
|
2026-05-12 21:53:41 +02:00
|
|
|
|
source_training_module_id: '',
|
|
|
|
|
|
source_module_title: '',
|
2026-05-13 07:21:33 +02:00
|
|
|
|
catalog_method_archetype: '',
|
|
|
|
|
|
catalog_method_profile: {},
|
|
|
|
|
|
planning_method_profile: null,
|
2026-05-05 13:39:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 14:47:38 +02:00
|
|
|
|
export async function hydrateExercisePlanningRow(exercise) {
|
|
|
|
|
|
let variants = Array.isArray(exercise?.variants) ? exercise.variants : []
|
|
|
|
|
|
let title = exercise?.title || ''
|
2026-05-13 06:30:53 +02:00
|
|
|
|
let exerciseKind = exercise?.exercise_kind
|
2026-05-05 14:47:38 +02:00
|
|
|
|
const id = exercise?.id
|
|
|
|
|
|
if (!id) return null
|
2026-05-07 10:09:25 +02:00
|
|
|
|
let meta = {}
|
2026-05-13 06:30:53 +02:00
|
|
|
|
|
2026-05-13 07:21:33 +02:00
|
|
|
|
let full
|
|
|
|
|
|
|
|
|
|
|
|
async function ensureFull() {
|
|
|
|
|
|
if (full !== undefined) return full
|
2026-05-05 14:47:38 +02:00
|
|
|
|
try {
|
2026-05-13 07:21:33 +02:00
|
|
|
|
full = await api.getExercise(id)
|
2026-05-13 06:30:53 +02:00
|
|
|
|
} catch {
|
2026-05-13 07:21:33 +02:00
|
|
|
|
full = null
|
2026-05-13 06:30:53 +02:00
|
|
|
|
}
|
2026-05-13 07:21:33 +02:00
|
|
|
|
return full
|
2026-05-13 06:30:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!variants.length) {
|
2026-05-13 07:21:33 +02:00
|
|
|
|
await ensureFull()
|
2026-05-13 06:30:53 +02:00
|
|
|
|
if (full) {
|
|
|
|
|
|
variants = Array.isArray(full.variants) ? full.variants : []
|
|
|
|
|
|
title = full.title || title
|
|
|
|
|
|
if (exerciseKind == null) exerciseKind = full.exercise_kind
|
2026-05-07 10:09:25 +02:00
|
|
|
|
meta = {
|
2026-05-13 06:30:53 +02:00
|
|
|
|
exercise_visibility: full.visibility || 'private',
|
|
|
|
|
|
exercise_club_id: full.club_id ?? null,
|
|
|
|
|
|
exercise_created_by: full.created_by ?? null,
|
|
|
|
|
|
exercise_status: full.status || 'draft',
|
2026-05-13 07:21:33 +02:00
|
|
|
|
catalog_method_archetype: typeof full.method_archetype === 'string' ? full.method_archetype.trim() : '',
|
|
|
|
|
|
catalog_method_profile: normalizeCatalogMethodProfile(full.method_profile),
|
2026-05-07 10:09:25 +02:00
|
|
|
|
}
|
2026-05-05 14:47:38 +02:00
|
|
|
|
}
|
2026-05-07 10:09:25 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
meta = {
|
|
|
|
|
|
exercise_visibility: exercise?.visibility ?? null,
|
|
|
|
|
|
exercise_club_id: exercise?.club_id ?? null,
|
|
|
|
|
|
exercise_created_by: exercise?.created_by ?? null,
|
|
|
|
|
|
exercise_status: exercise?.status ?? null,
|
|
|
|
|
|
}
|
2026-05-13 06:30:53 +02:00
|
|
|
|
if (
|
|
|
|
|
|
meta.exercise_visibility == null ||
|
|
|
|
|
|
meta.exercise_created_by == null ||
|
|
|
|
|
|
exerciseKind == null
|
|
|
|
|
|
) {
|
2026-05-13 07:21:33 +02:00
|
|
|
|
await ensureFull()
|
2026-05-13 06:30:53 +02:00
|
|
|
|
if (full) {
|
|
|
|
|
|
if (meta.exercise_visibility == null) meta.exercise_visibility = full.visibility || 'private'
|
|
|
|
|
|
if (meta.exercise_club_id == null) meta.exercise_club_id = full.club_id ?? null
|
|
|
|
|
|
if (meta.exercise_created_by == null) meta.exercise_created_by = full.created_by ?? null
|
|
|
|
|
|
if (meta.exercise_status == null) meta.exercise_status = full.status || 'draft'
|
|
|
|
|
|
if (exerciseKind == null) exerciseKind = full.exercise_kind
|
|
|
|
|
|
if (!variants.length) variants = Array.isArray(full.variants) ? full.variants : []
|
2026-05-07 10:09:25 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
meta.exercise_visibility = meta.exercise_visibility || 'private'
|
|
|
|
|
|
meta.exercise_status = meta.exercise_status || 'draft'
|
2026-05-05 14:47:38 +02:00
|
|
|
|
}
|
2026-05-13 06:30:53 +02:00
|
|
|
|
|
2026-05-05 14:47:38 +02:00
|
|
|
|
const row = exerciseRow()
|
|
|
|
|
|
row.exercise_id = id
|
|
|
|
|
|
row.exercise_variant_id = ''
|
|
|
|
|
|
row.exercise_title = title
|
2026-05-13 06:30:53 +02:00
|
|
|
|
row.exercise_kind =
|
|
|
|
|
|
String(exerciseKind || 'simple').toLowerCase().trim() === 'combination' ? 'combination' : 'simple'
|
|
|
|
|
|
if (row.exercise_kind === 'combination') {
|
|
|
|
|
|
row.variants = []
|
|
|
|
|
|
row.exercise_variant_id = ''
|
|
|
|
|
|
} else {
|
|
|
|
|
|
row.variants = variants
|
|
|
|
|
|
}
|
2026-05-07 10:09:25 +02:00
|
|
|
|
Object.assign(row, meta)
|
2026-05-13 07:21:33 +02:00
|
|
|
|
if (row.exercise_kind === 'combination') {
|
|
|
|
|
|
if (full === undefined) await ensureFull()
|
|
|
|
|
|
if (full) {
|
|
|
|
|
|
row.catalog_method_archetype =
|
|
|
|
|
|
typeof full.method_archetype === 'string' ? full.method_archetype.trim() : ''
|
|
|
|
|
|
row.catalog_method_profile = normalizeCatalogMethodProfile(full.method_profile)
|
2026-05-13 09:58:59 +02:00
|
|
|
|
row.combination_slots = Array.isArray(full.combination_slots) ? full.combination_slots : []
|
2026-05-13 07:21:33 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
row.planning_method_profile = null
|
2026-05-05 14:47:38 +02:00
|
|
|
|
return row
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 13:39:30 +02:00
|
|
|
|
export function noteRow() {
|
2026-05-12 21:53:41 +02:00
|
|
|
|
return { item_type: 'note', note_body: '', source_training_module_id: '', source_module_title: '' }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Zur Serialisierung in die Planungs-API (persistente Modul-Herkunft). */
|
|
|
|
|
|
function parseOptionalSourceTrainingModuleIdForPayload(v) {
|
|
|
|
|
|
if (v === null || v === undefined || v === '') return null
|
|
|
|
|
|
const n = typeof v === 'number' ? v : parseInt(String(v).trim(), 10)
|
|
|
|
|
|
return Number.isFinite(n) && n >= 1 ? n : null
|
2026-05-05 13:39:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 07:12:43 +02:00
|
|
|
|
function sortByOrderIndex(arr) {
|
|
|
|
|
|
return [...(arr || [])].sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Katalog-Abschnitt (GET) → Editor-Zeilen inkl. Kombi/Modul-Meta — wird von Legacy `sections` und von `phases` genutzt. */
|
|
|
|
|
|
function formItemsFromApiItems(items) {
|
|
|
|
|
|
return (items || []).map((it) => {
|
|
|
|
|
|
if (it.item_type === 'note') {
|
|
|
|
|
|
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
|
|
|
|
|
const rowNote = {
|
|
|
|
|
|
item_type: 'note',
|
|
|
|
|
|
note_body: it.note_body || '',
|
|
|
|
|
|
source_training_module_id: '',
|
|
|
|
|
|
source_module_title: '',
|
|
|
|
|
|
}
|
|
|
|
|
|
if (sm != null) {
|
|
|
|
|
|
rowNote.source_training_module_id = sm
|
|
|
|
|
|
rowNote.source_module_title = (
|
|
|
|
|
|
it.source_module_title ||
|
|
|
|
|
|
it.source_training_module_title ||
|
|
|
|
|
|
''
|
|
|
|
|
|
).trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
return rowNote
|
|
|
|
|
|
}
|
|
|
|
|
|
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
|
|
|
|
|
const ek = String(it.exercise_kind || 'simple').toLowerCase().trim()
|
|
|
|
|
|
const isCombo = ek === 'combination'
|
|
|
|
|
|
return {
|
|
|
|
|
|
item_type: 'exercise',
|
|
|
|
|
|
exercise_id: it.exercise_id,
|
|
|
|
|
|
exercise_kind: isCombo ? 'combination' : 'simple',
|
|
|
|
|
|
exercise_variant_id: isCombo ? '' : it.exercise_variant_id ?? '',
|
|
|
|
|
|
exercise_title: it.exercise_title || '',
|
|
|
|
|
|
variants: [],
|
|
|
|
|
|
planned_duration_min:
|
|
|
|
|
|
it.planned_duration_min !== null && it.planned_duration_min !== undefined
|
|
|
|
|
|
? String(it.planned_duration_min)
|
|
|
|
|
|
: '',
|
|
|
|
|
|
actual_duration_min:
|
|
|
|
|
|
it.actual_duration_min !== null && it.actual_duration_min !== undefined
|
|
|
|
|
|
? String(it.actual_duration_min)
|
|
|
|
|
|
: '',
|
|
|
|
|
|
notes: it.notes ?? '',
|
|
|
|
|
|
modifications: it.modifications ?? '',
|
|
|
|
|
|
catalog_method_archetype: String(it.catalog_method_archetype ?? '').trim(),
|
|
|
|
|
|
catalog_method_profile: normalizeCatalogMethodProfile(it.catalog_method_profile),
|
|
|
|
|
|
planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile),
|
|
|
|
|
|
...(smEx != null
|
|
|
|
|
|
? {
|
|
|
|
|
|
source_training_module_id: smEx,
|
|
|
|
|
|
source_module_title: (
|
2026-05-12 21:53:41 +02:00
|
|
|
|
it.source_module_title ||
|
|
|
|
|
|
it.source_training_module_title ||
|
|
|
|
|
|
''
|
2026-05-15 07:12:43 +02:00
|
|
|
|
).trim(),
|
2026-05-12 21:53:41 +02:00
|
|
|
|
}
|
2026-05-15 07:12:43 +02:00
|
|
|
|
: {}),
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** GET `phases` → flache Editor-Abschnitte mit `planLoc` (für PUT `phases` Roundtrip). */
|
|
|
|
|
|
function normalizePhasesToFormSections(fullUnit) {
|
|
|
|
|
|
const phases = sortByOrderIndex(fullUnit.phases || [])
|
|
|
|
|
|
const out = []
|
|
|
|
|
|
for (const ph of phases) {
|
|
|
|
|
|
const pOi = ph.order_index ?? 0
|
|
|
|
|
|
const pk = String(ph.phase_kind || 'whole_group').toLowerCase().trim()
|
|
|
|
|
|
const basePhaseLoc = {
|
|
|
|
|
|
phaseTitle: ph.title ?? null,
|
|
|
|
|
|
phaseGuidanceNotes: ph.guidance_notes ?? null,
|
|
|
|
|
|
}
|
|
|
|
|
|
if (pk === 'parallel') {
|
|
|
|
|
|
for (const st of sortByOrderIndex(ph.streams || [])) {
|
|
|
|
|
|
const sOi = st.order_index ?? 0
|
|
|
|
|
|
const streamLoc = {
|
|
|
|
|
|
phaseKind: 'parallel',
|
|
|
|
|
|
phaseOrderIndex: pOi,
|
|
|
|
|
|
parallelStreamOrderIndex: sOi,
|
|
|
|
|
|
...basePhaseLoc,
|
|
|
|
|
|
streamTitle: st.title ?? null,
|
|
|
|
|
|
streamNotes: st.notes ?? null,
|
|
|
|
|
|
streamAssignedTrainerProfileIds: st.assigned_trainer_profile_ids ?? null,
|
2026-05-05 13:39:30 +02:00
|
|
|
|
}
|
2026-05-15 07:12:43 +02:00
|
|
|
|
for (const sec of sortByOrderIndex(st.sections || [])) {
|
|
|
|
|
|
out.push({
|
|
|
|
|
|
title: sec.title,
|
|
|
|
|
|
guidance_notes: sec.guidance_notes || '',
|
|
|
|
|
|
items: formItemsFromApiItems(sec.items),
|
|
|
|
|
|
planLoc: { ...streamLoc },
|
|
|
|
|
|
})
|
2026-05-05 13:39:30 +02:00
|
|
|
|
}
|
2026-05-15 07:12:43 +02:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const loc = {
|
|
|
|
|
|
phaseKind: 'whole_group',
|
|
|
|
|
|
phaseOrderIndex: pOi,
|
|
|
|
|
|
parallelStreamOrderIndex: null,
|
|
|
|
|
|
...basePhaseLoc,
|
|
|
|
|
|
streamTitle: null,
|
|
|
|
|
|
streamNotes: null,
|
|
|
|
|
|
streamAssignedTrainerProfileIds: null,
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const sec of sortByOrderIndex(ph.sections || [])) {
|
|
|
|
|
|
out.push({
|
|
|
|
|
|
title: sec.title,
|
|
|
|
|
|
guidance_notes: sec.guidance_notes || '',
|
|
|
|
|
|
items: formItemsFromApiItems(sec.items),
|
|
|
|
|
|
planLoc: { ...loc },
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out.length ? out : [defaultSection()]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function normalizeUnitToForm(fullUnit) {
|
|
|
|
|
|
if (Array.isArray(fullUnit?.phases) && fullUnit.phases.length > 0) {
|
|
|
|
|
|
return normalizePhasesToFormSections(fullUnit)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (fullUnit.sections && fullUnit.sections.length) {
|
|
|
|
|
|
return fullUnit.sections.map((sec) => ({
|
|
|
|
|
|
title: sec.title,
|
|
|
|
|
|
guidance_notes: sec.guidance_notes || '',
|
|
|
|
|
|
items: formItemsFromApiItems(sec.items),
|
2026-05-05 13:39:30 +02:00
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
if (fullUnit.exercises && fullUnit.exercises.length) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
title: 'Übungen',
|
|
|
|
|
|
guidance_notes: '',
|
2026-05-13 06:30:53 +02:00
|
|
|
|
items: fullUnit.exercises.map((ex) => {
|
|
|
|
|
|
const ek = String(ex.exercise_kind || 'simple').toLowerCase().trim()
|
|
|
|
|
|
const isCombo = ek === 'combination'
|
|
|
|
|
|
return {
|
|
|
|
|
|
item_type: 'exercise',
|
|
|
|
|
|
exercise_kind: ek,
|
|
|
|
|
|
exercise_id: ex.exercise_id,
|
|
|
|
|
|
exercise_variant_id: isCombo ? '' : (ex.exercise_variant_id ?? ''),
|
|
|
|
|
|
exercise_title: ex.exercise_title || '',
|
|
|
|
|
|
variants: [],
|
|
|
|
|
|
planned_duration_min:
|
|
|
|
|
|
ex.planned_duration_min !== null && ex.planned_duration_min !== undefined
|
|
|
|
|
|
? String(ex.planned_duration_min)
|
|
|
|
|
|
: '',
|
|
|
|
|
|
actual_duration_min:
|
|
|
|
|
|
ex.actual_duration_min !== null && ex.actual_duration_min !== undefined
|
|
|
|
|
|
? String(ex.actual_duration_min)
|
|
|
|
|
|
: '',
|
|
|
|
|
|
notes: ex.notes ?? '',
|
|
|
|
|
|
modifications: ex.modifications ?? '',
|
2026-05-13 07:21:33 +02:00
|
|
|
|
catalog_method_archetype: String(ex.catalog_method_archetype ?? '').trim(),
|
|
|
|
|
|
catalog_method_profile: normalizeCatalogMethodProfile(ex.catalog_method_profile),
|
|
|
|
|
|
planning_method_profile: normalizePlanningMethodProfile(ex.planning_method_profile),
|
2026-05-13 06:30:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
}),
|
2026-05-05 13:39:30 +02:00
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
return [defaultSection()]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function enrichSectionsWithVariants(sections) {
|
|
|
|
|
|
if (!sections?.length) return sections
|
|
|
|
|
|
const ids = []
|
|
|
|
|
|
for (const sec of sections) {
|
|
|
|
|
|
for (const it of sec.items || []) {
|
|
|
|
|
|
if (it.item_type === 'note') continue
|
|
|
|
|
|
if (it.exercise_id) ids.push(it.exercise_id)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const unique = [...new Set(ids)]
|
|
|
|
|
|
const cache = new Map()
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
|
unique.map(async (id) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const ex = await api.getExercise(id)
|
2026-05-13 09:58:59 +02:00
|
|
|
|
const ek = String(ex.exercise_kind || 'simple').toLowerCase().trim()
|
2026-05-05 13:39:30 +02:00
|
|
|
|
cache.set(id, {
|
|
|
|
|
|
title: ex.title || '',
|
2026-05-13 09:58:59 +02:00
|
|
|
|
exercise_kind: ek,
|
2026-05-05 13:39:30 +02:00
|
|
|
|
variants: Array.isArray(ex.variants) ? ex.variants : [],
|
2026-05-07 10:09:25 +02:00
|
|
|
|
visibility: ex.visibility || 'private',
|
|
|
|
|
|
club_id: ex.club_id ?? null,
|
|
|
|
|
|
created_by: ex.created_by ?? null,
|
|
|
|
|
|
status: ex.status || 'draft',
|
2026-05-13 07:21:33 +02:00
|
|
|
|
method_archetype: typeof ex.method_archetype === 'string' ? ex.method_archetype.trim() : '',
|
|
|
|
|
|
method_profile: normalizeCatalogMethodProfile(ex.method_profile),
|
2026-05-13 09:58:59 +02:00
|
|
|
|
combination_slots: ek === 'combination' && Array.isArray(ex.combination_slots) ? ex.combination_slots : [],
|
2026-05-05 13:39:30 +02:00
|
|
|
|
})
|
|
|
|
|
|
} catch {
|
2026-05-07 10:09:25 +02:00
|
|
|
|
cache.set(id, {
|
|
|
|
|
|
title: '',
|
2026-05-13 06:30:53 +02:00
|
|
|
|
exercise_kind: 'simple',
|
2026-05-07 10:09:25 +02:00
|
|
|
|
variants: [],
|
|
|
|
|
|
visibility: 'private',
|
|
|
|
|
|
club_id: null,
|
|
|
|
|
|
created_by: null,
|
|
|
|
|
|
status: 'draft',
|
2026-05-13 07:21:33 +02:00
|
|
|
|
method_archetype: '',
|
|
|
|
|
|
method_profile: {},
|
2026-05-13 09:58:59 +02:00
|
|
|
|
combination_slots: [],
|
2026-05-07 10:09:25 +02:00
|
|
|
|
})
|
2026-05-05 13:39:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
2026-05-13 14:11:53 +02:00
|
|
|
|
|
|
|
|
|
|
const titleById = new Map()
|
|
|
|
|
|
for (const id of unique) {
|
|
|
|
|
|
const row = cache.get(id)
|
|
|
|
|
|
const t = (row?.title || '').trim()
|
|
|
|
|
|
if (t) titleById.set(Number(id), t)
|
|
|
|
|
|
}
|
|
|
|
|
|
const comboCandidateExtra = new Set()
|
|
|
|
|
|
for (const id of unique) {
|
|
|
|
|
|
const row = cache.get(id)
|
|
|
|
|
|
if (String(row?.exercise_kind || '').toLowerCase().trim() !== 'combination') continue
|
|
|
|
|
|
for (const slot of row.combination_slots || []) {
|
|
|
|
|
|
for (const raw of slot.candidate_exercise_ids || []) {
|
|
|
|
|
|
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
|
|
|
|
|
|
if (Number.isFinite(n) && !titleById.has(n)) comboCandidateExtra.add(n)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
|
[...comboCandidateExtra].map(async (cid) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const ex = await api.getExercise(cid)
|
|
|
|
|
|
titleById.set(cid, ((ex.title || '').trim() || `Übung #${cid}`))
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
titleById.set(cid, `Übung #${cid}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
function comboMemberTitleByIdForSlots(slots) {
|
|
|
|
|
|
const o = {}
|
|
|
|
|
|
for (const slot of slots || []) {
|
|
|
|
|
|
for (const raw of slot.candidate_exercise_ids || []) {
|
|
|
|
|
|
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
|
|
|
|
|
|
if (!Number.isFinite(n)) continue
|
|
|
|
|
|
const key = String(n)
|
|
|
|
|
|
if (!o[key]) o[key] = titleById.get(n) || `Übung #${n}`
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return o
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 13:39:30 +02:00
|
|
|
|
return sections.map((sec) => ({
|
|
|
|
|
|
...sec,
|
|
|
|
|
|
items: (sec.items || []).map((it) => {
|
|
|
|
|
|
if (it.item_type === 'note') return it
|
|
|
|
|
|
if (!it.exercise_id) return it
|
|
|
|
|
|
const c = cache.get(it.exercise_id)
|
|
|
|
|
|
if (!c) return it
|
2026-05-13 06:30:53 +02:00
|
|
|
|
const ek = String(c.exercise_kind || 'simple').toLowerCase().trim()
|
|
|
|
|
|
const isCombo = ek === 'combination'
|
2026-05-13 07:21:33 +02:00
|
|
|
|
const itemCatalog = normalizeCatalogMethodProfile(it.catalog_method_profile)
|
|
|
|
|
|
const catalog_method_profile =
|
|
|
|
|
|
Object.keys(itemCatalog).length > 0
|
|
|
|
|
|
? itemCatalog
|
|
|
|
|
|
: normalizeCatalogMethodProfile(c.method_profile)
|
|
|
|
|
|
const rowArche = String(it.catalog_method_archetype ?? '').trim()
|
|
|
|
|
|
const catalog_method_archetype = rowArche || String(c.method_archetype ?? '').trim()
|
2026-05-05 13:39:30 +02:00
|
|
|
|
return {
|
|
|
|
|
|
...it,
|
2026-05-13 07:21:33 +02:00
|
|
|
|
catalog_method_archetype,
|
|
|
|
|
|
catalog_method_profile,
|
|
|
|
|
|
planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile),
|
2026-05-13 06:30:53 +02:00
|
|
|
|
exercise_kind: isCombo ? 'combination' : 'simple',
|
2026-05-05 13:39:30 +02:00
|
|
|
|
exercise_title: it.exercise_title || c.title,
|
2026-05-13 06:30:53 +02:00
|
|
|
|
exercise_variant_id: isCombo ? '' : it.exercise_variant_id,
|
2026-05-05 13:39:30 +02:00
|
|
|
|
variants:
|
2026-05-13 06:30:53 +02:00
|
|
|
|
isCombo ? [] : Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants,
|
2026-05-07 10:09:25 +02:00
|
|
|
|
exercise_visibility: c.visibility,
|
|
|
|
|
|
exercise_club_id: c.club_id,
|
|
|
|
|
|
exercise_created_by: c.created_by,
|
|
|
|
|
|
exercise_status: c.status,
|
2026-05-13 14:11:53 +02:00
|
|
|
|
...(isCombo ? { combination_slots: c.combination_slots || [], combo_member_title_by_id: comboMemberTitleByIdForSlots(c.combination_slots || []) } : {}),
|
2026-05-05 13:39:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
}),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 09:58:59 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Outline für CombinationMethodProfileEditor: pro‑Slot‑Zeiten nur sichtbar, wenn Stationen übergeben werden.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function comboSlotsOutlineForProfileEditor(combinationSlots) {
|
|
|
|
|
|
if (!Array.isArray(combinationSlots) || combinationSlots.length === 0) return null
|
|
|
|
|
|
const sorted = sortCombinationSlotsForDisplay(combinationSlots)
|
|
|
|
|
|
return sorted.map((s, i) => {
|
|
|
|
|
|
const rawIx = s.slot_index
|
|
|
|
|
|
const si =
|
|
|
|
|
|
rawIx === '' || rawIx == null ? null : typeof rawIx === 'number' ? rawIx : parseInt(String(rawIx), 10)
|
|
|
|
|
|
return {
|
|
|
|
|
|
slot_index: Number.isFinite(si) ? si : i,
|
|
|
|
|
|
title: (s.title != null ? String(s.title) : '').trim(),
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 13:39:30 +02:00
|
|
|
|
export function parseMin(v) {
|
|
|
|
|
|
if (v === '' || v === null || v === undefined) return null
|
|
|
|
|
|
const n = parseInt(String(v), 10)
|
|
|
|
|
|
return Number.isFinite(n) ? n : null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 07:12:43 +02:00
|
|
|
|
export function buildOneSectionPayload(sec, orderIndex) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
order_index: orderIndex,
|
2026-05-05 13:39:30 +02:00
|
|
|
|
title: (sec.title || '').trim() || 'Abschnitt',
|
|
|
|
|
|
guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null,
|
|
|
|
|
|
items: (sec.items || [])
|
|
|
|
|
|
.map((it, ii) => {
|
|
|
|
|
|
if (it.item_type === 'note') {
|
2026-05-12 21:53:41 +02:00
|
|
|
|
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
|
|
|
|
|
const row = {
|
2026-05-05 13:39:30 +02:00
|
|
|
|
item_type: 'note',
|
|
|
|
|
|
order_index: ii,
|
|
|
|
|
|
note_body: it.note_body ?? '',
|
|
|
|
|
|
}
|
2026-05-12 21:53:41 +02:00
|
|
|
|
if (sm != null) row.source_training_module_id = sm
|
|
|
|
|
|
return row
|
2026-05-05 13:39:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
if (it.exercise_id === '' || it.exercise_id == null || Number.isNaN(Number(it.exercise_id))) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
2026-05-13 06:30:53 +02:00
|
|
|
|
const isCombo = String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
|
|
|
|
|
|
const vid = isCombo ? null : it.exercise_variant_id
|
2026-05-12 21:53:41 +02:00
|
|
|
|
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
|
|
|
|
|
const rowEx = {
|
2026-05-05 13:39:30 +02:00
|
|
|
|
item_type: 'exercise',
|
|
|
|
|
|
order_index: ii,
|
|
|
|
|
|
exercise_id: parseInt(it.exercise_id, 10),
|
|
|
|
|
|
exercise_variant_id:
|
|
|
|
|
|
vid !== '' && vid != null && !Number.isNaN(Number(vid)) ? parseInt(vid, 10) : null,
|
|
|
|
|
|
planned_duration_min: parseMin(it.planned_duration_min),
|
|
|
|
|
|
actual_duration_min: parseMin(it.actual_duration_min),
|
|
|
|
|
|
notes: it.notes?.trim() ? it.notes.trim() : null,
|
|
|
|
|
|
modifications: it.modifications?.trim() ? it.modifications.trim() : null,
|
|
|
|
|
|
}
|
2026-05-13 07:21:33 +02:00
|
|
|
|
if (isCombo) {
|
|
|
|
|
|
const pmp = it.planning_method_profile
|
|
|
|
|
|
if (pmp != null && typeof pmp === 'object' && !Array.isArray(pmp)) {
|
2026-05-13 15:28:37 +02:00
|
|
|
|
const cleaned = cloneJsonSerializablePlanningProfile(pmp)
|
|
|
|
|
|
if (cleaned && Object.keys(cleaned).length > 0) {
|
|
|
|
|
|
rowEx.planning_method_profile = cleaned
|
|
|
|
|
|
}
|
2026-05-13 07:21:33 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-12 21:53:41 +02:00
|
|
|
|
if (smEx != null) rowEx.source_training_module_id = smEx
|
|
|
|
|
|
return rowEx
|
2026-05-05 13:39:30 +02:00
|
|
|
|
})
|
|
|
|
|
|
.filter(Boolean),
|
2026-05-15 07:12:43 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** PUT /api/training-units: Legacy `sections` (eine whole_group-Phase) wenn kein `planLoc` gesetzt ist. */
|
|
|
|
|
|
export function buildSectionsPayload(sections) {
|
|
|
|
|
|
return sections.map((sec, si) => buildOneSectionPayload(sec, si))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stripPlanLoc(sec) {
|
|
|
|
|
|
if (!sec || typeof sec !== 'object') return sec
|
|
|
|
|
|
const { planLoc: _pl, ...rest } = sec
|
|
|
|
|
|
return rest
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function inheritPlanLocForPhasedSave(sections) {
|
|
|
|
|
|
let prev = {
|
|
|
|
|
|
phaseKind: 'whole_group',
|
|
|
|
|
|
phaseOrderIndex: 0,
|
|
|
|
|
|
parallelStreamOrderIndex: null,
|
|
|
|
|
|
phaseTitle: null,
|
|
|
|
|
|
phaseGuidanceNotes: null,
|
|
|
|
|
|
streamTitle: null,
|
|
|
|
|
|
streamNotes: null,
|
|
|
|
|
|
streamAssignedTrainerProfileIds: null,
|
|
|
|
|
|
}
|
|
|
|
|
|
return sections.map((s) => {
|
|
|
|
|
|
if (s?.planLoc && s.planLoc.phaseKind) {
|
|
|
|
|
|
prev = { ...s.planLoc }
|
|
|
|
|
|
return { ...s, planLoc: prev }
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ...s, planLoc: { ...prev } }
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildPhasesPayloadFromFlat(sections) {
|
|
|
|
|
|
const norm = inheritPlanLocForPhasedSave(sections)
|
|
|
|
|
|
const phases = []
|
|
|
|
|
|
let i = 0
|
|
|
|
|
|
while (i < norm.length) {
|
|
|
|
|
|
const loc0 = norm[i].planLoc
|
|
|
|
|
|
const pOi = loc0.phaseOrderIndex ?? 0
|
|
|
|
|
|
const pk = loc0.phaseKind === 'parallel' ? 'parallel' : 'whole_group'
|
|
|
|
|
|
|
|
|
|
|
|
const run = []
|
|
|
|
|
|
while (i < norm.length) {
|
|
|
|
|
|
const L = norm[i].planLoc
|
|
|
|
|
|
const pk2 = L.phaseKind === 'parallel' ? 'parallel' : 'whole_group'
|
|
|
|
|
|
if ((L.phaseOrderIndex ?? 0) !== pOi || pk2 !== pk) break
|
|
|
|
|
|
run.push(norm[i])
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const head = run[0].planLoc
|
|
|
|
|
|
if (pk === 'whole_group') {
|
|
|
|
|
|
phases.push({
|
|
|
|
|
|
phase_kind: 'whole_group',
|
|
|
|
|
|
order_index: pOi,
|
|
|
|
|
|
title: head.phaseTitle ?? null,
|
|
|
|
|
|
guidance_notes: head.phaseGuidanceNotes ?? null,
|
|
|
|
|
|
sections: run.map((s, idx) => buildOneSectionPayload(stripPlanLoc(s), idx)),
|
|
|
|
|
|
})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const byStream = new Map()
|
|
|
|
|
|
for (const s of run) {
|
|
|
|
|
|
const soi = s.planLoc.parallelStreamOrderIndex ?? 0
|
|
|
|
|
|
if (!byStream.has(soi)) byStream.set(soi, [])
|
|
|
|
|
|
byStream.get(soi).push(s)
|
|
|
|
|
|
}
|
|
|
|
|
|
const streamOrder = [...byStream.keys()].sort((a, b) => a - b)
|
|
|
|
|
|
const streams = streamOrder.map((soi) => {
|
|
|
|
|
|
const bucket = byStream.get(soi)
|
|
|
|
|
|
const h = bucket[0].planLoc
|
|
|
|
|
|
const st = {
|
|
|
|
|
|
order_index: soi,
|
|
|
|
|
|
title: h.streamTitle ?? null,
|
|
|
|
|
|
notes: h.streamNotes ?? null,
|
|
|
|
|
|
sections: bucket.map((s, idx) => buildOneSectionPayload(stripPlanLoc(s), idx)),
|
|
|
|
|
|
}
|
|
|
|
|
|
const asst = h.streamAssignedTrainerProfileIds
|
|
|
|
|
|
if (asst !== null && asst !== undefined) st.assigned_trainer_profile_ids = asst
|
|
|
|
|
|
return st
|
|
|
|
|
|
})
|
|
|
|
|
|
phases.push({
|
|
|
|
|
|
phase_kind: 'parallel',
|
|
|
|
|
|
order_index: pOi,
|
|
|
|
|
|
title: head.phaseTitle ?? null,
|
|
|
|
|
|
guidance_notes: head.phaseGuidanceNotes ?? null,
|
|
|
|
|
|
streams,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return { phases }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Speichern einer Einheit: flache `sections` oder verschachtelte `phases`, sobald `planLoc` gesetzt ist.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function buildPlanPayloadForSave(sections) {
|
|
|
|
|
|
const list = Array.isArray(sections) ? sections : []
|
|
|
|
|
|
const anyPhased = list.some((s) => s && s.planLoc && s.planLoc.phaseKind)
|
|
|
|
|
|
if (!anyPhased) {
|
|
|
|
|
|
return { sections: buildSectionsPayload(list) }
|
|
|
|
|
|
}
|
|
|
|
|
|
return buildPhasesPayloadFromFlat(list)
|
2026-05-05 13:39:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 21:53:41 +02:00
|
|
|
|
/** Fügt die Positionen eines Moduls in lokale Abschnitte ein (wie eine Übung, ohne Zwischenspeichern der Einheit). */
|
|
|
|
|
|
export async function insertTrainingModuleIntoPlanningSections({
|
|
|
|
|
|
sections,
|
|
|
|
|
|
moduleDetail,
|
|
|
|
|
|
sectionIndex,
|
|
|
|
|
|
insertBeforeItemIndex,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const secIx = typeof sectionIndex === 'number' ? sectionIndex : parseInt(String(sectionIndex), 10)
|
|
|
|
|
|
if (
|
|
|
|
|
|
!Array.isArray(sections) ||
|
|
|
|
|
|
!Number.isFinite(secIx) ||
|
|
|
|
|
|
secIx < 0 ||
|
|
|
|
|
|
secIx >= sections.length ||
|
|
|
|
|
|
!moduleDetail ||
|
|
|
|
|
|
typeof moduleDetail !== 'object'
|
|
|
|
|
|
) {
|
|
|
|
|
|
return sections
|
|
|
|
|
|
}
|
|
|
|
|
|
const prev = [...(sections[secIx].items || [])]
|
|
|
|
|
|
let beforeIx
|
|
|
|
|
|
if (insertBeforeItemIndex === null || insertBeforeItemIndex === undefined || insertBeforeItemIndex === 'end') {
|
|
|
|
|
|
beforeIx = prev.length
|
|
|
|
|
|
} else if (insertBeforeItemIndex === 'start') {
|
|
|
|
|
|
beforeIx = 0
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const n = typeof insertBeforeItemIndex === 'number' ? insertBeforeItemIndex : parseInt(String(insertBeforeItemIndex), 10)
|
|
|
|
|
|
beforeIx = Number.isFinite(n) ? Math.min(Math.max(n, 0), prev.length) : prev.length
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const midRaw = moduleDetail.id
|
|
|
|
|
|
const midNum = typeof midRaw === 'number' ? midRaw : parseInt(String(midRaw), 10)
|
|
|
|
|
|
if (!Number.isFinite(midNum) || midNum < 1) return sections
|
|
|
|
|
|
|
|
|
|
|
|
const modTitle = (moduleDetail.title || '').trim() || `Modul #${midNum}`
|
|
|
|
|
|
const modItems = [...(moduleDetail.items || [])].sort(
|
|
|
|
|
|
(a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const appendRows = []
|
|
|
|
|
|
for (const mi of modItems) {
|
|
|
|
|
|
if (mi.item_type === 'note') {
|
|
|
|
|
|
appendRows.push({
|
|
|
|
|
|
item_type: 'note',
|
|
|
|
|
|
note_body: mi.note_body || '',
|
|
|
|
|
|
source_training_module_id: midNum,
|
|
|
|
|
|
source_module_title: modTitle,
|
|
|
|
|
|
})
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!mi.exercise_id) continue
|
|
|
|
|
|
const hydrated = await hydrateExercisePlanningRow({ id: mi.exercise_id })
|
|
|
|
|
|
if (!hydrated) continue
|
|
|
|
|
|
hydrated.source_training_module_id = midNum
|
|
|
|
|
|
hydrated.source_module_title = modTitle
|
2026-05-13 06:30:53 +02:00
|
|
|
|
if (
|
|
|
|
|
|
hydrated.exercise_kind !== 'combination' &&
|
|
|
|
|
|
mi.exercise_variant_id
|
|
|
|
|
|
) {
|
|
|
|
|
|
hydrated.exercise_variant_id = String(mi.exercise_variant_id)
|
|
|
|
|
|
}
|
2026-05-12 21:53:41 +02:00
|
|
|
|
hydrated.planned_duration_min =
|
|
|
|
|
|
mi.planned_duration_min !== null && mi.planned_duration_min !== undefined
|
|
|
|
|
|
? String(mi.planned_duration_min)
|
|
|
|
|
|
: ''
|
|
|
|
|
|
hydrated.notes = mi.notes ?? ''
|
|
|
|
|
|
appendRows.push(hydrated)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const mergedItems = [...prev.slice(0, beforeIx), ...appendRows, ...prev.slice(beforeIx)]
|
|
|
|
|
|
return sections.map((sec, idx) => (idx === secIx ? { ...sec, items: mergedItems } : sec))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 13:39:30 +02:00
|
|
|
|
export function sectionPlannedMinutes(sec) {
|
|
|
|
|
|
return (sec.items || []).reduce((sum, it) => {
|
|
|
|
|
|
if (it.item_type !== 'exercise') return sum
|
|
|
|
|
|
const m = parseMin(it.planned_duration_min)
|
|
|
|
|
|
return sum + (m || 0)
|
|
|
|
|
|
}, 0)
|
|
|
|
|
|
}
|