chore(version): update version and changelog for release 0.8.140
All checks were successful
Deploy Development / deploy (push) Successful in 42s
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 1m8s

- Bumped APP_VERSION to 0.8.140 and updated the changelog to reflect recent changes.
- Enhanced the Training Planning Module with new controls for managing whole group and parallel phases, including the ability to add streams to existing parallel phases.
- Introduced utility functions for handling phase and stream configurations, improving the overall structure and usability of the training unit sections editor.
- Updated the TrainingPlanningUnitFormModal to support the new phase controls, ensuring seamless integration with the frontend components.
This commit is contained in:
Lars 2026-05-15 07:25:59 +02:00
parent 749c185e3d
commit f50e9db523
4 changed files with 256 additions and 2 deletions

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.139"
APP_VERSION = "0.8.140"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260515063"
@ -36,6 +36,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.140",
"date": "2026-05-14",
"changes": [
"Frontend Trainingsplanung: Breakout-Panel (neue Ganzgruppen-/parallele Phase, Stream in letzter parallelen Phase); pro Abschnitt Zuordnung zu Phase/Stream oder klassischer Ein-Ganzgruppen-Ablauf.",
],
},
{
"version": "0.8.139",
"date": "2026-05-14",

View File

@ -8,6 +8,11 @@ import {
cloneJsonSerializablePlanningProfile,
comboSlotsOutlineForProfileEditor,
defaultSection,
defaultPlanLocWholeGroup,
defaultPlanLocParallel,
maxPhaseOrderIndexFromSections,
buildPlanTargetOptions,
planLocKey,
exerciseRow,
noteRow,
sectionPlannedMinutes,
@ -16,6 +21,28 @@ import api from '../utils/api'
import { isCompactTagLegendMode } from '../config/planningModuleUx'
import { useAuth } from '../context/AuthContext'
function stripPlanLocFromSection(s) {
if (!s || typeof s !== 'object') return s
const { planLoc: _ignored, ...rest } = s
return rest
}
function planSelectOptionsForSection(sections, sIdx, baseOpts) {
const sec = sections[sIdx]
const k = planLocKey(sec?.planLoc)
if (k && !baseOpts.some((o) => o.key === k)) {
const pl = sec.planLoc
const label =
pl.phaseKind === 'parallel'
? `Parallel · Phase ${pl.phaseOrderIndex ?? 0} · Stream ${pl.parallelStreamOrderIndex ?? 0}`
: `Ganzgruppe · Phase ${pl.phaseOrderIndex ?? 0}`
return [...baseOpts, { key: k, label, template: { ...pl } }].sort((a, b) =>
a.key.localeCompare(b.key, undefined, { numeric: true })
)
}
return baseOpts
}
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item'
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
@ -192,6 +219,8 @@ export default function TrainingUnitSectionsEditor({
onMoveSectionsAcrossSlots = null,
/** Dünnes „+“ zwischen Einträge: Popup für Typ (Übung, Modul, …) */
betweenInsertMenus = true,
/** Trainingsplanung: Phasen/Streams anlegen und Abschnitte zuordnen */
enableParallelPhaseControls = false,
}) {
const { user } = useAuth()
const planningCompactLegend = isCompactTagLegendMode(
@ -218,7 +247,63 @@ export default function TrainingUnitSectionsEditor({
}
const addSection = () => {
patch((prev) => [...prev, defaultSection(`Abschnitt ${prev.length + 1}`)])
patch((prev) => {
const base = defaultSection(`Abschnitt ${prev.length + 1}`)
const last = prev[prev.length - 1]
const next = last?.planLoc ? { ...base, planLoc: { ...last.planLoc } } : base
return [...prev, next]
})
}
const addWholeGroupPhase = () => {
patch((prev) => {
const nextPo = maxPhaseOrderIndexFromSections(prev) + 1
const pl = defaultPlanLocWholeGroup(nextPo)
const base = defaultSection(`Abschnitt ${prev.length + 1}`)
return [...prev, { ...base, planLoc: pl }]
})
}
const addParallelPhase = () => {
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 addStreamToLastParallelPhase = () => {
patch((prev) => {
const par = (prev || []).filter((s) => s?.planLoc?.phaseKind === 'parallel')
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 maxS = Math.max(...inPhase.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0))
const newSo = maxS + 1
const tmpl = {
...inPhase[0].planLoc,
parallelStreamOrderIndex: newSo,
streamTitle: null,
streamNotes: null,
streamAssignedTrainerProfileIds: null,
}
const base = defaultSection(`Abschnitt ${prev.length + 1}`)
return [...prev, { ...base, planLoc: tmpl }]
})
}
const applySectionPlanTarget = (sIdx, rawKey) => {
patch((prev) => {
if (!rawKey) {
return prev.map((s, i) => (i === sIdx ? stripPlanLocFromSection(s) : s))
}
const opts = planSelectOptionsForSection(prev, sIdx, buildPlanTargetOptions(prev))
const hit = opts.find((o) => o.key === rawKey)
if (!hit) return prev
const tpl = { ...hit.template }
return prev.map((s, i) => (i === sIdx ? { ...s, planLoc: tpl } : s))
})
}
const removeSection = (sIdx) => {
@ -604,6 +689,11 @@ export default function TrainingUnitSectionsEditor({
const list = ensure(sections)
const hasParallelPhase = useMemo(
() => list.some((s) => s?.planLoc?.phaseKind === 'parallel'),
[list]
)
const comboPlanningModalDerived = useMemo(() => {
if (!comboPlanningModal) {
return { item: null, sIdx: null, iIdx: null }
@ -686,6 +776,48 @@ 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. Abschnitt hinzufügen unten
ü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}
title={
hasParallelPhase
? 'Weiterer Stream in der letzten parallelen Phase (höchster Phasen-Index)'
: 'Zuerst eine parallele Phase anlegen'
}
>
Stream hinzufügen
</button>
</div>
</div>
) : null}
{list.map((sec, sIdx) => {
const planMin = sectionPlannedMinutes(sec)
const itemCount = sec.items?.length ?? 0
@ -784,6 +916,20 @@ export default function TrainingUnitSectionsEditor({
Abschnitt entfernen
</button>
</div>
{enableParallelPhaseControls && sec.planLoc ? (
<p
style={{
fontSize: '0.75rem',
color: 'var(--text2)',
margin: '0 0 8px',
fontWeight: 500,
}}
>
{sec.planLoc.phaseKind === 'whole_group'
? `Ganzgruppen-Phase ${sec.planLoc.phaseOrderIndex ?? 0}`
: `Parallel · Phase ${sec.planLoc.phaseOrderIndex ?? 0} · Stream ${sec.planLoc.parallelStreamOrderIndex ?? 0}`}
</p>
) : null}
<textarea
className="form-input"
rows={2}
@ -793,6 +939,25 @@ export default function TrainingUnitSectionsEditor({
}
placeholder="Hinweise zum Abschnitt (Material, Zeit, Zielrichtung …)"
/>
{enableParallelPhaseControls ? (
<div className="form-row" style={{ marginTop: '10px', marginBottom: '2px' }}>
<label className="form-label" style={{ fontSize: '0.78rem' }}>
Zuordnung
</label>
<select
className="form-input"
value={planLocKey(sec.planLoc)}
onChange={(e) => applySectionPlanTarget(sIdx, e.target.value)}
>
<option value="">Standard eine Ganzgruppe (klassischer Ablauf)</option>
{planSelectOptionsForSection(list, sIdx, buildPlanTargetOptions(list)).map((o) => (
<option key={o.key} value={o.key}>
{o.label}
</option>
))}
</select>
</div>
) : null}
{planMin > 0 && (
<p style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)

View File

@ -353,6 +353,7 @@ export default function TrainingPlanningUnitFormModal({
onRequestExercisePick={onRequestExercisePick}
onPeekExercise={onPeekExercise}
showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
enableParallelPhaseControls
/>
</div>

View File

@ -5,6 +5,87 @@ export function defaultSection(title = 'Hauptteil') {
return { title, guidance_notes: '', items: [] }
}
/** Standard-`planLoc` für eine Ganzgruppen-Phase (Editor-Breakout-UI). */
export function defaultPlanLocWholeGroup(phaseOrderIndex = 0) {
return {
phaseKind: 'whole_group',
phaseOrderIndex,
parallelStreamOrderIndex: null,
phaseTitle: null,
phaseGuidanceNotes: null,
streamTitle: null,
streamNotes: null,
streamAssignedTrainerProfileIds: null,
}
}
/** Standard-`planLoc` für einen Stream innerhalb einer parallelen Phase. */
export function defaultPlanLocParallel(phaseOrderIndex, streamOrderIndex) {
return {
phaseKind: 'parallel',
phaseOrderIndex,
parallelStreamOrderIndex: streamOrderIndex,
phaseTitle: null,
phaseGuidanceNotes: null,
streamTitle: null,
streamNotes: null,
streamAssignedTrainerProfileIds: null,
}
}
export function planLocKey(pl) {
if (!pl || !pl.phaseKind) return ''
if (pl.phaseKind === 'whole_group') return `wg:${pl.phaseOrderIndex ?? 0}`
return `par:${pl.phaseOrderIndex ?? 0}:${pl.parallelStreamOrderIndex ?? 0}`
}
export function maxPhaseOrderIndexFromSections(sections) {
let m = -1
for (const s of sections || []) {
const pl = s?.planLoc
if (!pl || typeof pl.phaseOrderIndex !== 'number') continue
if (pl.phaseOrderIndex > m) m = pl.phaseOrderIndex
}
return m
}
/**
* Eindeutige Ziele für die Zuordnung eines Abschnitts (Dropdown).
* `template` ist ein vollständiges planLoc-Objekt zum Kopieren.
*/
export function buildPlanTargetOptions(sections) {
const map = new Map()
for (const s of sections || []) {
const pl = s?.planLoc
if (!pl?.phaseKind) continue
if (pl.phaseKind === 'whole_group') {
const po = pl.phaseOrderIndex ?? 0
const k = `wg:${po}`
if (!map.has(k)) {
const title = pl.phaseTitle != null && String(pl.phaseTitle).trim() ? String(pl.phaseTitle).trim() : ''
map.set(k, {
key: k,
label: title || `Ganzgruppe · Phase ${po}`,
template: { ...pl },
})
}
} else {
const po = pl.phaseOrderIndex ?? 0
const so = pl.parallelStreamOrderIndex ?? 0
const k = `par:${po}:${so}`
if (!map.has(k)) {
const st = pl.streamTitle != null && String(pl.streamTitle).trim() ? String(pl.streamTitle).trim() : ''
map.set(k, {
key: k,
label: st || `Parallel · Phase ${po} · Stream ${so}`,
template: { ...pl },
})
}
}
}
return [...map.values()].sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }))
}
function normalizeCatalogMethodProfile(cp) {
if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp }
return {}