Enhance TrainingUnitSectionsEditor with parallel 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 33s
Test Suite / playwright-tests (push) Successful in 1m10s

- Introduced logic to limit the number of streams per parallel phase, ensuring compliance with the defined maximum.
- Added utility functions for managing stream indices and visual representation of streams.
- Implemented section movement within parallel streams, allowing for reordering while maintaining stream integrity.
- Updated UI components to reflect changes in stream handling, including disabling buttons when limits are reached.
- Enhanced state management for parallel stream tabs, improving user experience in navigating between streams.
This commit is contained in:
Lars 2026-05-15 07:37:51 +02:00
parent f50e9db523
commit 0a203aaf75
2 changed files with 277 additions and 17 deletions

View File

@ -13,6 +13,12 @@ import {
maxPhaseOrderIndexFromSections,
buildPlanTargetOptions,
planLocKey,
MAX_PARALLEL_STREAMS_PER_PHASE,
parallelStreamVisual,
streamTabLabelFromIndices,
streamsForParallelPhaseOrders,
sectionIndicesForParallelStream,
reorderWithinBucketIndices,
exerciseRow,
noteRow,
sectionPlannedMinutes,
@ -279,6 +285,8 @@ export default function TrainingUnitSectionsEditor({
if (!par.length) return prev
const maxP = Math.max(...par.map((s) => s.planLoc.phaseOrderIndex ?? 0))
const inPhase = par.filter((s) => (s.planLoc.phaseOrderIndex ?? 0) === maxP)
const distinctStreams = new Set(inPhase.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0))
if (distinctStreams.size >= MAX_PARALLEL_STREAMS_PER_PHASE) return prev
const maxS = Math.max(...inPhase.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0))
const newSo = maxS + 1
const tmpl = {
@ -313,13 +321,27 @@ export default function TrainingUnitSectionsEditor({
})
}
/** Ganzgruppe: global tauschen; parallele Phase: nur innerhalb desselben Streams sortieren. */
const moveSection = (sIdx, dir) => {
patch((prev) => {
const p = [...prev]
const p = ensure(prev)
const sec = p[sIdx]
const L = sec?.planLoc
if (L?.phaseKind === 'parallel') {
const po = L.phaseOrderIndex ?? 0
const so = L.parallelStreamOrderIndex ?? 0
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)
}
const arr = [...p]
const ta = sIdx + dir
if (ta < 0 || ta >= p.length) return p
;[p[sIdx], p[ta]] = [p[ta], p[sIdx]]
return p
if (ta < 0 || ta >= arr.length) return arr
;[arr[sIdx], arr[ta]] = [arr[ta], arr[sIdx]]
return arr
})
}
@ -399,6 +421,8 @@ export default function TrainingUnitSectionsEditor({
const [dropTargetPos, setDropTargetPos] = useState(null)
const [dropSectionBand, setDropSectionBand] = useState(null)
/** Aktiver Reiter pro paralleler Phase (phaseOrder → streamOrder). */
const [parallelStreamTabByPhase, setParallelStreamTabByPhase] = useState({})
/** { slot: number, beforeIdx: number } */
useEffect(() => {
@ -694,6 +718,87 @@ export default function TrainingUnitSectionsEditor({
[list]
)
const firstSectionIndexByParallelPhase = useMemo(() => {
const m = new Map()
list.forEach((s, i) => {
const L = s?.planLoc
if (L?.phaseKind !== 'parallel') return
const po = L.phaseOrderIndex ?? 0
if (!m.has(po)) m.set(po, i)
})
return m
}, [list])
const parallelPhaseOrdersPresent = useMemo(() => {
const set = new Set()
for (const s of list) {
if (s?.planLoc?.phaseKind === 'parallel') set.add(s.planLoc.phaseOrderIndex ?? 0)
}
return [...set].sort((a, b) => a - b)
}, [list])
const cannotAddMoreStreams = useMemo(() => {
const par = list.filter((s) => s?.planLoc?.phaseKind === 'parallel')
if (!par.length) return true
const maxP = Math.max(...par.map((s) => s.planLoc.phaseOrderIndex ?? 0))
const inPhase = par.filter((s) => (s.planLoc.phaseOrderIndex ?? 0) === maxP)
const distinct = new Set(inPhase.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0))
return distinct.size >= MAX_PARALLEL_STREAMS_PER_PHASE
}, [list])
useEffect(() => {
if (!enableParallelPhaseControls || !parallelPhaseOrdersPresent.length) return
setParallelStreamTabByPhase((prev) => {
const next = { ...prev }
let changed = false
for (const po of parallelPhaseOrdersPresent) {
const orders = streamsForParallelPhaseOrders(list, po)
if (!orders.length) continue
if (next[po] === undefined) {
next[po] = orders[0]
changed = true
} else if (!orders.includes(next[po])) {
next[po] = orders[0]
changed = true
}
}
for (const k of Object.keys(next)) {
const poi = Number(k)
if (!Number.isFinite(poi) || !parallelPhaseOrdersPresent.includes(poi)) {
delete next[k]
changed = true
}
}
return changed ? next : prev
})
}, [list, parallelPhaseOrdersPresent, enableParallelPhaseControls])
const sectionMoveDisabledUp = (sIdx) => {
const sec = list[sIdx]
const L = sec?.planLoc
if (L?.phaseKind === 'parallel') {
const po = L.phaseOrderIndex ?? 0
const so = L.parallelStreamOrderIndex ?? 0
const bucket = sectionIndicesForParallelStream(list, po, so)
const pos = bucket.indexOf(sIdx)
return pos <= 0
}
return sIdx === 0
}
const sectionMoveDisabledDown = (sIdx) => {
const sec = list[sIdx]
const L = sec?.planLoc
if (L?.phaseKind === 'parallel') {
const po = L.phaseOrderIndex ?? 0
const so = L.parallelStreamOrderIndex ?? 0
const bucket = sectionIndicesForParallelStream(list, po, so)
const pos = bucket.indexOf(sIdx)
return pos < 0 || pos >= bucket.length - 1
}
return sIdx === list.length - 1
}
const comboPlanningModalDerived = useMemo(() => {
if (!comboPlanningModal) {
return { item: null, sIdx: null, iIdx: null }
@ -791,8 +896,9 @@ export default function TrainingUnitSectionsEditor({
Breakout: Phasen und parallele Streams
</div>
<p style={{ margin: '0 0 12px', fontSize: '0.8rem', color: 'var(--text2)', lineHeight: 1.5 }}>
Legt fest, ob Abschnitte zur ganzen Gruppe oder zu parallelen Gruppen gehören. „Abschnitt hinzufügen“ unten
übernimmt die Zuordnung des letzten Abschnitts. Speichern erzeugt bei Bedarf automatisch den{' '}
Legt fest, ob Abschnitte zur ganzen Gruppe oder zu parallelen Gruppen gehören. Pro paralleler Phase erscheinen
Reiter je Stream — nur der aktive Stream ist sichtbar (farbig am linken Rand). „Abschnitt hinzufügen“ übernimmt
die Zuordnung des letzten Abschnitts. Speichern erzeugt bei Bedarf automatisch den{' '}
<code style={{ fontSize: '0.78em' }}>phases</code>-Payload fürs Backend.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
@ -806,11 +912,13 @@ export default function TrainingUnitSectionsEditor({
type="button"
className="btn btn-secondary"
onClick={addStreamToLastParallelPhase}
disabled={!hasParallelPhase}
disabled={!hasParallelPhase || cannotAddMoreStreams}
title={
hasParallelPhase
? 'Weiterer Stream in der letzten parallelen Phase (höchster Phasen-Index)'
: 'Zuerst eine parallele Phase anlegen'
!hasParallelPhase
? 'Zuerst eine parallele Phase anlegen'
: cannotAddMoreStreams
? `Höchstens ${MAX_PARALLEL_STREAMS_PER_PHASE} Streams pro Phase`
: 'Weiterer Stream in der letzten parallelen Phase (höchster Phasen-Index)'
}
>
Stream hinzufügen
@ -828,6 +936,29 @@ export default function TrainingUnitSectionsEditor({
dropSectionBand.slot === sectionToSlot &&
dropSectionBand.beforeIdx === bx
const pl = sec?.planLoc
const parallelPhaseOrder =
enableParallelPhaseControls && pl?.phaseKind === 'parallel' ? pl.phaseOrderIndex ?? 0 : null
const streamOrdersForParallelPhase =
parallelPhaseOrder != null ? streamsForParallelPhaseOrders(list, parallelPhaseOrder) : []
const activeParallelStream =
parallelPhaseOrder != null
? parallelStreamTabByPhase[parallelPhaseOrder] ?? streamOrdersForParallelPhase[0] ?? 0
: null
const hideParallelSection =
enableParallelPhaseControls &&
pl?.phaseKind === 'parallel' &&
(pl.parallelStreamOrderIndex ?? 0) !== activeParallelStream
const isFirstSectionOfParallelPhase =
parallelPhaseOrder != null &&
firstSectionIndexByParallelPhase.get(parallelPhaseOrder) === sIdx
const streamVisual =
enableParallelPhaseControls && pl?.phaseKind === 'parallel'
? parallelStreamVisual(pl.parallelStreamOrderIndex ?? 0)
: null
const allowSectionDragGrip =
enableSectionDragReorder && !(enableParallelPhaseControls && pl?.phaseKind === 'parallel')
return (
<Fragment key={`secFrag-${sIdx}`}>
{enableSectionDragReorder ? (
@ -846,14 +977,69 @@ export default function TrainingUnitSectionsEditor({
onDrop={(e) => onSectionBandDrop(e, sIdx)}
/>
) : null}
{isFirstSectionOfParallelPhase &&
enableParallelPhaseControls &&
streamOrdersForParallelPhase.length ? (
<div
role="tablist"
aria-label={`Parallele Streams · Phase ${parallelPhaseOrder}`}
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginBottom: '10px',
padding: '8px 10px',
background: 'var(--surface2)',
borderRadius: '10px',
border: '1px solid var(--border, rgba(0,0,0,0.08))',
}}
>
{streamOrdersForParallelPhase.map((so) => {
const sel =
(parallelStreamTabByPhase[parallelPhaseOrder] ??
streamOrdersForParallelPhase[0] ??
0) === so
const pv = parallelStreamVisual(so)
const lab = streamTabLabelFromIndices(
list,
sectionIndicesForParallelStream(list, parallelPhaseOrder, so),
)
return (
<button
key={`p${parallelPhaseOrder}-tab-s${so}`}
type="button"
role="tab"
aria-selected={sel}
className="btn framework-ctrl framework-ctrl--xs"
style={{
margin: 0,
border: sel ? `2px solid ${pv.border}` : `1px solid ${pv.border}`,
background: sel ? pv.tabBgActive : pv.tabBg,
color: 'var(--text1)',
fontWeight: sel ? 600 : 500,
}}
onClick={() =>
setParallelStreamTabByPhase((prev) => ({ ...prev, [parallelPhaseOrder]: so }))
}
>
{lab}
</button>
)
})}
</div>
) : null}
{!hideParallelSection ? (
<div
className="tu-section-shell"
style={{
marginBottom: '1rem',
padding: '0.75rem',
background: 'var(--surface2)',
background: streamVisual ? streamVisual.soft : 'var(--surface2)',
borderRadius: '10px',
border: '1px solid var(--border, rgba(0,0,0,0.08))',
border: streamVisual
? `1px solid ${streamVisual.border}`
: '1px solid var(--border, rgba(0,0,0,0.08))',
borderLeft: streamVisual ? `5px solid ${streamVisual.border}` : undefined,
}}
>
<div
@ -865,7 +1051,7 @@ export default function TrainingUnitSectionsEditor({
alignItems: 'flex-start',
}}
>
{enableSectionDragReorder ? (
{allowSectionDragGrip ? (
<span
className="tu-sec-drag-grip"
draggable
@ -890,8 +1076,11 @@ export default function TrainingUnitSectionsEditor({
type="button"
aria-label="Abschnitt hoch"
onClick={() => moveSection(sIdx, -1)}
disabled={sIdx === 0}
style={{ padding: '4px 10px', opacity: sIdx === 0 ? 0.35 : 1 }}
disabled={sectionMoveDisabledUp(sIdx)}
style={{
padding: '4px 10px',
opacity: sectionMoveDisabledUp(sIdx) ? 0.35 : 1,
}}
>
▲
</button>
@ -899,10 +1088,10 @@ export default function TrainingUnitSectionsEditor({
type="button"
aria-label="Abschnitt runter"
onClick={() => moveSection(sIdx, 1)}
disabled={sIdx === list.length - 1}
disabled={sectionMoveDisabledDown(sIdx)}
style={{
padding: '4px 10px',
opacity: sIdx === list.length - 1 ? 0.35 : 1,
opacity: sectionMoveDisabledDown(sIdx) ? 0.35 : 1,
}}
>
▼
@ -1479,6 +1668,7 @@ export default function TrainingUnitSectionsEditor({
</div>
) : null}
</div>
) : null}
</Fragment>
)
})}

View File

@ -86,6 +86,76 @@ export function buildPlanTargetOptions(sections) {
return [...map.values()].sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }))
}
/** 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
}
/** Reihenfolge innerhalb eines Stream-Buckets (globale Indizes) ändern. */
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
}
function normalizeCatalogMethodProfile(cp) {
if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp }
return {}