feat(training-planning): enhance training module integration and UI
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 56s

- Introduced a new function to handle optional source training module IDs, ensuring proper validation and integration.
- Updated the backend to include source training module ID and title in section items, allowing for better tracking of module origins.
- Enhanced the frontend to display module bands in the Training Unit Sections Editor, improving user experience by indicating the source of exercises and notes.
- Added functionality to insert training modules at specified positions within sections, providing users with more control over their training plans.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-12 21:53:41 +02:00
parent c1243651bb
commit e41908af73
6 changed files with 385 additions and 83 deletions

View File

@ -404,6 +404,19 @@ _ORIGIN_LINEAGE_FIELDS = """
"""
def _optional_source_training_module_id_payload(raw_val) -> Optional[int]:
"""Erlaubt None; sonst positives int (FK-Verletzung bei ungültigem Modul möglich)."""
if raw_val is None or raw_val == "":
return None
try:
i = int(raw_val)
except (TypeError, ValueError):
return None
if i < 1:
return None
return i
def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
cur.execute(
"""
@ -429,10 +442,12 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
LIMIT 1
) AS exercise_focus_area,
ev.variant_name AS exercise_variant_name
ev.variant_name AS exercise_variant_name,
tm.title AS source_module_title
FROM training_unit_section_items tusi
LEFT JOIN exercises e ON tusi.exercise_id = e.id
LEFT JOIN exercise_variants ev ON tusi.exercise_variant_id = ev.id
LEFT JOIN training_modules tm ON tm.id = tusi.source_training_module_id
WHERE tusi.section_id = %s
ORDER BY tusi.order_index
""",
@ -453,18 +468,19 @@ def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]:
itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note")
oix = it.get("order_index")
if itype == "note":
items_clean.append(
{
note_item = {
"item_type": "note",
"order_index": oix,
"note_body": it.get("note_body") or "",
}
)
sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
if sm is not None:
note_item["source_training_module_id"] = sm
items_clean.append(note_item)
continue
if itype != "exercise" or not it.get("exercise_id"):
continue
items_clean.append(
{
ex_item = {
"item_type": "exercise",
"order_index": oix,
"exercise_id": it["exercise_id"],
@ -474,7 +490,10 @@ def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]:
"notes": it.get("notes"),
"modifications": it.get("modifications"),
}
)
sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
if sm is not None:
ex_item["source_training_module_id"] = sm
items_clean.append(ex_item)
out.append(
{
"title": sec.get("title"),
@ -670,18 +689,19 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
body = raw.get("note_body")
if body is None:
body = ""
src_mod = _optional_source_training_module_id_payload(raw.get("source_training_module_id"))
cur.execute(
"""
INSERT INTO training_unit_section_items (
section_id, order_index, item_type,
exercise_id, exercise_variant_id,
planned_duration_min, actual_duration_min,
notes, modifications, note_body
notes, modifications, note_body, source_training_module_id
) VALUES (%s, %s, 'note',
NULL, NULL, NULL, NULL, NULL, NULL, %s
NULL, NULL, NULL, NULL, NULL, NULL, %s, %s
)
""",
(section_id, order_ix, body),
(section_id, order_ix, body, src_mod),
)
continue
@ -691,15 +711,16 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
eid = int(eid)
vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id")
_validate_variant_for_exercise(cur, eid, vid)
src_mod = _optional_source_training_module_id_payload(raw.get("source_training_module_id"))
cur.execute(
"""
INSERT INTO training_unit_section_items (
section_id, order_index, item_type,
exercise_id, exercise_variant_id,
planned_duration_min, actual_duration_min,
notes, modifications, note_body
notes, modifications, note_body, source_training_module_id
) VALUES (%s, %s, 'exercise',
%s, %s, %s, %s, %s, %s, NULL
%s, %s, %s, %s, %s, %s, NULL, %s
)
""",
(
@ -711,6 +732,7 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
raw.get("actual_duration_min"),
raw.get("notes"),
raw.get("modifications"),
src_mod,
),
)

View File

@ -5115,6 +5115,19 @@ a.analysis-split__nav-item {
max-width: 100%;
}
.tu-planning-module-band {
margin-top: 0.85rem;
margin-bottom: 0.05rem;
padding: 0.35rem 0.65rem;
border-radius: 8px;
border: 1px solid var(--border2);
background: linear-gradient(to right, var(--accent-light), transparent 140%);
font-size: 0.78rem;
font-weight: 700;
color: var(--accent-dark);
letter-spacing: 0.01em;
}
.tu-item-row {
display: flex;
flex-wrap: wrap;

View File

@ -10,6 +10,12 @@ import {
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item'
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
function normalizedPlanningModuleChainId(raw) {
if (raw == null || raw === '') return null
const n = typeof raw === 'number' ? raw : Number(raw)
return Number.isFinite(n) && n >= 1 ? n : null
}
function dtHasType(e, mime) {
const t = e?.dataTransfer?.types
if (!t || !mime) return false
@ -521,15 +527,29 @@ export default function TrainingUnitSectionsEditor({
}
: {}
const prevIt = iIdx > 0 ? sec.items[iIdx - 1] : null
const curMn = normalizedPlanningModuleChainId(it.source_training_module_id)
const showModuleBand =
curMn != null && curMn !== normalizedPlanningModuleChainId(prevIt?.source_training_module_id)
const modBandTitle =
(it.source_module_title || '').trim() ||
(curMn != null ? `Modul #${curMn}` : '')
if (it.item_type === 'note') {
const notePv = truncatePreview(it.note_body || '', 260)
const noteHasText = Boolean((it.note_body || '').trim())
return (
<Fragment key={`it-${sIdx}-${iIdx}`}>
{showModuleBand ? (
<div
key={`note-${sIdx}-${iIdx}`}
className={`${rowCommon} tu-item-row--note`}
{...dndRowProps}
className="tu-planning-module-band"
role="group"
aria-label={`Baustein ${modBandTitle}`}
>
Baustein: {modBandTitle}
</div>
) : null}
<div className={`${rowCommon} tu-item-row--note`} {...dndRowProps}>
{enableItemDragReorder ? (
<span
className="tu-row-grip"
@ -596,6 +616,7 @@ export default function TrainingUnitSectionsEditor({
</button>
</div>
</Fragment>
)
}
@ -611,11 +632,17 @@ export default function TrainingUnitSectionsEditor({
: Number(it.exercise_variant_id)
return (
<Fragment key={`it-${sIdx}-${iIdx}`}>
{showModuleBand ? (
<div
key={`ex-${sIdx}-${iIdx}`}
className={`${rowCommon} tu-item-row--exercise`}
{...dndRowProps}
className="tu-planning-module-band"
role="group"
aria-label={`Baustein ${modBandTitle}`}
>
Baustein: {modBandTitle}
</div>
) : null}
<div className={`${rowCommon} tu-item-row--exercise`} {...dndRowProps}>
<div className="tu-item-row__mainline">
{enableItemDragReorder ? (
<span
@ -793,6 +820,7 @@ export default function TrainingUnitSectionsEditor({
</div>
) : null}
</div>
</Fragment>
)
})}

View File

@ -1,8 +1,10 @@
import React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
import ExercisePickerModal from '../components/ExercisePickerModal'
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
import { useAuth } from '../context/AuthContext'
import { activeClubMemberships, getResolvedActiveClubIdForUi } from '../utils/activeClub'
function nextLocalKey() {
return `m-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
@ -38,6 +40,19 @@ export default function TrainingModuleEditPage() {
const [primaryMethodId, setPrimaryMethodId] = useState('')
const [items, setItems] = useState([])
const { user } = useAuth()
const clubChoices = useMemo(() => activeClubMemberships(user?.clubs ?? []), [user?.clubs])
useEffect(() => {
if (!isNew || visibility !== 'club') return
if ((clubIdField || '').trim() !== '') return
if (clubChoices.length === 1) setClubIdField(String(clubChoices[0].id))
else {
const r = getResolvedActiveClubIdForUi(user)
if (r) setClubIdField(String(r))
}
}, [isNew, visibility, clubIdField, clubChoices, user])
const itemsPayload = items.map((it, i) => {
if (it.item_type === 'note') {
return { item_type: 'note', order_index: i, note_body: it.note_body ?? '' }
@ -133,8 +148,16 @@ export default function TrainingModuleEditPage() {
}, [isNew, moduleId])
const buildBody = () => {
const cid =
visibility === 'club' && clubIdField !== '' ? parseInt(clubIdField, 10) : null
let cid = null
if (visibility === 'club') {
const raw = (clubIdField || '').trim()
if (raw !== '') {
const p = parseInt(raw, 10)
if (Number.isFinite(p) && p >= 1) cid = p
} else if (clubChoices.length === 1) {
cid = clubChoices[0].id
}
}
const pm =
primaryMethodId !== '' && primaryMethodId != null ? parseInt(primaryMethodId, 10) : null
return {
@ -270,22 +293,79 @@ export default function TrainingModuleEditPage() {
<div className="form-row" style={{ display: 'grid', gap: '1rem', gridTemplateColumns: '1fr 1fr' }}>
<div>
<label className="form-label">Sichtbarkeit</label>
<select className="form-input" value={visibility} onChange={(e) => setVisibility(e.target.value)}>
<select
className="form-input"
value={visibility}
onChange={(e) => {
const v = e.target.value
setVisibility(v)
if (v !== 'club') {
setClubIdField('')
return
}
const xs = clubChoices
if (xs.length === 1) setClubIdField(String(xs[0].id))
else if (xs.length === 0) setClubIdField('')
else {
const resolved = getResolvedActiveClubIdForUi(user)
setClubIdField(resolved != null ? String(resolved) : '')
}
}}
>
<option value="private">Privat</option>
<option value="club">Vereinsintern</option>
<option value="official">Offiziell</option>
</select>
</div>
<div>
<label className="form-label">VereinsID (optional, bei VereinsSichtbarkeit)</label>
<label className="form-label">Verein (bei Vereinsintern)</label>
{visibility !== 'club' ? (
<p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Bei privaten oder offiziellen Modulen ist keine Vereinszuordnung nötig (Server legt keine
Vereinsbindung fest).
</p>
) : clubChoices.length === 0 ? (
<p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: 'var(--danger)', lineHeight: 1.45 }}>
Kein aktiver Verein im Profil bitte zuerst einem Verein beitreten.
</p>
) : clubChoices.length === 1 ? (
<>
<input
className="form-input"
type="number"
min={1}
disabled
readOnly
value={
(clubChoices[0].short_name || clubChoices[0].name || '').trim() ||
`Verein #${clubChoices[0].id}`
}
/>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Fixiert durch deine Mitgliedschaft. Verein-ID {clubChoices[0].id} wird beim Speichern verwendet.
</p>
</>
) : (
<>
<select
className="form-input"
value={clubIdField}
onChange={(e) => setClubIdField(e.target.value)}
placeholder="Leer = aktiver Verein (Server)"
/>
>
<option value="">Automatisch (aktueller Verein im Profil)</option>
{clubChoices.map((c) => {
const ln = `${((c.short_name || c.name || '').trim() || '') || `Verein #${c.id}`}`
return (
<option key={c.id} value={String(c.id)}>
{ln}
</option>
)
})}
</select>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Bei Automatisch entscheidet der aktiv gewählte Verein beim Speichern (wie bei anderen
Bibliotheksinhalten).
</p>
</>
)}
</div>
</div>

View File

@ -14,6 +14,7 @@ import {
enrichSectionsWithVariants,
buildSectionsPayload,
hydrateExercisePlanningRow,
insertTrainingModuleIntoPlanningSections,
} from '../utils/trainingUnitSectionsForm'
function addDaysIsoDate(isoDay, daysDelta) {
@ -149,6 +150,7 @@ function TrainingPlanningPage() {
const [moduleApplyList, setModuleApplyList] = useState([])
const [moduleApplyModuleId, setModuleApplyModuleId] = useState('')
const [moduleApplySectionIx, setModuleApplySectionIx] = useState(0)
const [moduleApplyInsertSlot, setModuleApplyInsertSlot] = useState('__end__')
const [moduleApplyErr, setModuleApplyErr] = useState('')
const [startDate, setStartDate] = useState(today)
@ -196,6 +198,19 @@ function TrainingPlanningPage() {
return Number.isFinite(c) ? c : null
}, [groups, formData.group_id])
const moduleApplyTargetItems = useMemo(() => {
const secs = formData.sections || []
if (!secs.length) return []
let ix =
typeof moduleApplySectionIx === 'number'
? moduleApplySectionIx
: parseInt(String(moduleApplySectionIx), 10)
if (!Number.isFinite(ix)) ix = 0
if (ix < 0 || ix >= secs.length) return []
const sec = secs[ix]
return Array.isArray(sec?.items) ? sec.items : []
}, [formData.sections, moduleApplySectionIx])
const refreshPlanningSectionMeta = useCallback(async () => {
const next = await enrichSectionsWithVariants(planningFormRef.current.sections)
setFormData((prev) => ({ ...prev, sections: next }))
@ -672,6 +687,7 @@ function TrainingPlanningPage() {
const openModuleApplyModal = useCallback(async () => {
setModuleApplyErr('')
setModuleApplySectionIx(0)
setModuleApplyInsertSlot('__end__')
setModuleApplyOpen(true)
try {
const list = await api.listTrainingModules()
@ -685,41 +701,48 @@ function TrainingPlanningPage() {
}, [])
const handleApplyTrainingModuleConfirm = useCallback(async () => {
if (!editingUnit?.id) return
const mid = parseInt(moduleApplyModuleId, 10)
if (!Number.isFinite(mid)) {
alert('Bitte ein Trainingsmodul wählen.')
return
}
let secIx = parseInt(moduleApplySectionIx, 10)
let secIx = parseInt(String(moduleApplySectionIx), 10)
if (!Number.isFinite(secIx)) secIx = 0
if (!formData.sections?.length) {
const baseSections = planningFormRef.current?.sections ?? formData.sections ?? []
if (!baseSections.length) {
alert('Keine Abschnitte im Formular.')
return
}
if (secIx < 0 || secIx >= formData.sections.length) secIx = 0
if (secIx < 0 || secIx >= baseSections.length) secIx = 0
let insertBefore = null
if (moduleApplyInsertSlot === '__end__') insertBefore = 'end'
else if (moduleApplyInsertSlot === '__start__') insertBefore = 'start'
else if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) {
const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10)
insertBefore = Number.isFinite(zi) ? zi : 'end'
} else insertBefore = 'end'
setModuleApplyBusy(true)
setModuleApplyErr('')
try {
await api.applyTrainingModuleToTrainingUnit(editingUnit.id, {
module_id: mid,
section_order_index: secIx,
const detail = await api.getTrainingModule(mid)
let nextSections = await insertTrainingModuleIntoPlanningSections({
sections: baseSections,
moduleDetail: detail,
sectionIndex: secIx,
insertBeforeItemIndex: insertBefore,
})
await handleEdit({ id: editingUnit.id })
nextSections = await enrichSectionsWithVariants(nextSections)
setFormData((fd) => ({ ...fd, sections: nextSections }))
setModuleApplyOpen(false)
} catch (e) {
setModuleApplyErr(e.message || 'Übernehmen fehlgeschlagen')
setModuleApplyErr(e.message || 'Einfügen fehlgeschlagen')
} finally {
setModuleApplyBusy(false)
}
}, [
editingUnit?.id,
moduleApplyModuleId,
moduleApplySectionIx,
formData.sections?.length,
handleEdit,
])
}, [moduleApplyModuleId, moduleApplySectionIx, moduleApplyInsertSlot, formData.sections])
const handleTakeLead = async (unit) => {
if (!user?.id) return
@ -1895,11 +1918,12 @@ function TrainingPlanningPage() {
aria-labelledby="module-apply-title"
>
<h2 id="module-apply-title" style={{ marginBottom: '0.5rem', fontSize: '1.15rem' }}>
Trainingsmodul übernehmen
Modul einfügen
</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Der Inhalt wird <strong>kopiert</strong> und ans Ende des gewählten Abschnitts angehängt (Herkunft wird
gespeichert). Anschließend kannst du ihn lokal bearbeiten.
Übungen und Notizen des Moduls werden <strong>kopiert</strong> wie bei einer einzelnen Übung
ohne die Einheit vorher gespeichert zu haben (Speichern am Ende wie gewohnt). Die Herkunft bleibt
am Block sichtbar; du kannst alles weiter anpassen.
</p>
{moduleApplyErr ? (
@ -1930,7 +1954,10 @@ function TrainingPlanningPage() {
<select
className="form-input"
value={String(moduleApplySectionIx)}
onChange={(e) => setModuleApplySectionIx(parseInt(e.target.value, 10))}
onChange={(e) => {
setModuleApplySectionIx(parseInt(e.target.value, 10))
setModuleApplyInsertSlot('__end__')
}}
disabled={moduleApplyBusy || !formData.sections?.length}
>
{(formData.sections || []).map((s, i) => (
@ -1941,6 +1968,32 @@ function TrainingPlanningPage() {
</select>
</div>
<div className="form-row">
<label className="form-label">Position in diesem Abschnitt</label>
<select
className="form-input"
value={moduleApplyInsertSlot}
onChange={(e) => setModuleApplyInsertSlot(e.target.value)}
disabled={moduleApplyBusy || !(formData.sections?.length > 0)}
>
<option value="__end__">Ans Ende einfügen (nach allen Einträgen)</option>
<option value="__start__">An den Anfang (vor dem ersten Eintrag)</option>
{moduleApplyTargetItems.map((row, xi) => {
const labelPart =
row.item_type === 'note'
? 'Zwischen-Anmerkung'
: (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
const clipped =
labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}` : labelPart
return (
<option key={`before-${xi}`} value={`before:${xi}`}>
Vor Eintrag {xi + 1}: {clipped}
</option>
)
})}
</select>
</div>
<div
style={{
display: 'flex',
@ -1959,7 +2012,7 @@ function TrainingPlanningPage() {
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={moduleApplyBusy} onClick={handleApplyTrainingModuleConfirm}>
{moduleApplyBusy ? 'Übernehmen …' : 'Übernehmen'}
{moduleApplyBusy ? 'Einfügen …' : 'Einfügen'}
</button>
</div>
@ -2473,16 +2526,14 @@ function TrainingPlanningPage() {
<button type="button" className="btn btn-secondary" onClick={handleSaveAsTemplate}>
Vorlage aus Aufbau speichern
</button>
{editingUnit?.id ? (
<button
type="button"
className="btn btn-secondary"
onClick={openModuleApplyModal}
title="Übungen und Notizen aus einem Modul ans Ende eines Abschnitts kopieren"
title="Modulpositionen hier einfügen (Kopie, auch ohne zwischengespeicherte Einheit)"
>
Aus Modul übernehmen
Modul einfügen
</button>
) : null}
</>
}
sections={formData.sections}

View File

@ -15,6 +15,8 @@ export function exerciseRow() {
actual_duration_min: '',
notes: '',
modifications: '',
source_training_module_id: '',
source_module_title: '',
}
}
@ -69,7 +71,14 @@ export async function hydrateExercisePlanningRow(exercise) {
}
export function noteRow() {
return { item_type: 'note', note_body: '' }
return { item_type: 'note', note_body: '', source_training_module_id: '', source_module_title: '' }
}
/** Zur Serialisierung in die Planungs-API (persistente Modul-Herkunft). */
function parseOptionalSourceTrainingModuleIdForPayload(v) {
if (v === null || v === undefined || v === '') return null
const n = typeof v === 'number' ? v : parseInt(String(v).trim(), 10)
return Number.isFinite(n) && n >= 1 ? n : null
}
export function normalizeUnitToForm(fullUnit) {
@ -79,8 +88,24 @@ export function normalizeUnitToForm(fullUnit) {
guidance_notes: sec.guidance_notes || '',
items: (sec.items || []).map((it) => {
if (it.item_type === 'note') {
return { item_type: 'note', note_body: it.note_body || '' }
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
const rowNote = {
item_type: 'note',
note_body: it.note_body || '',
source_training_module_id: '',
source_module_title: '',
}
if (sm != null) {
rowNote.source_training_module_id = sm
rowNote.source_module_title = (
it.source_module_title ||
it.source_training_module_title ||
''
).trim()
}
return rowNote
}
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
return {
item_type: 'exercise',
exercise_id: it.exercise_id,
@ -97,6 +122,16 @@ export function normalizeUnitToForm(fullUnit) {
: '',
notes: it.notes ?? '',
modifications: it.modifications ?? '',
...(smEx != null
? {
source_training_module_id: smEx,
source_module_title: (
it.source_module_title ||
it.source_training_module_title ||
''
).trim(),
}
: {}),
}
}),
}))
@ -199,17 +234,21 @@ export function buildSectionsPayload(sections) {
items: (sec.items || [])
.map((it, ii) => {
if (it.item_type === 'note') {
return {
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
const row = {
item_type: 'note',
order_index: ii,
note_body: it.note_body ?? '',
}
if (sm != null) row.source_training_module_id = sm
return row
}
if (it.exercise_id === '' || it.exercise_id == null || Number.isNaN(Number(it.exercise_id))) {
return null
}
const vid = it.exercise_variant_id
return {
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
const rowEx = {
item_type: 'exercise',
order_index: ii,
exercise_id: parseInt(it.exercise_id, 10),
@ -220,11 +259,80 @@ export function buildSectionsPayload(sections) {
notes: it.notes?.trim() ? it.notes.trim() : null,
modifications: it.modifications?.trim() ? it.modifications.trim() : null,
}
if (smEx != null) rowEx.source_training_module_id = smEx
return rowEx
})
.filter(Boolean),
}))
}
/** Fügt die Positionen eines Moduls in lokale Abschnitte ein (wie eine Übung, ohne Zwischenspeichern der Einheit). */
export async function insertTrainingModuleIntoPlanningSections({
sections,
moduleDetail,
sectionIndex,
insertBeforeItemIndex,
}) {
const secIx = typeof sectionIndex === 'number' ? sectionIndex : parseInt(String(sectionIndex), 10)
if (
!Array.isArray(sections) ||
!Number.isFinite(secIx) ||
secIx < 0 ||
secIx >= sections.length ||
!moduleDetail ||
typeof moduleDetail !== 'object'
) {
return sections
}
const prev = [...(sections[secIx].items || [])]
let beforeIx
if (insertBeforeItemIndex === null || insertBeforeItemIndex === undefined || insertBeforeItemIndex === 'end') {
beforeIx = prev.length
} else if (insertBeforeItemIndex === 'start') {
beforeIx = 0
} else {
const n = typeof insertBeforeItemIndex === 'number' ? insertBeforeItemIndex : parseInt(String(insertBeforeItemIndex), 10)
beforeIx = Number.isFinite(n) ? Math.min(Math.max(n, 0), prev.length) : prev.length
}
const midRaw = moduleDetail.id
const midNum = typeof midRaw === 'number' ? midRaw : parseInt(String(midRaw), 10)
if (!Number.isFinite(midNum) || midNum < 1) return sections
const modTitle = (moduleDetail.title || '').trim() || `Modul #${midNum}`
const modItems = [...(moduleDetail.items || [])].sort(
(a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)
)
const appendRows = []
for (const mi of modItems) {
if (mi.item_type === 'note') {
appendRows.push({
item_type: 'note',
note_body: mi.note_body || '',
source_training_module_id: midNum,
source_module_title: modTitle,
})
continue
}
if (!mi.exercise_id) continue
const hydrated = await hydrateExercisePlanningRow({ id: mi.exercise_id })
if (!hydrated) continue
hydrated.source_training_module_id = midNum
hydrated.source_module_title = modTitle
if (mi.exercise_variant_id) hydrated.exercise_variant_id = String(mi.exercise_variant_id)
hydrated.planned_duration_min =
mi.planned_duration_min !== null && mi.planned_duration_min !== undefined
? String(mi.planned_duration_min)
: ''
hydrated.notes = mi.notes ?? ''
appendRows.push(hydrated)
}
const mergedItems = [...prev.slice(0, beforeIx), ...appendRows, ...prev.slice(beforeIx)]
return sections.map((sec, idx) => (idx === secIx ? { ...sec, items: mergedItems } : sec))
}
export function sectionPlannedMinutes(sec) {
return (sec.items || []).reduce((sum, it) => {
if (it.item_type !== 'exercise') return sum