All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
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 1m9s
- Updated the backend to improve the fetching and insertion of training unit sections, including a new function for handling section items. - Added documentation notes regarding the unique constraint on `training_unit_sections` and the implications for parallel training streams. - Updated frontend components and utility functions to reflect changes in the training planning API and to prepare for future enhancements related to parallel streams.
531 lines
19 KiB
JavaScript
531 lines
19 KiB
JavaScript
import api from './api'
|
||
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
|
||
|
||
export function defaultSection(title = 'Hauptteil') {
|
||
return { title, guidance_notes: '', items: [] }
|
||
}
|
||
|
||
function normalizeCatalogMethodProfile(cp) {
|
||
if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp }
|
||
return {}
|
||
}
|
||
|
||
/** NULL = Planung folgt Katalogprofil der Übung (Reihenfolge beibehalten: zuerst String-JSON auflösen). */
|
||
function normalizePlanningMethodProfile(pm) {
|
||
if (pm == null) return null
|
||
if (typeof pm === 'string') {
|
||
const t = pm.trim()
|
||
if (!t || t === 'null') return null
|
||
try {
|
||
const p = JSON.parse(t)
|
||
if (p && typeof p === 'object' && !Array.isArray(p)) return { ...p }
|
||
return null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
if (typeof pm === 'object' && !Array.isArray(pm)) return { ...pm }
|
||
return null
|
||
}
|
||
|
||
/** Reines JSON für PUT /training-units (vermeidet nicht serialisierbare Werte → 500). */
|
||
export function cloneJsonSerializablePlanningProfile(obj) {
|
||
if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) return null
|
||
try {
|
||
return JSON.parse(JSON.stringify(obj))
|
||
} catch {
|
||
return {}
|
||
}
|
||
}
|
||
|
||
export function exerciseRow() {
|
||
return {
|
||
item_type: 'exercise',
|
||
exercise_id: '',
|
||
exercise_variant_id: '',
|
||
exercise_kind: 'simple',
|
||
exercise_title: '',
|
||
variants: [],
|
||
planned_duration_min: '',
|
||
actual_duration_min: '',
|
||
notes: '',
|
||
modifications: '',
|
||
source_training_module_id: '',
|
||
source_module_title: '',
|
||
catalog_method_archetype: '',
|
||
catalog_method_profile: {},
|
||
planning_method_profile: null,
|
||
}
|
||
}
|
||
|
||
export async function hydrateExercisePlanningRow(exercise) {
|
||
let variants = Array.isArray(exercise?.variants) ? exercise.variants : []
|
||
let title = exercise?.title || ''
|
||
let exerciseKind = exercise?.exercise_kind
|
||
const id = exercise?.id
|
||
if (!id) return null
|
||
let meta = {}
|
||
|
||
let full
|
||
|
||
async function ensureFull() {
|
||
if (full !== undefined) return full
|
||
try {
|
||
full = await api.getExercise(id)
|
||
} catch {
|
||
full = null
|
||
}
|
||
return full
|
||
}
|
||
|
||
if (!variants.length) {
|
||
await ensureFull()
|
||
if (full) {
|
||
variants = Array.isArray(full.variants) ? full.variants : []
|
||
title = full.title || title
|
||
if (exerciseKind == null) exerciseKind = full.exercise_kind
|
||
meta = {
|
||
exercise_visibility: full.visibility || 'private',
|
||
exercise_club_id: full.club_id ?? null,
|
||
exercise_created_by: full.created_by ?? null,
|
||
exercise_status: full.status || 'draft',
|
||
catalog_method_archetype: typeof full.method_archetype === 'string' ? full.method_archetype.trim() : '',
|
||
catalog_method_profile: normalizeCatalogMethodProfile(full.method_profile),
|
||
}
|
||
}
|
||
} else {
|
||
meta = {
|
||
exercise_visibility: exercise?.visibility ?? null,
|
||
exercise_club_id: exercise?.club_id ?? null,
|
||
exercise_created_by: exercise?.created_by ?? null,
|
||
exercise_status: exercise?.status ?? null,
|
||
}
|
||
if (
|
||
meta.exercise_visibility == null ||
|
||
meta.exercise_created_by == null ||
|
||
exerciseKind == null
|
||
) {
|
||
await ensureFull()
|
||
if (full) {
|
||
if (meta.exercise_visibility == null) meta.exercise_visibility = full.visibility || 'private'
|
||
if (meta.exercise_club_id == null) meta.exercise_club_id = full.club_id ?? null
|
||
if (meta.exercise_created_by == null) meta.exercise_created_by = full.created_by ?? null
|
||
if (meta.exercise_status == null) meta.exercise_status = full.status || 'draft'
|
||
if (exerciseKind == null) exerciseKind = full.exercise_kind
|
||
if (!variants.length) variants = Array.isArray(full.variants) ? full.variants : []
|
||
}
|
||
}
|
||
meta.exercise_visibility = meta.exercise_visibility || 'private'
|
||
meta.exercise_status = meta.exercise_status || 'draft'
|
||
}
|
||
|
||
const row = exerciseRow()
|
||
row.exercise_id = id
|
||
row.exercise_variant_id = ''
|
||
row.exercise_title = title
|
||
row.exercise_kind =
|
||
String(exerciseKind || 'simple').toLowerCase().trim() === 'combination' ? 'combination' : 'simple'
|
||
if (row.exercise_kind === 'combination') {
|
||
row.variants = []
|
||
row.exercise_variant_id = ''
|
||
} else {
|
||
row.variants = variants
|
||
}
|
||
Object.assign(row, meta)
|
||
if (row.exercise_kind === 'combination') {
|
||
if (full === undefined) await ensureFull()
|
||
if (full) {
|
||
row.catalog_method_archetype =
|
||
typeof full.method_archetype === 'string' ? full.method_archetype.trim() : ''
|
||
row.catalog_method_profile = normalizeCatalogMethodProfile(full.method_profile)
|
||
row.combination_slots = Array.isArray(full.combination_slots) ? full.combination_slots : []
|
||
}
|
||
}
|
||
row.planning_method_profile = null
|
||
return row
|
||
}
|
||
|
||
export function noteRow() {
|
||
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) {
|
||
if (fullUnit.sections && fullUnit.sections.length) {
|
||
return fullUnit.sections.map((sec) => ({
|
||
title: sec.title,
|
||
guidance_notes: sec.guidance_notes || '',
|
||
items: (sec.items || []).map((it) => {
|
||
if (it.item_type === 'note') {
|
||
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)
|
||
const ek = String(it.exercise_kind || 'simple').toLowerCase().trim()
|
||
const isCombo = ek === 'combination'
|
||
return {
|
||
item_type: 'exercise',
|
||
exercise_id: it.exercise_id,
|
||
exercise_kind: isCombo ? 'combination' : 'simple',
|
||
exercise_variant_id: isCombo ? '' : it.exercise_variant_id ?? '',
|
||
exercise_title: it.exercise_title || '',
|
||
variants: [],
|
||
planned_duration_min:
|
||
it.planned_duration_min !== null && it.planned_duration_min !== undefined
|
||
? String(it.planned_duration_min)
|
||
: '',
|
||
actual_duration_min:
|
||
it.actual_duration_min !== null && it.actual_duration_min !== undefined
|
||
? String(it.actual_duration_min)
|
||
: '',
|
||
notes: it.notes ?? '',
|
||
modifications: it.modifications ?? '',
|
||
catalog_method_archetype: String(it.catalog_method_archetype ?? '').trim(),
|
||
catalog_method_profile: normalizeCatalogMethodProfile(it.catalog_method_profile),
|
||
planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile),
|
||
...(smEx != null
|
||
? {
|
||
source_training_module_id: smEx,
|
||
source_module_title: (
|
||
it.source_module_title ||
|
||
it.source_training_module_title ||
|
||
''
|
||
).trim(),
|
||
}
|
||
: {}),
|
||
}
|
||
}),
|
||
}))
|
||
}
|
||
if (fullUnit.exercises && fullUnit.exercises.length) {
|
||
return [
|
||
{
|
||
title: 'Übungen',
|
||
guidance_notes: '',
|
||
items: fullUnit.exercises.map((ex) => {
|
||
const ek = String(ex.exercise_kind || 'simple').toLowerCase().trim()
|
||
const isCombo = ek === 'combination'
|
||
return {
|
||
item_type: 'exercise',
|
||
exercise_kind: ek,
|
||
exercise_id: ex.exercise_id,
|
||
exercise_variant_id: isCombo ? '' : (ex.exercise_variant_id ?? ''),
|
||
exercise_title: ex.exercise_title || '',
|
||
variants: [],
|
||
planned_duration_min:
|
||
ex.planned_duration_min !== null && ex.planned_duration_min !== undefined
|
||
? String(ex.planned_duration_min)
|
||
: '',
|
||
actual_duration_min:
|
||
ex.actual_duration_min !== null && ex.actual_duration_min !== undefined
|
||
? String(ex.actual_duration_min)
|
||
: '',
|
||
notes: ex.notes ?? '',
|
||
modifications: ex.modifications ?? '',
|
||
catalog_method_archetype: String(ex.catalog_method_archetype ?? '').trim(),
|
||
catalog_method_profile: normalizeCatalogMethodProfile(ex.catalog_method_profile),
|
||
planning_method_profile: normalizePlanningMethodProfile(ex.planning_method_profile),
|
||
}
|
||
}),
|
||
},
|
||
]
|
||
}
|
||
return [defaultSection()]
|
||
}
|
||
|
||
export async function enrichSectionsWithVariants(sections) {
|
||
if (!sections?.length) return sections
|
||
const ids = []
|
||
for (const sec of sections) {
|
||
for (const it of sec.items || []) {
|
||
if (it.item_type === 'note') continue
|
||
if (it.exercise_id) ids.push(it.exercise_id)
|
||
}
|
||
}
|
||
const unique = [...new Set(ids)]
|
||
const cache = new Map()
|
||
await Promise.all(
|
||
unique.map(async (id) => {
|
||
try {
|
||
const ex = await api.getExercise(id)
|
||
const ek = String(ex.exercise_kind || 'simple').toLowerCase().trim()
|
||
cache.set(id, {
|
||
title: ex.title || '',
|
||
exercise_kind: ek,
|
||
variants: Array.isArray(ex.variants) ? ex.variants : [],
|
||
visibility: ex.visibility || 'private',
|
||
club_id: ex.club_id ?? null,
|
||
created_by: ex.created_by ?? null,
|
||
status: ex.status || 'draft',
|
||
method_archetype: typeof ex.method_archetype === 'string' ? ex.method_archetype.trim() : '',
|
||
method_profile: normalizeCatalogMethodProfile(ex.method_profile),
|
||
combination_slots: ek === 'combination' && Array.isArray(ex.combination_slots) ? ex.combination_slots : [],
|
||
})
|
||
} catch {
|
||
cache.set(id, {
|
||
title: '',
|
||
exercise_kind: 'simple',
|
||
variants: [],
|
||
visibility: 'private',
|
||
club_id: null,
|
||
created_by: null,
|
||
status: 'draft',
|
||
method_archetype: '',
|
||
method_profile: {},
|
||
combination_slots: [],
|
||
})
|
||
}
|
||
})
|
||
)
|
||
|
||
const titleById = new Map()
|
||
for (const id of unique) {
|
||
const row = cache.get(id)
|
||
const t = (row?.title || '').trim()
|
||
if (t) titleById.set(Number(id), t)
|
||
}
|
||
const comboCandidateExtra = new Set()
|
||
for (const id of unique) {
|
||
const row = cache.get(id)
|
||
if (String(row?.exercise_kind || '').toLowerCase().trim() !== 'combination') continue
|
||
for (const slot of row.combination_slots || []) {
|
||
for (const raw of slot.candidate_exercise_ids || []) {
|
||
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
|
||
if (Number.isFinite(n) && !titleById.has(n)) comboCandidateExtra.add(n)
|
||
}
|
||
}
|
||
}
|
||
await Promise.all(
|
||
[...comboCandidateExtra].map(async (cid) => {
|
||
try {
|
||
const ex = await api.getExercise(cid)
|
||
titleById.set(cid, ((ex.title || '').trim() || `Übung #${cid}`))
|
||
} catch {
|
||
titleById.set(cid, `Übung #${cid}`)
|
||
}
|
||
}),
|
||
)
|
||
|
||
function comboMemberTitleByIdForSlots(slots) {
|
||
const o = {}
|
||
for (const slot of slots || []) {
|
||
for (const raw of slot.candidate_exercise_ids || []) {
|
||
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
|
||
if (!Number.isFinite(n)) continue
|
||
const key = String(n)
|
||
if (!o[key]) o[key] = titleById.get(n) || `Übung #${n}`
|
||
}
|
||
}
|
||
return o
|
||
}
|
||
|
||
return sections.map((sec) => ({
|
||
...sec,
|
||
items: (sec.items || []).map((it) => {
|
||
if (it.item_type === 'note') return it
|
||
if (!it.exercise_id) return it
|
||
const c = cache.get(it.exercise_id)
|
||
if (!c) return it
|
||
const ek = String(c.exercise_kind || 'simple').toLowerCase().trim()
|
||
const isCombo = ek === 'combination'
|
||
const itemCatalog = normalizeCatalogMethodProfile(it.catalog_method_profile)
|
||
const catalog_method_profile =
|
||
Object.keys(itemCatalog).length > 0
|
||
? itemCatalog
|
||
: normalizeCatalogMethodProfile(c.method_profile)
|
||
const rowArche = String(it.catalog_method_archetype ?? '').trim()
|
||
const catalog_method_archetype = rowArche || String(c.method_archetype ?? '').trim()
|
||
return {
|
||
...it,
|
||
catalog_method_archetype,
|
||
catalog_method_profile,
|
||
planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile),
|
||
exercise_kind: isCombo ? 'combination' : 'simple',
|
||
exercise_title: it.exercise_title || c.title,
|
||
exercise_variant_id: isCombo ? '' : it.exercise_variant_id,
|
||
variants:
|
||
isCombo ? [] : Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants,
|
||
exercise_visibility: c.visibility,
|
||
exercise_club_id: c.club_id,
|
||
exercise_created_by: c.created_by,
|
||
exercise_status: c.status,
|
||
...(isCombo ? { combination_slots: c.combination_slots || [], combo_member_title_by_id: comboMemberTitleByIdForSlots(c.combination_slots || []) } : {}),
|
||
}
|
||
}),
|
||
}))
|
||
}
|
||
|
||
/**
|
||
* Outline für CombinationMethodProfileEditor: pro‑Slot‑Zeiten nur sichtbar, wenn Stationen übergeben werden.
|
||
*/
|
||
export function comboSlotsOutlineForProfileEditor(combinationSlots) {
|
||
if (!Array.isArray(combinationSlots) || combinationSlots.length === 0) return null
|
||
const sorted = sortCombinationSlotsForDisplay(combinationSlots)
|
||
return sorted.map((s, i) => {
|
||
const rawIx = s.slot_index
|
||
const si =
|
||
rawIx === '' || rawIx == null ? null : typeof rawIx === 'number' ? rawIx : parseInt(String(rawIx), 10)
|
||
return {
|
||
slot_index: Number.isFinite(si) ? si : i,
|
||
title: (s.title != null ? String(s.title) : '').trim(),
|
||
}
|
||
})
|
||
}
|
||
|
||
export function parseMin(v) {
|
||
if (v === '' || v === null || v === undefined) return null
|
||
const n = parseInt(String(v), 10)
|
||
return Number.isFinite(n) ? n : null
|
||
}
|
||
|
||
/** PUT /api/training-units/:id `sections` — flache order_index pro Einheit; Parallelität bricht am DB-UNIQUE (training_unit_id, order_index) bis Migration. */
|
||
export function buildSectionsPayload(sections) {
|
||
return sections.map((sec, si) => ({
|
||
order_index: si,
|
||
title: (sec.title || '').trim() || 'Abschnitt',
|
||
guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null,
|
||
items: (sec.items || [])
|
||
.map((it, ii) => {
|
||
if (it.item_type === 'note') {
|
||
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 isCombo = String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
|
||
const vid = isCombo ? null : it.exercise_variant_id
|
||
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
||
const rowEx = {
|
||
item_type: 'exercise',
|
||
order_index: ii,
|
||
exercise_id: parseInt(it.exercise_id, 10),
|
||
exercise_variant_id:
|
||
vid !== '' && vid != null && !Number.isNaN(Number(vid)) ? parseInt(vid, 10) : null,
|
||
planned_duration_min: parseMin(it.planned_duration_min),
|
||
actual_duration_min: parseMin(it.actual_duration_min),
|
||
notes: it.notes?.trim() ? it.notes.trim() : null,
|
||
modifications: it.modifications?.trim() ? it.modifications.trim() : null,
|
||
}
|
||
if (isCombo) {
|
||
const pmp = it.planning_method_profile
|
||
if (pmp != null && typeof pmp === 'object' && !Array.isArray(pmp)) {
|
||
const cleaned = cloneJsonSerializablePlanningProfile(pmp)
|
||
if (cleaned && Object.keys(cleaned).length > 0) {
|
||
rowEx.planning_method_profile = cleaned
|
||
}
|
||
}
|
||
}
|
||
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 (
|
||
hydrated.exercise_kind !== 'combination' &&
|
||
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
|
||
const m = parseMin(it.planned_duration_min)
|
||
return sum + (m || 0)
|
||
}, 0)
|
||
}
|