diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py
index d621e88..4294d5d 100644
--- a/backend/routers/training_planning.py
+++ b/backend/routers/training_planning.py
@@ -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,28 +468,32 @@ 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(
- {
- "item_type": "note",
- "order_index": oix,
- "note_body": it.get("note_body") or "",
- }
- )
+ 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(
- {
- "item_type": "exercise",
- "order_index": oix,
- "exercise_id": it["exercise_id"],
- "exercise_variant_id": it.get("exercise_variant_id"),
- "planned_duration_min": it.get("planned_duration_min"),
- "actual_duration_min": it.get("actual_duration_min"),
- "notes": it.get("notes"),
- "modifications": it.get("modifications"),
- }
- )
+ ex_item = {
+ "item_type": "exercise",
+ "order_index": oix,
+ "exercise_id": it["exercise_id"],
+ "exercise_variant_id": it.get("exercise_variant_id"),
+ "planned_duration_min": it.get("planned_duration_min"),
+ "actual_duration_min": it.get("actual_duration_min"),
+ "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,
),
)
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 9379619..44d1318 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -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;
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index 31daf70..27f790d 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -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 (
-
+
+ {showModuleBand ? (
+
+ Baustein: {modBandTitle}
+
+ ) : null}
+
{enableItemDragReorder ? (
+
)
}
@@ -611,11 +632,17 @@ export default function TrainingUnitSectionsEditor({
: Number(it.exercise_variant_id)
return (
-
+
+ {showModuleBand ? (
+
+ Baustein: {modBandTitle}
+
+ ) : null}
+
{enableItemDragReorder ? (
) : null}
+
)
})}
diff --git a/frontend/src/pages/TrainingModuleEditPage.jsx b/frontend/src/pages/TrainingModuleEditPage.jsx
index 08e88db..4700e12 100644
--- a/frontend/src/pages/TrainingModuleEditPage.jsx
+++ b/frontend/src/pages/TrainingModuleEditPage.jsx
@@ -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() {
Sichtbarkeit
- setVisibility(e.target.value)}>
+ {
+ 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) : '')
+ }
+ }}
+ >
Privat
Vereinsintern
Offiziell
-
Vereins‑ID (optional, bei Vereins‑Sichtbarkeit)
-
setClubIdField(e.target.value)}
- placeholder="Leer = aktiver Verein (Server)"
- />
+
Verein (bei „Vereinsintern“)
+ {visibility !== 'club' ? (
+
+ Bei privaten oder offiziellen Modulen ist keine Vereinszuordnung nötig (Server legt keine
+ Vereinsbindung fest).
+
+ ) : clubChoices.length === 0 ? (
+
+ Kein aktiver Verein im Profil — bitte zuerst einem Verein beitreten.
+
+ ) : clubChoices.length === 1 ? (
+ <>
+
+
+ Fixiert durch deine Mitgliedschaft. Verein-ID {clubChoices[0].id} wird beim Speichern verwendet.
+
+ >
+ ) : (
+ <>
+
setClubIdField(e.target.value)}
+ >
+ Automatisch (aktueller Verein im Profil)
+ {clubChoices.map((c) => {
+ const ln = `${((c.short_name || c.name || '').trim() || '') || `Verein #${c.id}`}`
+ return (
+
+ {ln}
+
+ )
+ })}
+
+
+ Bei „Automatisch“ entscheidet der aktiv gewählte Verein beim Speichern (wie bei anderen
+ Bibliotheksinhalten).
+
+ >
+ )}
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index 0a04cbc..a4e97a5 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -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"
>
- Trainingsmodul übernehmen
+ Modul einfügen
- Der Inhalt wird kopiert 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 kopiert 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.
{moduleApplyErr ? (
@@ -1930,7 +1954,10 @@ function TrainingPlanningPage() {
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() {
+
+ Position in diesem Abschnitt
+ setModuleApplyInsertSlot(e.target.value)}
+ disabled={moduleApplyBusy || !(formData.sections?.length > 0)}
+ >
+ Ans Ende einfügen (nach allen Einträgen)
+ An den Anfang (vor dem ersten Eintrag)
+ {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 (
+
+ Vor Eintrag {xi + 1}: {clipped}
+
+ )
+ })}
+
+
+
- {moduleApplyBusy ? 'Übernehmen …' : 'Übernehmen'}
+ {moduleApplyBusy ? 'Einfügen …' : 'Einfügen'}
@@ -2473,16 +2526,14 @@ function TrainingPlanningPage() {
Vorlage aus Aufbau speichern
- {editingUnit?.id ? (
-
- Aus Modul übernehmen…
-
- ) : null}
+
+ Modul einfügen…
+
>
}
sections={formData.sections}
diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js
index de9670e..f99851e 100644
--- a/frontend/src/utils/trainingUnitSectionsForm.js
+++ b/frontend/src/utils/trainingUnitSectionsForm.js
@@ -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