Parlellsession- Plan #35

Merged
Lars merged 34 commits from develop into main 2026-05-15 22:04:53 +02:00
2 changed files with 277 additions and 17 deletions
Showing only changes of commit 0a203aaf75 - Show all commits

View File

@ -13,6 +13,12 @@ import {
maxPhaseOrderIndexFromSections, maxPhaseOrderIndexFromSections,
buildPlanTargetOptions, buildPlanTargetOptions,
planLocKey, planLocKey,
MAX_PARALLEL_STREAMS_PER_PHASE,
parallelStreamVisual,
streamTabLabelFromIndices,
streamsForParallelPhaseOrders,
sectionIndicesForParallelStream,
reorderWithinBucketIndices,
exerciseRow, exerciseRow,
noteRow, noteRow,
sectionPlannedMinutes, sectionPlannedMinutes,
@ -279,6 +285,8 @@ export default function TrainingUnitSectionsEditor({
if (!par.length) return prev if (!par.length) return prev
const maxP = Math.max(...par.map((s) => s.planLoc.phaseOrderIndex ?? 0)) const maxP = Math.max(...par.map((s) => s.planLoc.phaseOrderIndex ?? 0))
const inPhase = par.filter((s) => (s.planLoc.phaseOrderIndex ?? 0) === maxP) 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 maxS = Math.max(...inPhase.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0))
const newSo = maxS + 1 const newSo = maxS + 1
const tmpl = { const tmpl = {
@ -313,13 +321,27 @@ export default function TrainingUnitSectionsEditor({
}) })
} }
/** Ganzgruppe: global tauschen; parallele Phase: nur innerhalb desselben Streams sortieren. */
const moveSection = (sIdx, dir) => { const moveSection = (sIdx, dir) => {
patch((prev) => { 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 const ta = sIdx + dir
if (ta < 0 || ta >= p.length) return p if (ta < 0 || ta >= arr.length) return arr
;[p[sIdx], p[ta]] = [p[ta], p[sIdx]] ;[arr[sIdx], arr[ta]] = [arr[ta], arr[sIdx]]
return p return arr
}) })
} }
@ -399,6 +421,8 @@ export default function TrainingUnitSectionsEditor({
const [dropTargetPos, setDropTargetPos] = useState(null) const [dropTargetPos, setDropTargetPos] = useState(null)
const [dropSectionBand, setDropSectionBand] = useState(null) const [dropSectionBand, setDropSectionBand] = useState(null)
/** Aktiver Reiter pro paralleler Phase (phaseOrder → streamOrder). */
const [parallelStreamTabByPhase, setParallelStreamTabByPhase] = useState({})
/** { slot: number, beforeIdx: number } */ /** { slot: number, beforeIdx: number } */
useEffect(() => { useEffect(() => {
@ -694,6 +718,87 @@ export default function TrainingUnitSectionsEditor({
[list] [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(() => { const comboPlanningModalDerived = useMemo(() => {
if (!comboPlanningModal) { if (!comboPlanningModal) {
return { item: null, sIdx: null, iIdx: null } return { item: null, sIdx: null, iIdx: null }
@ -791,8 +896,9 @@ export default function TrainingUnitSectionsEditor({
Breakout: Phasen und parallele Streams Breakout: Phasen und parallele Streams
</div> </div>
<p style={{ margin: '0 0 12px', fontSize: '0.8rem', color: 'var(--text2)', lineHeight: 1.5 }}> <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 Legt fest, ob Abschnitte zur ganzen Gruppe oder zu parallelen Gruppen gehören. Pro paralleler Phase erscheinen
übernimmt die Zuordnung des letzten Abschnitts. Speichern erzeugt bei Bedarf automatisch den{' '} 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. <code style={{ fontSize: '0.78em' }}>phases</code>-Payload fürs Backend.
</p> </p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
@ -806,11 +912,13 @@ export default function TrainingUnitSectionsEditor({
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
onClick={addStreamToLastParallelPhase} onClick={addStreamToLastParallelPhase}
disabled={!hasParallelPhase} disabled={!hasParallelPhase || cannotAddMoreStreams}
title={ title={
hasParallelPhase !hasParallelPhase
? 'Weiterer Stream in der letzten parallelen Phase (höchster Phasen-Index)' ? 'Zuerst eine parallele Phase anlegen'
: '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 Stream hinzufügen
@ -828,6 +936,29 @@ export default function TrainingUnitSectionsEditor({
dropSectionBand.slot === sectionToSlot && dropSectionBand.slot === sectionToSlot &&
dropSectionBand.beforeIdx === bx 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 ( return (
<Fragment key={`secFrag-${sIdx}`}> <Fragment key={`secFrag-${sIdx}`}>
{enableSectionDragReorder ? ( {enableSectionDragReorder ? (
@ -846,14 +977,69 @@ export default function TrainingUnitSectionsEditor({
onDrop={(e) => onSectionBandDrop(e, sIdx)} onDrop={(e) => onSectionBandDrop(e, sIdx)}
/> />
) : null} ) : 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 <div
className="tu-section-shell" className="tu-section-shell"
style={{ style={{
marginBottom: '1rem', marginBottom: '1rem',
padding: '0.75rem', padding: '0.75rem',
background: 'var(--surface2)', background: streamVisual ? streamVisual.soft : 'var(--surface2)',
borderRadius: '10px', 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 <div
@ -865,7 +1051,7 @@ export default function TrainingUnitSectionsEditor({
alignItems: 'flex-start', alignItems: 'flex-start',
}} }}
> >
{enableSectionDragReorder ? ( {allowSectionDragGrip ? (
<span <span
className="tu-sec-drag-grip" className="tu-sec-drag-grip"
draggable draggable
@ -890,8 +1076,11 @@ export default function TrainingUnitSectionsEditor({
type="button" type="button"
aria-label="Abschnitt hoch" aria-label="Abschnitt hoch"
onClick={() => moveSection(sIdx, -1)} onClick={() => moveSection(sIdx, -1)}
disabled={sIdx === 0} disabled={sectionMoveDisabledUp(sIdx)}
style={{ padding: '4px 10px', opacity: sIdx === 0 ? 0.35 : 1 }} style={{
padding: '4px 10px',
opacity: sectionMoveDisabledUp(sIdx) ? 0.35 : 1,
}}
> >
</button> </button>
@ -899,10 +1088,10 @@ export default function TrainingUnitSectionsEditor({
type="button" type="button"
aria-label="Abschnitt runter" aria-label="Abschnitt runter"
onClick={() => moveSection(sIdx, 1)} onClick={() => moveSection(sIdx, 1)}
disabled={sIdx === list.length - 1} disabled={sectionMoveDisabledDown(sIdx)}
style={{ style={{
padding: '4px 10px', 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> </div>
) : null} ) : null}
</div> </div>
) : null}
</Fragment> </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 })) 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) { function normalizeCatalogMethodProfile(cp) {
if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp } if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp }
return {} return {}