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
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:
parent
f50e9db523
commit
0a203aaf75
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user