Implement visual enhancements and logic for section drop bands in TrainingUnitSectionsEditor
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 36s
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 1m20s

- Added CSS styles for visual representation of section drop bands, differentiating between split and whole group regions.
- Introduced a new function to determine the appropriate drop band class based on section types, improving user experience during section reordering.
- Enhanced drag-and-drop functionality to support parallel phase management, allowing for more intuitive section placement within the editor.
- Updated utility functions to handle the movement of parallel phase runs, ensuring proper plan location adjustments during reordering.
This commit is contained in:
Lars 2026-05-15 08:37:16 +02:00
parent a0a0be8bef
commit 514b64682c
3 changed files with 211 additions and 13 deletions

View File

@ -5935,6 +5935,45 @@ a.analysis-split__nav-item {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 42%, transparent);
}
/* Planungseditor: Einfügebänder — Split vs. Ganzgruppe (inaktiv sichtbar, aktiv weiterhin Akzent-Ring) */
.tu-section-dropband--region-split {
height: 12px;
background: hsl(200 38% 92%);
border: 1px dashed hsl(200 42% 58%);
}
.tu-section-dropband--region-whole {
height: 12px;
background: color-mix(in srgb, var(--accent) 10%, var(--surface2));
border: 1px dashed color-mix(in srgb, var(--accent) 32%, transparent);
}
.tu-section-dropband--region-split-to-whole {
height: 12px;
background: linear-gradient(
90deg,
hsl(200 38% 92%) 0%,
color-mix(in srgb, var(--accent) 12%, var(--surface2)) 100%
);
border: 1px dashed hsl(200 36% 50%);
}
.tu-section-dropband--region-whole-to-split {
height: 12px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--accent) 12%, var(--surface2)) 0%,
hsl(200 38% 92%) 100%
);
border: 1px dashed hsl(200 36% 50%);
}
.tu-section-dropband--region-neutral {
height: 11px;
border: 1px dashed color-mix(in srgb, var(--border) 70%, transparent);
background: color-mix(in srgb, var(--surface2) 55%, transparent);
}
.tu-sec-drag-grip {
flex-shrink: 0;
display: inline-flex;

View File

@ -26,6 +26,9 @@ import {
reorderBlocksImmutableWithPlanLoc,
movePhaseRunUpByPhaseOrder,
movePhaseRunDownByPhaseOrder,
moveParallelPhaseRunToInsertBefore,
afterSectionReorderParallelGuard,
indicesOfParallelPhase,
exerciseRow,
noteRow,
sectionPlannedMinutes,
@ -75,6 +78,31 @@ function dtHasType(e, mime) {
return Array.from(t).includes(mime)
}
/** Visuelle Zuordnung der Einfügezeile zu Split- vs. Ganzgruppen-Bereich (nur Darstellung). */
function sectionDropBandRegionClass(sections, beforeIdx, enableParallel) {
if (!enableParallel) return ''
const n = sections?.length ?? 0
const below = beforeIdx < n ? sections[beforeIdx] : null
const above = beforeIdx > 0 ? sections[beforeIdx - 1] : null
const po = (s) => s?.planLoc?.phaseOrderIndex ?? 0
const aboveP = above?.planLoc?.phaseKind === 'parallel'
const belowP = below?.planLoc?.phaseKind === 'parallel'
const aboveW = above?.planLoc?.phaseKind === 'whole_group'
const belowW = below?.planLoc?.phaseKind === 'whole_group'
if (aboveP && belowP && po(above) === po(below)) {
return ' tu-section-dropband--region-split'
}
if (aboveW && belowW) return ' tu-section-dropband--region-whole'
if (aboveP && belowW) return ' tu-section-dropband--region-split-to-whole'
if (aboveW && belowP) return ' tu-section-dropband--region-whole-to-split'
if (!above && belowP) return ' tu-section-dropband--region-split'
if (!below && aboveP) return ' tu-section-dropband--region-split-to-whole'
if (!above && belowW) return ' tu-section-dropband--region-whole'
if (!below && aboveW) return ' tu-section-dropband--region-whole'
return ' tu-section-dropband--region-neutral'
}
function truncatePreview(text, max = 160) {
const t = (text || '').replace(/\s+/g, ' ').trim()
if (t.length <= max) return t
@ -298,7 +326,13 @@ export default function TrainingUnitSectionsEditor({
streamAssignedTrainerProfileIds: null,
}
const base = defaultSection(`Abschnitt ${prev.length + 1}`)
return [...prev, { ...base, planLoc: tmpl }]
const inPhaseIdx = indicesOfParallelPhase(prev, po)
const insertAfter = inPhaseIdx.length ? Math.max(...inPhaseIdx) : prev.length - 1
return [
...prev.slice(0, insertAfter + 1),
{ ...base, planLoc: tmpl },
...prev.slice(insertAfter + 1),
]
})
}
@ -633,6 +667,25 @@ export default function TrainingUnitSectionsEditor({
const clearSectionDnD = () => setDropSectionBand(null)
const onParallelPhaseDragStart = (e, phaseOrderIndex) => {
if (!enableSectionDragReorder || !enableParallelPhaseControls) return
e.stopPropagation()
try {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData(
DND_TU_SECTION,
JSON.stringify({
fromSlot: sectionToSlot,
fromSectionIdx: null,
phaseRunMove: { phaseOrderIndex },
})
)
} catch {
/* ignore */
}
setDropSectionBand(null)
}
const onSectionDragStart = (e, sIdx) => {
if (!enableSectionDragReorder) return
e.stopPropagation()
@ -683,7 +736,19 @@ export default function TrainingUnitSectionsEditor({
return
}
const fromSi = data.fromSectionIdx
const phaseRunMove = data.phaseRunMove
const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1
if (phaseRunMove != null && phaseRunMove.phaseOrderIndex != null) {
patch((prev) => {
const po = Number(phaseRunMove.phaseOrderIndex) || 0
let next = moveParallelPhaseRunToInsertBefore(prev, po, insertBeforeIdx)
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
return next
})
return
}
if (typeof fromSi !== 'number') return
if (
@ -701,10 +766,11 @@ export default function TrainingUnitSectionsEditor({
}
patch((prev) => {
if (enableParallelPhaseControls) {
return reorderBlocksImmutableWithPlanLoc(prev, fromSi, insertBeforeIdx)
}
return reorderBlocksImmutable(prev, fromSi, insertBeforeIdx)
let next = enableParallelPhaseControls
? reorderBlocksImmutableWithPlanLoc(prev, fromSi, insertBeforeIdx)
: reorderBlocksImmutable(prev, fromSi, insertBeforeIdx)
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
return next
})
}
@ -1048,7 +1114,11 @@ export default function TrainingUnitSectionsEditor({
<Fragment key={`secFrag-${sIdx}`}>
{enableSectionDragReorder ? (
<div
className={'tu-section-dropband' + (bandActiveBefore(sIdx) ? ' tu-section-dropband--active' : '')}
className={
'tu-section-dropband' +
sectionDropBandRegionClass(list, sIdx, enableParallelPhaseControls) +
(bandActiveBefore(sIdx) ? ' tu-section-dropband--active' : '')
}
title="Abschnitt hier einfügen"
onDragOver={(e) => {
if (!enableSectionDragReorder) return
@ -1083,6 +1153,19 @@ export default function TrainingUnitSectionsEditor({
marginBottom: '10px',
}}
>
{enableSectionDragReorder ? (
<span
className="tu-sec-drag-grip"
draggable
onDragStart={(e) => onParallelPhaseDragStart(e, parallelPhaseOrder)}
role="button"
tabIndex={0}
aria-label="Parallele Phase ziehen"
title="Gesamte parallele Phase an neue Planposition ziehen"
>
<GripVertical size={16} strokeWidth={2} aria-hidden />
</span>
) : null}
<label
className="form-label"
style={{ fontSize: '0.78rem', marginBottom: 0, flex: '0 0 auto' }}
@ -2022,6 +2105,7 @@ export default function TrainingUnitSectionsEditor({
<div
className={
'tu-section-dropband tu-section-dropband--end' +
sectionDropBandRegionClass(list, list.length, enableParallelPhaseControls) +
(dropSectionBand &&
dropSectionBand.slot === sectionToSlot &&
dropSectionBand.beforeIdx === list.length

View File

@ -799,8 +799,8 @@ export function movePhaseRunDownByPhaseOrder(prev, phaseOrderIndex) {
}
/**
* Abschnitt verschieben und planLoc an der Einfügestelle an Nachbarn anpassen
* (z. B. Ganzgruppe in parallelen Stream).
* Abschnitt verschieben und planLoc an der Einfügestelle an Nachbarn anpassen.
* Regel: Einfügen vor Abschnitt X übernimmt X.planLoc; am Listenende nach Parallel neue Ganzgruppen-Phase.
*/
export function reorderBlocksImmutableWithPlanLoc(prev, fromI, toBeforeIdx) {
const arr = [...prev]
@ -809,12 +809,32 @@ export function reorderBlocksImmutableWithPlanLoc(prev, fromI, toBeforeIdx) {
let insertAt = toBeforeIdx
if (fromI < toBeforeIdx) insertAt = toBeforeIdx - 1
insertAt = Math.max(0, Math.min(insertAt, arr.length))
const below = arr[insertAt]
const above = arr[insertAt - 1]
const ref = below ?? above
const below = insertAt < arr.length ? arr[insertAt] : undefined
const above = insertAt > 0 ? arr[insertAt - 1] : undefined
let planLocNext = null
if (below?.planLoc?.phaseKind) {
planLocNext = { ...below.planLoc }
} else if (insertAt === arr.length) {
if (!above) {
planLocNext = defaultPlanLocWholeGroup(0)
} else if (above.planLoc?.phaseKind === 'parallel') {
const mx = maxPhaseOrderIndexFromSections(arr)
planLocNext = defaultPlanLocWholeGroup(mx + 1)
} else if (above.planLoc?.phaseKind === 'whole_group') {
planLocNext = { ...above.planLoc }
}
} else if (above?.planLoc?.phaseKind === 'whole_group') {
planLocNext = { ...above.planLoc }
} else if (above?.planLoc?.phaseKind === 'parallel') {
const mx = maxPhaseOrderIndexFromSections(arr)
planLocNext = defaultPlanLocWholeGroup(mx + 1)
}
let nextMoved = { ...moved }
if (ref?.planLoc?.phaseKind) {
nextMoved = { ...moved, planLoc: { ...ref.planLoc } }
if (planLocNext) {
nextMoved = { ...moved, planLoc: planLocNext }
} else {
nextMoved = stripPlanLoc(moved)
}
@ -822,6 +842,61 @@ export function reorderBlocksImmutableWithPlanLoc(prev, fromI, toBeforeIdx) {
return arr
}
/** Alle globalen Indizes einer parallelen Phase (alle Streams), sortiert. */
export function indicesOfParallelPhase(sections, phaseOrderIndex) {
const po = Number(phaseOrderIndex) || 0
const out = []
;(sections || []).forEach((s, i) => {
const L = s?.planLoc
if (L?.phaseKind === 'parallel' && (L.phaseOrderIndex ?? 0) === po) out.push(i)
})
return out.sort((a, b) => a - b)
}
/** Gesamten Parallel-Block an neue Position (insertBefore globale Liste) schieben. */
export function moveParallelPhaseRunToInsertBefore(prev, phaseOrderIndex, toBeforeIdx) {
const po = Number(phaseOrderIndex) || 0
const indices = indicesOfParallelPhase(prev, po)
if (!indices.length) return prev
const indexSet = new Set(indices)
const blocks = indices.map((i) => prev[i])
const without = prev.filter((_, i) => !indexSet.has(i))
let ins = toBeforeIdx
for (const i of indices) {
if (i < toBeforeIdx) ins -= 1
}
ins = Math.max(0, Math.min(ins, without.length))
return [...without.slice(0, ins), ...blocks, ...without.slice(ins)]
}
/**
* Nach Drag&Drop: wenn aus einer Parallelphase noch 1 Stream übrig ist (vorher 2), Rückfrage wie beim Stream-Löschen.
*/
export function afterSectionReorderParallelGuard(prev, next) {
const seenPo = new Set()
for (const s of prev || []) {
const L = s?.planLoc
if (L?.phaseKind !== 'parallel') continue
seenPo.add(L.phaseOrderIndex ?? 0)
}
let out = next
for (const po of seenPo) {
const prevN = streamsForParallelPhaseOrders(prev, po).length
if (prevN < 2) continue
const nowN = streamsForParallelPhaseOrders(out, po).length
if (nowN <= 1) {
if (
window.confirm(
'In dieser parallelen Phase ist nur noch eine Gruppe übrig. Parallelaufbau auflösen und alle zugehörigen Abschnitte als gemeinsame Ganzgruppen-Phase führen?'
)
) {
out = dissolveParallelPhaseToWholeGroup(out, po)
}
}
}
return out
}
function buildPhasesPayloadFromFlat(sections) {
const norm = inheritPlanLocForPhasedSave(sections)
const phases = []