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
}
2026-05-15 08:20:43 +02:00
export function inheritPlanLocForPhasedSave ( sections ) {
2026-05-15 07:12:43 +02:00
let prev = {
phaseKind : 'whole_group' ,
phaseOrderIndex : 0 ,
parallelStreamOrderIndex : null ,
phaseTitle : null ,
phaseGuidanceNotes : null ,
streamTitle : null ,
streamNotes : null ,
streamAssignedTrainerProfileIds : null ,
}
2026-05-15 08:20:43 +02:00
return ( sections || [ ] ) . map ( ( s ) => {
2026-05-15 07:12:43 +02:00
if ( s ? . planLoc && s . planLoc . phaseKind ) {
prev = { ... s . planLoc }
return { ... s , planLoc : prev }
}
return { ... s , planLoc : { ... prev } }
} )
}
2026-05-15 08:20:43 +02:00
/** Phasen-„Runs“ in der flachen Abschnittsliste (Reihenfolge wie beim Speichern). */
export function phaseRunsFromSections ( sections ) {
const norm = inheritPlanLocForPhasedSave ( sections )
const runs = [ ]
let i = 0
while ( i < norm . length ) {
const loc0 = norm [ i ] ? . planLoc
if ( ! loc0 ? . phaseKind ) {
i += 1
continue
}
const pOi = loc0 . phaseOrderIndex ? ? 0
const pk = loc0 . phaseKind === 'parallel' ? 'parallel' : 'whole_group'
const start = i
while ( i < norm . length ) {
const L = norm [ i ] ? . planLoc
if ( ! L ? . phaseKind ) break
const pk2 = L . phaseKind === 'parallel' ? 'parallel' : 'whole_group'
if ( ( L . phaseOrderIndex ? ? 0 ) !== pOi || pk2 !== pk ) break
i += 1
}
runs . push ( { phaseKind : pk , phaseOrderIndex : pOi , start , end : i } )
}
return runs
}
/** Vertauscht zwei unmittelbar benachbarte Runs (upperRunIndex = erste der beiden). */
export function swapAdjacentPhaseRuns ( prev , upperRunIndex ) {
const runs = phaseRunsFromSections ( prev )
const a = upperRunIndex
const b = upperRunIndex + 1
if ( a < 0 || b >= runs . length ) return prev
const rgA = runs [ a ]
const rgB = runs [ b ]
const head = prev . slice ( 0 , rgA . start )
const blA = prev . slice ( rgA . start , rgA . end )
const blB = prev . slice ( rgB . start , rgB . end )
const tail = prev . slice ( rgB . end )
return [ ... head , ... blB , ... blA , ... tail ]
}
export function movePhaseRunUpByPhaseOrder ( prev , phaseOrderIndex ) {
const po = Number ( phaseOrderIndex ) || 0
const runs = phaseRunsFromSections ( prev )
const rIdx = runs . findIndex ( ( r ) => r . phaseKind === 'parallel' && r . phaseOrderIndex === po )
if ( rIdx <= 0 ) return prev
return swapAdjacentPhaseRuns ( prev , rIdx - 1 )
}
export function movePhaseRunDownByPhaseOrder ( prev , phaseOrderIndex ) {
const po = Number ( phaseOrderIndex ) || 0
const runs = phaseRunsFromSections ( prev )
const rIdx = runs . findIndex ( ( r ) => r . phaseKind === 'parallel' && r . phaseOrderIndex === po )
if ( rIdx < 0 || rIdx >= runs . length - 1 ) return prev
return swapAdjacentPhaseRuns ( prev , rIdx )
}
/ * *
2026-05-15 08:37:16 +02:00
* Abschnitt verschieben und planLoc an der Einfügestelle an Nachbarn anpassen .
* Regel : Einfügen vor Abschnitt X übernimmt X . planLoc ; am Listenende nach Parallel → neue Ganzgruppen - Phase .
2026-05-15 08:20:43 +02:00
* /
export function reorderBlocksImmutableWithPlanLoc ( prev , fromI , toBeforeIdx ) {
const arr = [ ... prev ]
if ( fromI < 0 || fromI >= arr . length ) return prev
const [ moved ] = arr . splice ( fromI , 1 )
let insertAt = toBeforeIdx
if ( fromI < toBeforeIdx ) insertAt = toBeforeIdx - 1
insertAt = Math . max ( 0 , Math . min ( insertAt , arr . length ) )
2026-05-15 08:37:16 +02:00
const below = insertAt < arr . length ? arr [ insertAt ] : undefined
const above = insertAt > 0 ? arr [ insertAt - 1 ] : undefined
let planLocNext = null
if ( below ? . planLoc ? . phaseKind ) {
planLocNext = { ... below . planLoc }
} else if ( insertAt === arr . length ) {
if ( ! above ) {
planLocNext = defaultPlanLocWholeGroup ( 0 )
} else if ( above . planLoc ? . phaseKind === 'parallel' ) {
const mx = maxPhaseOrderIndexFromSections ( arr )
planLocNext = defaultPlanLocWholeGroup ( mx + 1 )
} else if ( above . planLoc ? . phaseKind === 'whole_group' ) {
planLocNext = { ... above . planLoc }
}
} else if ( above ? . planLoc ? . phaseKind === 'whole_group' ) {
planLocNext = { ... above . planLoc }
} else if ( above ? . planLoc ? . phaseKind === 'parallel' ) {
const mx = maxPhaseOrderIndexFromSections ( arr )
planLocNext = defaultPlanLocWholeGroup ( mx + 1 )
}
2026-05-15 08:20:43 +02:00
let nextMoved = { ... moved }
2026-05-15 08:37:16 +02:00
if ( planLocNext ) {
nextMoved = { ... moved , planLoc : planLocNext }
2026-05-15 08:20:43 +02:00
} else {
nextMoved = stripPlanLoc ( moved )
}
arr . splice ( insertAt , 0 , nextMoved )
return arr
}
2026-05-15 09:10:54 +02:00
/ * *
* Abschnitt ans Ende eines parallelen Streams setzen ( planLoc wie dieser Stream ) .
* Leerer Stream : Einfügen hinter den letzten Abschnitt der zugehörigen parallelen Phase , planLoc vom Referenz - Abschnitt mit angepasstem streamIndex .
* /
export function reorderBlockIntoParallelStreamEnd ( prev , fromI , phaseOrderIndex , streamOrderIndex ) {
const po = Number ( phaseOrderIndex ) || 0
const so = Number ( streamOrderIndex ) || 0
const len = prev ? . length ? ? 0
if ( fromI < 0 || fromI >= len ) return prev
const arr = [ ... prev ]
const [ moved ] = arr . splice ( fromI , 1 )
const streamIdx = sectionIndicesForParallelStream ( arr , po , so )
let insertAt
let planLocTemplate
if ( streamIdx . length ) {
const last = Math . max ( ... streamIdx )
planLocTemplate = { ... arr [ last ] . planLoc }
insertAt = last + 1
} else {
const phaseIdx = indicesOfParallelPhase ( arr , po )
if ( ! phaseIdx . length ) return prev
const ref = arr [ phaseIdx [ phaseIdx . length - 1 ] ]
planLocTemplate = {
... ref . planLoc ,
parallelStreamOrderIndex : so ,
streamTitle : null ,
streamNotes : null ,
streamAssignedTrainerProfileIds : null ,
}
insertAt = phaseIdx [ phaseIdx . length - 1 ] + 1
}
if ( fromI < insertAt ) insertAt -= 1
insertAt = Math . max ( 0 , Math . min ( insertAt , arr . length ) )
arr . splice ( insertAt , 0 , { ... moved , planLoc : { ... planLocTemplate } } )
return arr
}
/** Globales insertBeforeIndex, um einen Abschnitt hinter den letzten des Streams einzufügen (z. B. Rahmen-Slots). */
export function globalInsertBeforeIndexForParallelStreamEnd ( sections , phaseOrderIndex , streamOrderIndex ) {
const arr = sections || [ ]
const po = Number ( phaseOrderIndex ) || 0
const so = Number ( streamOrderIndex ) || 0
const streamIdx = sectionIndicesForParallelStream ( arr , po , so )
if ( streamIdx . length ) return Math . max ( ... streamIdx ) + 1
const phaseIdx = indicesOfParallelPhase ( arr , po )
if ( ! phaseIdx . length ) return arr . length
return Math . max ( ... phaseIdx ) + 1
}
2026-05-15 08:37:16 +02:00
/** Alle globalen Indizes einer parallelen Phase (alle Streams), sortiert. */
export function indicesOfParallelPhase ( sections , phaseOrderIndex ) {
const po = Number ( phaseOrderIndex ) || 0
const out = [ ]
; ( sections || [ ] ) . forEach ( ( s , i ) => {
const L = s ? . planLoc
if ( L ? . phaseKind === 'parallel' && ( L . phaseOrderIndex ? ? 0 ) === po ) out . push ( i )
} )
return out . sort ( ( a , b ) => a - b )
}
/** Gesamten Parallel-Block an neue Position (insertBefore globale Liste) schieben. */
export function moveParallelPhaseRunToInsertBefore ( prev , phaseOrderIndex , toBeforeIdx ) {
const po = Number ( phaseOrderIndex ) || 0
const indices = indicesOfParallelPhase ( prev , po )
if ( ! indices . length ) return prev
const indexSet = new Set ( indices )
const blocks = indices . map ( ( i ) => prev [ i ] )
const without = prev . filter ( ( _ , i ) => ! indexSet . has ( i ) )
let ins = toBeforeIdx
for ( const i of indices ) {
if ( i < toBeforeIdx ) ins -= 1
}
ins = Math . max ( 0 , Math . min ( ins , without . length ) )
return [ ... without . slice ( 0 , ins ) , ... blocks , ... without . slice ( ins ) ]
}
/ * *
* Nach Drag & Drop : wenn aus einer Parallelphase noch ≤ 1 Stream übrig ist ( vorher ≥ 2 ) , Rückfrage wie beim Stream - Löschen .
* /
export function afterSectionReorderParallelGuard ( prev , next ) {
const seenPo = new Set ( )
for ( const s of prev || [ ] ) {
const L = s ? . planLoc
if ( L ? . phaseKind !== 'parallel' ) continue
seenPo . add ( L . phaseOrderIndex ? ? 0 )
}
let out = next
for ( const po of seenPo ) {
const prevN = streamsForParallelPhaseOrders ( prev , po ) . length
if ( prevN < 2 ) continue
const nowN = streamsForParallelPhaseOrders ( out , po ) . length
if ( nowN <= 1 ) {
if (
window . confirm (
'In dieser parallelen Phase ist nur noch eine Gruppe übrig. Parallelaufbau auflösen und alle zugehörigen Abschnitte als gemeinsame Ganzgruppen-Phase führen?'
)
) {
out = dissolveParallelPhaseToWholeGroup ( out , po )
}
}
}
return out
}
2026-05-15 07:12:43 +02:00
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 )
}