shinkan-jinkendo/frontend/src/utils/trainingUnitSectionsForm.js
Lars 220a16429c
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
Enhance training unit sections handling and documentation for parallel training streams
- 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.
2026-05-14 22:24:55 +02:00

531 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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