Enhance TrainingUnitSectionsEditor with advanced phase management features
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 12s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s

- Introduced new utility functions for managing phase runs, including the ability to swap adjacent phase runs and move phase runs up or down by their order.
- Updated the TrainingUnitSectionsEditor to incorporate these new functions, allowing for improved reordering of sections within parallel phases.
- Enhanced the moveSection logic to support movement across different phase types, ensuring better user experience when managing sections.
- Refactored the reorderBlocksImmutable function to accommodate plan location adjustments during section reordering, improving overall functionality.
This commit is contained in:
Lars 2026-05-15 08:20:43 +02:00
parent 613fedfaff
commit a0a0be8bef
2 changed files with 186 additions and 34 deletions

View File

@ -21,6 +21,11 @@ import {
reorderWithoutIndices,
parallelStreamBucketHasContent,
dissolveParallelPhaseToWholeGroup,
phaseRunsFromSections,
swapAdjacentPhaseRuns,
reorderBlocksImmutableWithPlanLoc,
movePhaseRunUpByPhaseOrder,
movePhaseRunDownByPhaseOrder,
exerciseRow,
noteRow,
sectionPlannedMinutes,
@ -263,15 +268,6 @@ export default function TrainingUnitSectionsEditor({
})
}
const addWholeGroupPhase = () => {
patch((prev) => {
const nextPo = maxPhaseOrderIndexFromSections(prev) + 1
const pl = defaultPlanLocWholeGroup(nextPo)
const base = defaultSection(`Abschnitt ${prev.length + 1}`)
return [...prev, { ...base, planLoc: pl }]
})
}
const addParallelPhaseTwoStreams = () => {
patch((prev) => {
const nextPo = maxPhaseOrderIndexFromSections(prev) + 1
@ -430,7 +426,7 @@ export default function TrainingUnitSectionsEditor({
})
}
/** Ganzgruppe: global tauschen; parallele Phase: nur innerhalb desselben Streams sortieren. */
/** Ganzgruppe: global tauschen; parallele Phase: innerhalb Stream oder ganze Parallel-Phase am Stück */
const moveSection = (sIdx, dir) => {
patch((prev) => {
const p = ensure(prev)
@ -442,9 +438,27 @@ export default function TrainingUnitSectionsEditor({
const bucket = sectionIndicesForParallelStream(p, po, so)
const pos = bucket.indexOf(sIdx)
if (pos < 0) return p
const newPos = pos + dir
if (newPos < 0 || newPos >= bucket.length) return p
return reorderWithinBucketIndices(p, bucket, pos, newPos)
if (dir < 0 && pos > 0) {
const newPos = pos + dir
return reorderWithinBucketIndices(p, bucket, pos, newPos)
}
if (dir > 0 && pos < bucket.length - 1) {
const newPos = pos + dir
return reorderWithinBucketIndices(p, bucket, pos, newPos)
}
if (dir < 0 && pos === 0) {
const runs = phaseRunsFromSections(p)
const rIdx = runs.findIndex((r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po)
if (rIdx <= 0) return p
return swapAdjacentPhaseRuns(p, rIdx - 1)
}
if (dir > 0 && pos === bucket.length - 1) {
const runs = phaseRunsFromSections(p)
const rIdx = runs.findIndex((r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po)
if (rIdx < 0 || rIdx >= runs.length - 1) return p
return swapAdjacentPhaseRuns(p, rIdx)
}
return p
}
const arr = [...p]
const ta = sIdx + dir
@ -686,7 +700,12 @@ export default function TrainingUnitSectionsEditor({
return
}
patch((prev) => reorderBlocksImmutable(prev, fromSi, insertBeforeIdx))
patch((prev) => {
if (enableParallelPhaseControls) {
return reorderBlocksImmutableWithPlanLoc(prev, fromSi, insertBeforeIdx)
}
return reorderBlocksImmutable(prev, fromSi, insertBeforeIdx)
})
}
const onItemDragStart = (e, sIdx, iIdx) => {
@ -829,6 +848,8 @@ export default function TrainingUnitSectionsEditor({
const list = ensure(sections)
const planningPhaseRuns = useMemo(() => phaseRunsFromSections(list), [list])
const firstSectionIndexByParallelPhase = useMemo(() => {
const m = new Map()
list.forEach((s, i) => {
@ -883,7 +904,11 @@ export default function TrainingUnitSectionsEditor({
const so = L.parallelStreamOrderIndex ?? 0
const bucket = sectionIndicesForParallelStream(list, po, so)
const pos = bucket.indexOf(sIdx)
return pos <= 0
if (pos > 0) return false
const rIdx = planningPhaseRuns.findIndex(
(r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po
)
return rIdx <= 0
}
return sIdx === 0
}
@ -896,7 +921,11 @@ export default function TrainingUnitSectionsEditor({
const so = L.parallelStreamOrderIndex ?? 0
const bucket = sectionIndicesForParallelStream(list, po, so)
const pos = bucket.indexOf(sIdx)
return pos < 0 || pos >= bucket.length - 1
if (pos >= 0 && pos < bucket.length - 1) return false
const rIdx = planningPhaseRuns.findIndex(
(r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po
)
return rIdx < 0 || rIdx >= planningPhaseRuns.length - 1
}
return sIdx === list.length - 1
}
@ -1013,8 +1042,7 @@ export default function TrainingUnitSectionsEditor({
enableParallelPhaseControls && pl?.phaseKind === 'parallel'
? parallelStreamVisual(pl.parallelStreamOrderIndex ?? 0)
: null
const allowSectionDragGrip =
enableSectionDragReorder && !(enableParallelPhaseControls && pl?.phaseKind === 'parallel')
const allowSectionDragGrip = enableSectionDragReorder
return (
<Fragment key={`secFrag-${sIdx}`}>
@ -1124,6 +1152,51 @@ export default function TrainingUnitSectionsEditor({
</>
)
})()}
{(() => {
const prRunIdx = planningPhaseRuns.findIndex(
(r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === parallelPhaseOrder
)
const chipPhaseUpDis = prRunIdx <= 0
const chipPhaseDownDis =
prRunIdx < 0 || prRunIdx >= planningPhaseRuns.length - 1
return (
<div
style={{
display: 'inline-flex',
gap: '4px',
alignItems: 'center',
marginRight: '2px',
}}
>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
disabled={chipPhaseUpDis}
aria-label="Parallelen Block nach oben"
title="Gesamten parallelen Block im Plan nach oben schieben"
onClick={() =>
patch((p) => movePhaseRunUpByPhaseOrder(p, parallelPhaseOrder))
}
style={{ padding: '4px 10px' }}
>
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
disabled={chipPhaseDownDis}
aria-label="Parallelen Block nach unten"
title="Gesamten parallelen Block im Plan nach unten schieben"
onClick={() =>
patch((p) => movePhaseRunDownByPhaseOrder(p, parallelPhaseOrder))
}
style={{ padding: '4px 10px' }}
>
</button>
</div>
)
})()}
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
@ -1979,9 +2052,6 @@ export default function TrainingUnitSectionsEditor({
>
{enableParallelPhaseControls ? (
<>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={addWholeGroupPhase}>
Neue Ganzgruppen-Phase
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
@ -2002,20 +2072,21 @@ export default function TrainingUnitSectionsEditor({
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={addWholeGroupSection}
title="Neuer Abschnitt für die gemeinsame Gruppe (nicht für einen parallelen Stream)"
title="Neuer Abschnitt für die gemeinsame Gruppe (legt bei Bedarf eine neue Ganzgruppen-Phase an)"
>
+ Ganzgruppen-Abschnitt
+ Abschnitt (Ganzgruppe)
</button>
</>
) : null}
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={addSection}
title="Letzte Zuordnung übernehmen (Phase / Stream)"
>
+ Abschnitt hinzufügen
</button>
) : (
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={addSection}
title="Abschnitt am Ende anfügen"
>
+ Abschnitt hinzufügen
</button>
)}
</div>
{insertChooser ? (

View File

@ -721,7 +721,7 @@ function stripPlanLoc(sec) {
return rest
}
function inheritPlanLocForPhasedSave(sections) {
export function inheritPlanLocForPhasedSave(sections) {
let prev = {
phaseKind: 'whole_group',
phaseOrderIndex: 0,
@ -732,7 +732,7 @@ function inheritPlanLocForPhasedSave(sections) {
streamNotes: null,
streamAssignedTrainerProfileIds: null,
}
return sections.map((s) => {
return (sections || []).map((s) => {
if (s?.planLoc && s.planLoc.phaseKind) {
prev = { ...s.planLoc }
return { ...s, planLoc: prev }
@ -741,6 +741,87 @@ function inheritPlanLocForPhasedSave(sections) {
})
}
/** 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)
}
/**
* Abschnitt verschieben und planLoc an der Einfügestelle an Nachbarn anpassen
* (z. B. Ganzgruppe in parallelen Stream).
*/
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))
const below = arr[insertAt]
const above = arr[insertAt - 1]
const ref = below ?? above
let nextMoved = { ...moved }
if (ref?.planLoc?.phaseKind) {
nextMoved = { ...moved, planLoc: { ...ref.planLoc } }
} else {
nextMoved = stripPlanLoc(moved)
}
arr.splice(insertAt, 0, nextMoved)
return arr
}
function buildPhasesPayloadFromFlat(sections) {
const norm = inheritPlanLocForPhasedSave(sections)
const phases = []