All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 59s
- Integrated PsycopgJson for improved handling of planning method profiles in the backend. - Updated CombinationPlanBracket to display primary load labels for better clarity in the UI. - Enhanced TrainingUnitSectionsEditor and utility functions to ensure proper serialization of planning profiles, preventing potential errors during API interactions. - Improved CSS for combo plan brackets to enhance visual alignment and presentation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
121 lines
4.1 KiB
JavaScript
121 lines
4.1 KiB
JavaScript
/**
|
|
* Hilfen für Trainingsplan-Ansicht, Coach-Modus und API-Payload für PUT /training-units/:id.
|
|
*/
|
|
|
|
import { cloneJsonSerializablePlanningProfile } from './trainingUnitSectionsForm'
|
|
export function sortedSections(unit) {
|
|
const raw = unit?.sections
|
|
if (!Array.isArray(raw)) return []
|
|
return [...raw].sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
|
}
|
|
|
|
export function sortedItems(sec) {
|
|
const raw = sec?.items
|
|
if (!Array.isArray(raw)) return []
|
|
return [...raw].sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
|
}
|
|
|
|
export function itemStableKey(it, secOrder, ix) {
|
|
if (it && it.id != null) return String(it.id)
|
|
return `${secOrder}-${it?.item_type || 'row'}-${ix}`
|
|
}
|
|
|
|
/** Flache Reihenfolge wie auf der Matte: alle Notizen und Übungen nacheinander. */
|
|
export function flattenPlanTimeline(unit) {
|
|
const list = []
|
|
sortedSections(unit).forEach((sec, si) => {
|
|
const secOrder = sec.order_index ?? si
|
|
sortedItems(sec).forEach((item, ii) => {
|
|
list.push({
|
|
si,
|
|
ii,
|
|
secOrder,
|
|
flatIndex: list.length,
|
|
sec,
|
|
item,
|
|
})
|
|
})
|
|
})
|
|
return list
|
|
}
|
|
|
|
export function summarizeTimelineEntry({ item }) {
|
|
if (!item) return ''
|
|
if (item.item_type === 'note') {
|
|
const t = String(item.note_body || '').trim()
|
|
return t.length > 72 ? `${t.slice(0, 70)}…` : t || 'Notiz'
|
|
}
|
|
const title = item.exercise_title || (item.exercise_id ? `Übung #${item.exercise_id}` : 'Übung')
|
|
const vn = item.exercise_variant_name ? ` · ${item.exercise_variant_name}` : ''
|
|
return `${title}${vn}`
|
|
}
|
|
|
|
/** Payload für PUT (schließt bestehendes Unit mit optionalen Overrides pro Abschnitt-Item-ID ab). */
|
|
export function sectionsToPutPayload(unit, durationOverridesByItemId = {}) {
|
|
return sortedSections(unit).map((sec, si) => ({
|
|
order_index: sec.order_index ?? si,
|
|
title: ((sec.title || '').trim() || 'Abschnitt'),
|
|
guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null,
|
|
...(sec.source_template_section_id != null
|
|
? { source_template_section_id: sec.source_template_section_id }
|
|
: {}),
|
|
items: sortedItems(sec)
|
|
.map((it, ii) => {
|
|
if (it.item_type === 'note') {
|
|
return {
|
|
item_type: 'note',
|
|
order_index: it.order_index ?? ii,
|
|
note_body: it.note_body ?? '',
|
|
}
|
|
}
|
|
const eid = it.exercise_id
|
|
if (eid === '' || eid == null || Number.isNaN(Number(eid))) {
|
|
return null
|
|
}
|
|
const isCombo =
|
|
String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
|
|
const vid = isCombo ? null : it.exercise_variant_id
|
|
let actual =
|
|
durationOverridesByItemId[String(it.id)]?.actual_duration_min ??
|
|
it.actual_duration_min
|
|
if (actual === '' || actual === undefined) actual = null
|
|
else actual = typeof actual === 'number' ? actual : parseInt(String(actual), 10)
|
|
if (actual !== null && !Number.isFinite(actual)) actual = null
|
|
|
|
const row = {
|
|
item_type: 'exercise',
|
|
order_index: it.order_index ?? ii,
|
|
exercise_id: parseInt(String(eid), 10),
|
|
exercise_variant_id:
|
|
vid !== '' && vid != null && !Number.isNaN(Number(vid)) ? parseInt(String(vid), 10) : null,
|
|
planned_duration_min: coalescePositiveInt(it.planned_duration_min),
|
|
actual_duration_min: actual,
|
|
notes: trimOrNull(it.notes),
|
|
modifications: trimOrNull(it.modifications),
|
|
}
|
|
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) {
|
|
row.planning_method_profile = cleaned
|
|
}
|
|
}
|
|
}
|
|
return row
|
|
})
|
|
.filter(Boolean),
|
|
}))
|
|
}
|
|
|
|
function coalescePositiveInt(v) {
|
|
if (v === '' || v === null || v === undefined) return null
|
|
const n = parseInt(String(v), 10)
|
|
return Number.isFinite(n) ? n : null
|
|
}
|
|
|
|
function trimOrNull(v) {
|
|
const s = v != null ? String(v).trim() : ''
|
|
return s ? s : null
|
|
}
|