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>
343 lines
12 KiB
JavaScript
343 lines
12 KiB
JavaScript
import api from './api'
|
|
|
|
export function defaultSection(title = 'Hauptteil') {
|
|
return { title, guidance_notes: '', items: [] }
|
|
}
|
|
|
|
export function exerciseRow() {
|
|
return {
|
|
item_type: 'exercise',
|
|
exercise_id: '',
|
|
exercise_variant_id: '',
|
|
exercise_title: '',
|
|
variants: [],
|
|
planned_duration_min: '',
|
|
actual_duration_min: '',
|
|
notes: '',
|
|
modifications: '',
|
|
source_training_module_id: '',
|
|
source_module_title: '',
|
|
}
|
|
}
|
|
|
|
export async function hydrateExercisePlanningRow(exercise) {
|
|
let variants = Array.isArray(exercise?.variants) ? exercise.variants : []
|
|
let title = exercise?.title || ''
|
|
const id = exercise?.id
|
|
if (!id) return null
|
|
let meta = {}
|
|
if (!variants.length) {
|
|
try {
|
|
const full = await api.getExercise(id)
|
|
variants = Array.isArray(full?.variants) ? full.variants : []
|
|
title = full?.title || title
|
|
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',
|
|
}
|
|
} catch {
|
|
variants = []
|
|
}
|
|
} 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) {
|
|
try {
|
|
const full = await api.getExercise(id)
|
|
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'
|
|
} catch {
|
|
/* keep partial meta */
|
|
}
|
|
}
|
|
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.variants = variants
|
|
Object.assign(row, meta)
|
|
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)
|
|
return {
|
|
item_type: 'exercise',
|
|
exercise_id: it.exercise_id,
|
|
exercise_variant_id: 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 ?? '',
|
|
...(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) => ({
|
|
item_type: 'exercise',
|
|
exercise_id: ex.exercise_id,
|
|
exercise_variant_id: 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 ?? '',
|
|
})),
|
|
},
|
|
]
|
|
}
|
|
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)
|
|
cache.set(id, {
|
|
title: ex.title || '',
|
|
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',
|
|
})
|
|
} catch {
|
|
cache.set(id, {
|
|
title: '',
|
|
variants: [],
|
|
visibility: 'private',
|
|
club_id: null,
|
|
created_by: null,
|
|
status: 'draft',
|
|
})
|
|
}
|
|
})
|
|
)
|
|
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
|
|
return {
|
|
...it,
|
|
exercise_title: it.exercise_title || c.title,
|
|
variants:
|
|
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,
|
|
}
|
|
}),
|
|
}))
|
|
}
|
|
|
|
export function parseMin(v) {
|
|
if (v === '' || v === null || v === undefined) return null
|
|
const n = parseInt(String(v), 10)
|
|
return Number.isFinite(n) ? n : null
|
|
}
|
|
|
|
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 vid = 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 (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
|
|
const m = parseMin(it.planned_duration_min)
|
|
return sum + (m || 0)
|
|
}, 0)
|
|
}
|