shinkan-jinkendo/frontend/src/utils/trainingUnitSectionsForm.js
Lars e41908af73
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
feat(training-planning): enhance training module integration and UI
- 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>
2026-05-12 21:53:41 +02:00

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)
}