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 {}