Enhance TrainingUnitSectionsEditor with new section management features
All checks were successful
Deploy Development / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m7s

- Introduced functions to add and manage parallel phases and sections, allowing for more flexible training unit configurations.
- Implemented logic to handle the addition of whole group sections and parallel streams, improving the user experience in the editor.
- Added utility functions for reordering sections and checking for content within parallel stream buckets.
- Updated state management to ensure proper handling of section titles and removal of streams, enhancing overall functionality.
This commit is contained in:
Lars 2026-05-15 07:50:16 +02:00
parent 0a203aaf75
commit 2e761161ef
2 changed files with 355 additions and 121 deletions

View File

@ -1,5 +1,5 @@
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { GripVertical, Pencil } from 'lucide-react'
import { GripVertical, Pencil, X } from 'lucide-react'
import CombinationMethodProfileEditor from './CombinationMethodProfileEditor'
import CombinationPlanBracket from './CombinationPlanBracket'
import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
@ -15,10 +15,12 @@ import {
planLocKey,
MAX_PARALLEL_STREAMS_PER_PHASE,
parallelStreamVisual,
streamTabLabelFromIndices,
streamsForParallelPhaseOrders,
sectionIndicesForParallelStream,
reorderWithinBucketIndices,
reorderWithoutIndices,
parallelStreamBucketHasContent,
dissolveParallelPhaseToWholeGroup,
exerciseRow,
noteRow,
sectionPlannedMinutes,
@ -270,27 +272,30 @@ export default function TrainingUnitSectionsEditor({
})
}
const addParallelPhase = () => {
const addParallelPhaseTwoStreams = () => {
patch((prev) => {
const nextPo = maxPhaseOrderIndexFromSections(prev) + 1
const pl = defaultPlanLocParallel(nextPo, 0)
const base = defaultSection(`Abschnitt ${prev.length + 1}`)
return [...prev, { ...base, planLoc: pl }]
const pl0 = defaultPlanLocParallel(nextPo, 0)
const pl1 = defaultPlanLocParallel(nextPo, 1)
const base0 = defaultSection(`Abschnitt ${prev.length + 1}`)
const base1 = defaultSection(`Abschnitt ${prev.length + 2}`)
return [...prev, { ...base0, planLoc: pl0 }, { ...base1, planLoc: pl1 }]
})
}
const addStreamToLastParallelPhase = () => {
const addStreamToParallelPhase = (phaseOrder) => {
patch((prev) => {
const par = (prev || []).filter((s) => s?.planLoc?.phaseKind === 'parallel')
const po = Number(phaseOrder) || 0
const par = (prev || []).filter(
(s) => s?.planLoc?.phaseKind === 'parallel' && (s.planLoc.phaseOrderIndex ?? 0) === po
)
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 distinct = new Set(par.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0))
if (distinct.size >= MAX_PARALLEL_STREAMS_PER_PHASE) return prev
const maxS = Math.max(...par.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0))
const newSo = maxS + 1
const tmpl = {
...inPhase[0].planLoc,
...par[0].planLoc,
parallelStreamOrderIndex: newSo,
streamTitle: null,
streamNotes: null,
@ -301,6 +306,110 @@ export default function TrainingUnitSectionsEditor({
})
}
const addWholeGroupSection = () => {
patch((prev) => {
const L = ensure(prev)
const wgs = L.filter((s) => s?.planLoc?.phaseKind === 'whole_group')
let pl
if (wgs.length) {
const maxPo = Math.max(...wgs.map((s) => s.planLoc.phaseOrderIndex ?? 0))
const sample = wgs.find((s) => (s.planLoc.phaseOrderIndex ?? 0) === maxPo)
pl = { ...sample.planLoc }
} else {
const nextPo = maxPhaseOrderIndexFromSections(L) + 1
pl = defaultPlanLocWholeGroup(nextPo)
}
const base = defaultSection(`Abschnitt ${L.length + 1}`)
return [...L, { ...base, planLoc: pl }]
})
}
const addSectionToParallelStream = (phaseOrder, streamOrder) => {
patch((prev) => {
const L = ensure(prev)
const po = Number(phaseOrder) || 0
const so = Number(streamOrder) || 0
const idxs = sectionIndicesForParallelStream(L, po, so)
const tmpl = idxs.length ? L[idxs[0]].planLoc : defaultPlanLocParallel(po, so)
const pl = {
...tmpl,
phaseKind: 'parallel',
phaseOrderIndex: po,
parallelStreamOrderIndex: so,
}
const base = defaultSection(`Abschnitt ${L.length + 1}`)
if (!idxs.length) {
return [...L, { ...base, planLoc: pl }]
}
const insertAfter = Math.max(...idxs)
return [...L.slice(0, insertAfter + 1), { ...base, planLoc: pl }, ...L.slice(insertAfter + 1)]
})
}
const updateParallelPhaseTitleAll = (phaseOrder, title) => {
const po = Number(phaseOrder) || 0
const v = title.trim() ? title.trim() : null
patch((prev) =>
prev.map((s) => {
const L = s?.planLoc
if (L?.phaseKind !== 'parallel' || (L.phaseOrderIndex ?? 0) !== po) return s
return { ...s, planLoc: { ...L, phaseTitle: v } }
})
)
}
const updateParallelStreamTitleAll = (phaseOrder, streamOrder, title) => {
const po = Number(phaseOrder) || 0
const so = Number(streamOrder) || 0
const v = title.trim() ? title.trim() : null
patch((prev) =>
prev.map((s) => {
const L = s?.planLoc
if (
L?.phaseKind !== 'parallel' ||
(L.phaseOrderIndex ?? 0) !== po ||
(L.parallelStreamOrderIndex ?? 0) !== so
) {
return s
}
return { ...s, planLoc: { ...L, streamTitle: v } }
})
)
}
const removeParallelStream = (phaseOrder, streamOrder) => {
const po = Number(phaseOrder) || 0
const so = Number(streamOrder) || 0
const idxs = sectionIndicesForParallelStream(list, po, so)
if (!idxs.length) return
if (
parallelStreamBucketHasContent(list, idxs, SECTION_INSERT_SEPARATOR_BODY) &&
!window.confirm(
'In diesem Stream sind Übungen oder Anmerkungen geplant. Stream wirklich löschen?'
)
) {
return
}
patch((prev) => {
const L = ensure(prev)
const beforeOrders = streamsForParallelPhaseOrders(L, po)
const rm = sectionIndicesForParallelStream(L, po, so)
if (!rm.length) return prev
let next = reorderWithoutIndices(L, rm)
const afterOrders = streamsForParallelPhaseOrders(next, po)
if (beforeOrders.length >= 2 && afterOrders.length <= 1) {
if (
window.confirm(
'Nur noch eine Gruppe in dieser Phase übrig. Parallelen Aufbau auflösen und alle Abschnitte als gemeinsame Ganzgruppen-Phase weiterführen?'
)
) {
next = dissolveParallelPhaseToWholeGroup(next, po)
}
}
return next
})
}
const applySectionPlanTarget = (sIdx, rawKey) => {
patch((prev) => {
if (!rawKey) {
@ -713,11 +822,6 @@ export default function TrainingUnitSectionsEditor({
const list = ensure(sections)
const hasParallelPhase = useMemo(
() => list.some((s) => s?.planLoc?.phaseKind === 'parallel'),
[list]
)
const firstSectionIndexByParallelPhase = useMemo(() => {
const m = new Map()
list.forEach((s, i) => {
@ -737,15 +841,6 @@ export default function TrainingUnitSectionsEditor({
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) => {
@ -881,51 +976,6 @@ export default function TrainingUnitSectionsEditor({
) : null}
</div>
) : null}
{enableParallelPhaseControls ? (
<div
className="card"
style={{
marginBottom: '1rem',
padding: '12px 14px',
background: 'var(--surface2)',
border: '1px solid var(--border, rgba(0,0,0,0.08))',
borderRadius: '10px',
}}
>
<div style={{ fontSize: '0.88rem', fontWeight: 600, marginBottom: '6px', color: 'var(--text1)' }}>
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. 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' }}>
<button type="button" className="btn btn-secondary" onClick={addWholeGroupPhase}>
Neue Ganzgruppen-Phase
</button>
<button type="button" className="btn btn-secondary" onClick={addParallelPhase}>
Neue parallele Phase
</button>
<button
type="button"
className="btn btn-secondary"
onClick={addStreamToLastParallelPhase}
disabled={!hasParallelPhase || cannotAddMoreStreams}
title={
!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
</button>
</div>
</div>
) : null}
{list.map((sec, sIdx) => {
const planMin = sectionPlannedMinutes(sec)
const itemCount = sec.items?.length ?? 0
@ -981,51 +1031,142 @@ export default function TrainingUnitSectionsEditor({
enableParallelPhaseControls &&
streamOrdersForParallelPhase.length ? (
<div
role="tablist"
aria-label={`Parallele Streams · Phase ${parallelPhaseOrder}`}
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginBottom: '10px',
padding: '8px 10px',
marginBottom: '12px',
padding: '10px 12px',
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
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'center',
marginBottom: '10px',
}}
>
<label
className="form-label"
style={{ fontSize: '0.78rem', marginBottom: 0, flex: '0 0 auto' }}
>
Phase
</label>
<input
className="form-input"
style={{ flex: '2 1 160px', maxWidth: '280px', marginBottom: 0 }}
value={
(() => {
const hi = firstSectionIndexByParallelPhase.get(parallelPhaseOrder)
const t = hi != null ? list[hi]?.planLoc?.phaseTitle : null
return t != null ? String(t) : ''
})()
}
onChange={(e) => updateParallelPhaseTitleAll(parallelPhaseOrder, e.target.value)}
placeholder={`Bezeichnung (z. B. Drill runden · Phase ${parallelPhaseOrder})`}
/>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => addStreamToParallelPhase(parallelPhaseOrder)}
disabled={streamOrdersForParallelPhase.length >= MAX_PARALLEL_STREAMS_PER_PHASE}
title={
streamOrdersForParallelPhase.length >= MAX_PARALLEL_STREAMS_PER_PHASE
? `Höchstens ${MAX_PARALLEL_STREAMS_PER_PHASE} Streams`
: 'Weitere parallele Gruppe'
}
>
+ Stream
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() =>
addSectionToParallelStream(parallelPhaseOrder, activeParallelStream ?? 0)
}
title="Neuer Abschnitt im gerade gewählten Stream"
>
+ Abschnitt in diesem Stream
</button>
</div>
<div
role="tablist"
aria-label={`Streams · Phase ${parallelPhaseOrder}`}
style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'stretch' }}
>
{streamOrdersForParallelPhase.map((so) => {
const sel =
(parallelStreamTabByPhase[parallelPhaseOrder] ??
streamOrdersForParallelPhase[0] ??
0) === so
const pv = parallelStreamVisual(so)
const si = sectionIndicesForParallelStream(list, parallelPhaseOrder, so)
const titleSource = si.length ? list[si[0]]?.planLoc?.streamTitle : null
const streamName = titleSource != null ? String(titleSource) : ''
return (
<div
key={`p${parallelPhaseOrder}-chip-s${so}`}
role="tab"
aria-selected={sel}
className={sel ? 'tu-stream-chip tu-stream-chip--active' : 'tu-stream-chip'}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '4px 6px 4px 8px',
borderRadius: '999px',
cursor: 'pointer',
border: sel ? `2px solid ${pv.border}` : `1px solid ${pv.border}`,
background: sel ? pv.tabBgActive : pv.tabBg,
maxWidth: '100%',
}}
onClick={() =>
setParallelStreamTabByPhase((prev) => ({ ...prev, [parallelPhaseOrder]: so }))
}
>
<input
className="form-input"
style={{
minWidth: '5.5rem',
maxWidth: '11rem',
margin: 0,
padding: '4px 8px',
fontSize: '0.8rem',
borderRadius: '8px',
flex: '1 1 auto',
}}
value={streamName}
onChange={(e) =>
updateParallelStreamTitleAll(parallelPhaseOrder, so, e.target.value)
}
onClick={(e) => e.stopPropagation()}
placeholder={`Gruppe ${so + 1}`}
aria-label={`Name Stream ${so + 1}`}
/>
<button
type="button"
className="tu-icon-btn"
style={{
flex: '0 0 auto',
padding: '4px',
color: 'var(--text2)',
borderRadius: '8px',
}}
title="Stream entfernen"
aria-label={`Stream ${so + 1} entfernen`}
onClick={(e) => {
e.stopPropagation()
removeParallelStream(parallelPhaseOrder, so)
}}
>
<X size={16} strokeWidth={2} aria-hidden />
</button>
</div>
)
})}
</div>
</div>
) : null}
{!hideParallelSection ? (
@ -1115,8 +1256,24 @@ export default function TrainingUnitSectionsEditor({
}}
>
{sec.planLoc.phaseKind === 'whole_group'
? `Ganzgruppen-Phase ${sec.planLoc.phaseOrderIndex ?? 0}`
: `Parallel · Phase ${sec.planLoc.phaseOrderIndex ?? 0} · Stream ${sec.planLoc.parallelStreamOrderIndex ?? 0}`}
? (() => {
const pt = sec.planLoc.phaseTitle
const po = sec.planLoc.phaseOrderIndex ?? 0
return pt != null && String(pt).trim()
? `Ganzgruppe: ${String(pt).trim()} (Phase ${po})`
: `Ganzgruppen-Phase ${po}`
})()
: (() => {
const pt = sec.planLoc.phaseTitle
const st = sec.planLoc.streamTitle
const po = sec.planLoc.phaseOrderIndex ?? 0
const so = sec.planLoc.parallelStreamOrderIndex ?? 0
const phaseLbl =
pt != null && String(pt).trim() ? String(pt).trim() : `Phase ${po}`
const streamLbl =
st != null && String(st).trim() ? String(st).trim() : `Gruppe ${so + 1}`
return `Parallel · ${phaseLbl} · ${streamLbl}`
})()}
</p>
) : null}
<textarea
@ -1696,13 +1853,46 @@ export default function TrainingUnitSectionsEditor({
/>
) : null}
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={addSection}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'center',
marginTop: '0.75rem',
}}
>
+ Abschnitt hinzufügen
</button>
{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"
onClick={addParallelPhaseTwoStreams}
>
Zwei Gruppen parallel
</button>
<button
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)"
>
+ Ganzgruppen-Abschnitt
</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>
</div>
{insertChooser ? (
<div

View File

@ -139,7 +139,51 @@ export function sectionIndicesForParallelStream(sections, phaseOrderIndex, strea
return out
}
/** Reihenfolge innerhalb eines Stream-Buckets (globale Indizes) ändern. */
/** Abschnitte an den angegebenen globalen Indizes entfernen (mindestens ein Abschnitt bleibt). */
export function reorderWithoutIndices(prev, removeGlobalIndices) {
const set = new Set(removeGlobalIndices || [])
const next = (prev || []).filter((_, i) => !set.has(i))
return next.length ? next : [defaultSection()]
}
/**
* Ob in den Abschnitten eines Stream-Buckets planerisch etwas steht (Übungen, Text-Anmerkungen).
* Trennlinien-Marker (---) zählen nicht als Inhalt.
*/
export function parallelStreamBucketHasContent(sections, globalIndices, separatorBody = '---') {
for (const gi of globalIndices || []) {
const sec = (sections || [])[gi]
if (!sec) continue
for (const it of sec.items || []) {
if ((it.item_type || '') === 'note') {
const b = (it.note_body || '').trim()
if (b && b !== separatorBody) return true
} else {
if (it.exercise_id) return true
if ((it.exercise_title || '').trim()) return true
}
}
}
return false
}
/** Parallele Phase auflösen: alle Abschnitte dieser Phase werden Ganzgruppe (gleicher phaseOrderIndex). */
export function dissolveParallelPhaseToWholeGroup(sections, phaseOrderIndex) {
const po = Number(phaseOrderIndex) || 0
return (sections || []).map((s) => {
const L = s?.planLoc
if (L?.phaseKind !== 'parallel' || (L.phaseOrderIndex ?? 0) !== po) return s
return {
...s,
planLoc: {
...defaultPlanLocWholeGroup(po),
phaseTitle: L.phaseTitle ?? null,
phaseGuidanceNotes: L.phaseGuidanceNotes ?? null,
},
}
})
}
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) {