feat(training-unit-editor): integrate new summary function and enhance combination exercise display
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 56s
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 56s
- Introduced `summarizeSlotProfileBrief` utility for concise slot profile summaries, improving the display of combination exercises. - Updated `CombinationCoachSlots` and `ExercisePeekModal` components to utilize the new summary function for better user experience. - Enhanced `TrainingUnitSectionsEditor` to manage combination slots more effectively, including improved title handling and display options. - Adjusted `TrainingPlanningPage` to support additional peek context for combination exercises, streamlining the planning process. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
805ad3c5a5
commit
5dc93d9a8c
|
|
@ -10,47 +10,7 @@ import {
|
|||
combinationArchetypeLabel,
|
||||
sortCombinationSlotsForDisplay,
|
||||
} from '../constants/combinationArchetypes'
|
||||
import { readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
|
||||
|
||||
function summarizeSlotProfilesRow(r) {
|
||||
if (!r) return null
|
||||
const adv = r.advance_mode || 'timed'
|
||||
const bits = []
|
||||
if (adv === 'timed') {
|
||||
bits.push('Zeit')
|
||||
if (r.load_sec != null) bits.push(`${r.load_sec}s Arbeit`)
|
||||
} else if (adv === 'rep') {
|
||||
bits.push('Ziel‑Wdh.')
|
||||
const nSer = r.rep_series_count != null && r.rep_series_count >= 1 ? r.rep_series_count : 1
|
||||
if (r.consecutive_reps != null) {
|
||||
if (nSer >= 2) bits.push(`${nSer} Serien à ${r.consecutive_reps}×`)
|
||||
else bits.push(`${r.consecutive_reps}×`)
|
||||
}
|
||||
} else {
|
||||
bits.push('Coach')
|
||||
const nSerMan = r.rep_series_count != null && r.rep_series_count >= 2 ? r.rep_series_count : 1
|
||||
if (r.consecutive_reps != null) {
|
||||
if (nSerMan >= 2) bits.push(`${nSerMan} Serien à Richtwert ${r.consecutive_reps}×`)
|
||||
else bits.push(`Richtwert ${r.consecutive_reps}×`)
|
||||
} else if (r.rep_series_count != null && r.rep_series_count >= 2) {
|
||||
bits.push(`${r.rep_series_count} Serien`)
|
||||
}
|
||||
}
|
||||
if (r.intra_rep_rest_sec != null) {
|
||||
if (adv === 'timed') bits.push(`Pause ${r.intra_rep_rest_sec}s`)
|
||||
else if (adv === 'rep' && r.rep_series_count != null && r.rep_series_count >= 2)
|
||||
bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
|
||||
else if (
|
||||
adv === 'manual' &&
|
||||
r.rep_series_count != null &&
|
||||
r.rep_series_count >= 2
|
||||
) {
|
||||
bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
|
||||
}
|
||||
}
|
||||
if (r.transition_after_sec != null) bits.push(`Wechsel ${r.transition_after_sec}s`)
|
||||
return bits.join(' · ')
|
||||
}
|
||||
import { readSlotProfilesV1, summarizeSlotProfileBrief } from '../utils/combinationMethodProfileUi'
|
||||
|
||||
export default function CombinationCoachSlots({
|
||||
combinationSlots,
|
||||
|
|
@ -233,7 +193,7 @@ export default function CombinationCoachSlots({
|
|||
`Station ${slot.slot_index != null ? Number(slot.slot_index) + 1 : si + 1}`
|
||||
|
||||
const ix = slot.slot_index != null ? Number(slot.slot_index) : si
|
||||
const timingSummary = summarizeSlotProfilesRow(slotTimingByIx.get(ix))
|
||||
const timingSummary = summarizeSlotProfileBrief(slotTimingByIx.get(ix))
|
||||
|
||||
return (
|
||||
<li key={`${slot.slot_index ?? si}-${slotTitle}`} style={{ lineHeight: 1.45 }}>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
/**
|
||||
* Schnellansicht einer Übung aus dem Katalog (ohne die Planungsseite zu verlassen).
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
|
||||
import CombinationCoachSlots from './CombinationCoachSlots'
|
||||
import { combinationArchetypeLabel } from '../constants/combinationArchetypes'
|
||||
import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
|
||||
|
||||
function TagMini({ exercise }) {
|
||||
const parts = []
|
||||
|
|
@ -29,6 +32,8 @@ export default function ExercisePeekModal({
|
|||
variantId,
|
||||
onClose,
|
||||
titleFallback,
|
||||
/** Nur Planung: effektives method_profile aus Zeilen-Katalog + Planungs-Override */
|
||||
peekExtras,
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [err, setErr] = useState(null)
|
||||
|
|
@ -39,6 +44,22 @@ export default function ExercisePeekModal({
|
|||
? exercise.variants.find((v) => String(v.id) === String(variantId)) || null
|
||||
: null
|
||||
|
||||
const isCombination =
|
||||
exercise &&
|
||||
String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
|
||||
|
||||
const comboMethodProfileEffective = useMemo(() => {
|
||||
if (!exercise || !isCombination) return {}
|
||||
const fromPeek =
|
||||
peekExtras?.catalog_method_profile &&
|
||||
typeof peekExtras.catalog_method_profile === 'object' &&
|
||||
!Array.isArray(peekExtras.catalog_method_profile) &&
|
||||
Object.keys(peekExtras.catalog_method_profile).length > 0
|
||||
? peekExtras.catalog_method_profile
|
||||
: exercise.method_profile || {}
|
||||
return effectiveComboMethodProfile(fromPeek, peekExtras?.planning_method_profile ?? null)
|
||||
}, [exercise, isCombination, peekExtras])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setExercise(null)
|
||||
|
|
@ -69,6 +90,8 @@ export default function ExercisePeekModal({
|
|||
|
||||
if (!open) return null
|
||||
|
||||
const sheetWide = Boolean(isCombination && exercise && !loading)
|
||||
|
||||
return (
|
||||
<div className="admin-modal-backdrop" role="presentation" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div
|
||||
|
|
@ -77,7 +100,7 @@ export default function ExercisePeekModal({
|
|||
aria-modal="true"
|
||||
aria-labelledby="exercise-peek-title"
|
||||
style={{
|
||||
maxWidth: '620px',
|
||||
maxWidth: sheetWide ? 'min(760px, 96vw)' : '620px',
|
||||
width: '100%',
|
||||
maxHeight: '88vh',
|
||||
display: 'flex',
|
||||
|
|
@ -103,6 +126,49 @@ export default function ExercisePeekModal({
|
|||
{!loading && err && <p style={{ color: 'var(--danger)' }}>{err}</p>}
|
||||
{!loading && exercise && (
|
||||
<>
|
||||
{isCombination ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '12px',
|
||||
padding: '8px 10px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--surface2)',
|
||||
border: '1px solid var(--border)',
|
||||
fontSize: '0.82rem',
|
||||
color: 'var(--text2)',
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: 'var(--text1)' }}>Kombination</strong>
|
||||
<span style={{ marginLeft: 8 }}>
|
||||
{(() => {
|
||||
const ak = String(exercise.method_archetype || '').trim()
|
||||
const lbl = ak ? combinationArchetypeLabel(ak) : null
|
||||
return lbl || ak || 'Archetyp nicht gesetzt'
|
||||
})()}
|
||||
</span>
|
||||
{peekExtras?.planning_method_profile != null &&
|
||||
typeof peekExtras.planning_method_profile === 'object' &&
|
||||
!Array.isArray(peekExtras.planning_method_profile) ? (
|
||||
<span style={{ marginLeft: 8, color: 'var(--accent-dark)', fontWeight: 600 }}>
|
||||
· Planung angepasst
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<CombinationCoachSlots
|
||||
combinationSlots={
|
||||
Array.isArray(exercise.combination_slots) ? exercise.combination_slots : []
|
||||
}
|
||||
methodArchetype={exercise.method_archetype || ''}
|
||||
methodProfile={comboMethodProfileEffective}
|
||||
compactPlanningView
|
||||
omitGlobalKeyValueBlock={false}
|
||||
/>
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1rem 0' }} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{variant ? (
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { GripVertical, Pencil } from 'lucide-react'
|
|||
import CombinationMethodProfileEditor from './CombinationMethodProfileEditor'
|
||||
import CombinationCoachSlots from './CombinationCoachSlots'
|
||||
import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
|
||||
import { combinationArchetypeLabel } from '../constants/combinationArchetypes'
|
||||
import { combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
|
||||
import {
|
||||
comboSlotsOutlineForProfileEditor,
|
||||
defaultSection,
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
sectionPlannedMinutes,
|
||||
} from '../utils/trainingUnitSectionsForm'
|
||||
import api from '../utils/api'
|
||||
import { readSlotProfilesV1, summarizeSlotProfileBrief } from '../utils/combinationMethodProfileUi'
|
||||
import { isCompactTagLegendMode } from '../config/planningModuleUx'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
|
|
@ -77,6 +78,59 @@ function compactComboPlanningCaption(it) {
|
|||
return archLbl ? `${archLbl} · wie Katalog` : 'wie im Katalog'
|
||||
}
|
||||
|
||||
/** Globale Eckdaten aus effective profile (optional unter Stationenliste). */
|
||||
function comboRoughGlobalTimingHint(profileObj, archetypeKey) {
|
||||
if (!profileObj || typeof profileObj !== 'object' || Array.isArray(profileObj)) return null
|
||||
const bits = []
|
||||
const rounds = profileObj.rounds
|
||||
const ws = profileObj.work_seconds
|
||||
const rb = profileObj.rest_between_rounds_sec
|
||||
const hint = profileObj.hint_step_duration_sec
|
||||
const globRest = profileObj.rest_between_sets_sec
|
||||
if (rounds != null && rounds !== '') bits.push(`${rounds} Runden`)
|
||||
if (ws != null && ws !== '') bits.push(`${ws}s Arbeit`)
|
||||
if (rb != null && rb !== '') bits.push(`Pause ${rb}s`)
|
||||
if (globRest != null && globRest !== '') bits.push(`Sets-Pause ${globRest}s`)
|
||||
if (hint != null && hint !== '') bits.push(`Orientierung ~${hint}s`)
|
||||
const arch = (archetypeKey || '').trim()
|
||||
if (arch === 'time_domain_interval') {
|
||||
const iw = profileObj.interval_work_sec
|
||||
const ir = profileObj.interval_rest_sec
|
||||
const ig = profileObj.interval_groups
|
||||
if (iw != null && iw !== '') bits.push(`${iw}s Intervall`)
|
||||
if (ir != null && ir !== '') bits.push(`${ir}s Erholung`)
|
||||
if (ig != null && ig !== '') bits.push(`${ig} Gruppen`)
|
||||
}
|
||||
return bits.length ? bits.join(' · ') : null
|
||||
}
|
||||
|
||||
/** Pro Station eine kompakte Textzeile für die Planungsliste. */
|
||||
function comboPlanningStripBulletTexts(it) {
|
||||
const slots = sortCombinationSlotsForDisplay(it.combination_slots || [])
|
||||
if (!slots.length) return []
|
||||
const mp = effectiveComboMethodProfile(it.catalog_method_profile || {}, it.planning_method_profile)
|
||||
const byIx = new Map(readSlotProfilesV1(mp).map((r) => [Number(r.slot_index), r]))
|
||||
const titles = it.combo_member_title_by_id || {}
|
||||
return slots.map((slot, idx) => {
|
||||
const siRaw = slot.slot_index
|
||||
const siParsed =
|
||||
siRaw === '' || siRaw == null ? idx : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10)
|
||||
const ix = Number.isFinite(siParsed) ? siParsed : idx
|
||||
const stationLbl = ((slot.title || '').trim() || `Station ${ix}`)
|
||||
const candIds = (slot.candidate_exercise_ids || [])
|
||||
.map((raw) => (typeof raw === 'number' ? raw : parseInt(String(raw), 10)))
|
||||
.filter((n) => Number.isFinite(n))
|
||||
const namesJoined =
|
||||
candIds.length === 0
|
||||
? '(keine Übung)'
|
||||
: candIds.map((id) => titles[String(id)] || `Übung ${id}`).join(' ↔ ')
|
||||
const timing = summarizeSlotProfileBrief(byIx.get(ix))
|
||||
let line = `${stationLbl}: ${namesJoined}`
|
||||
if (timing) line += ` · ${timing}`
|
||||
return line
|
||||
})
|
||||
}
|
||||
|
||||
/** Stabile Farbzurodnung aus Modul-ID (nur Darstellung). */
|
||||
function planningModulePalette(moduleId) {
|
||||
const id = normalizedPlanningModuleChainId(moduleId)
|
||||
|
|
@ -964,6 +1018,24 @@ export default function TrainingUnitSectionsEditor({
|
|||
? undefined
|
||||
: Number(it.exercise_variant_id)
|
||||
|
||||
const stripArchRaw =
|
||||
isCombination && it.exercise_id ? String(it.catalog_method_archetype || '').trim() : ''
|
||||
const stripArchLbl =
|
||||
stripArchRaw && isCombination ? combinationArchetypeLabel(stripArchRaw) : null
|
||||
const stripBullets =
|
||||
isCombination && it.exercise_id ? comboPlanningStripBulletTexts(it) : []
|
||||
const stripMpEff =
|
||||
isCombination && it.exercise_id
|
||||
? effectiveComboMethodProfile(
|
||||
it.catalog_method_profile || {},
|
||||
it.planning_method_profile,
|
||||
)
|
||||
: null
|
||||
const stripGlobalRough =
|
||||
isCombination && it.exercise_id && stripMpEff
|
||||
? comboRoughGlobalTimingHint(stripMpEff, stripArchRaw)
|
||||
: null
|
||||
|
||||
return (
|
||||
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
|
||||
{!planningCompactLegend &&
|
||||
|
|
@ -1047,7 +1119,16 @@ export default function TrainingUnitSectionsEditor({
|
|||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
onClick={() =>
|
||||
onPeekExercise(Number(it.exercise_id), variantIdPeek)
|
||||
onPeekExercise(
|
||||
Number(it.exercise_id),
|
||||
variantIdPeek,
|
||||
isCombination
|
||||
? {
|
||||
catalog_method_profile: it.catalog_method_profile,
|
||||
planning_method_profile: it.planning_method_profile,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
>
|
||||
Vorschau
|
||||
|
|
@ -1142,29 +1223,73 @@ export default function TrainingUnitSectionsEditor({
|
|||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '8px 10px',
|
||||
padding: '6px 12px 8px',
|
||||
alignItems: 'flex-start',
|
||||
gap: '10px',
|
||||
padding: '8px 12px 10px',
|
||||
paddingLeft: enableItemDragReorder ? 44 : 12,
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
<div
|
||||
style={{
|
||||
flex: '1 1 200px',
|
||||
minWidth: 0,
|
||||
fontSize: '0.78rem',
|
||||
color: 'var(--text2)',
|
||||
flex: '1 1 160px',
|
||||
minWidth: 0,
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
title="Ablaufprofil für diese Kombi in diesem Termin — Editor öffnen zum Anpassen"
|
||||
title="Stationen und grobe Zeiten aus Katalog bzw. Planungs-Anpassung — Details unter „Ablauf bearbeiten“ oder „Vorschau“"
|
||||
>
|
||||
<strong style={{ color: 'var(--text1)', fontWeight: 600 }}>Ablauf: </strong>
|
||||
<span>{compactComboPlanningCaption(it)}</span>
|
||||
</span>
|
||||
<div style={{ marginBottom: stripBullets.length || stripGlobalRough ? 6 : 0 }}>
|
||||
<strong style={{ color: 'var(--text1)', fontWeight: 600 }}>Archetyp: </strong>
|
||||
<span style={{ color: 'var(--text1)' }}>
|
||||
{stripArchLbl || stripArchRaw || '—'}
|
||||
{stripArchRaw && stripArchLbl && stripArchLbl !== stripArchRaw ? (
|
||||
<span style={{ marginLeft: 6, fontWeight: 400, color: 'var(--text3)', fontSize: '0.72rem' }}>
|
||||
({stripArchRaw})
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span style={{ marginLeft: 10, fontWeight: 500 }}>{compactComboPlanningCaption(it)}</span>
|
||||
</div>
|
||||
{stripGlobalRough ? (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: stripBullets.length ? 6 : 0,
|
||||
fontSize: '0.74rem',
|
||||
color: 'var(--text3)',
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: 'var(--text2)', fontWeight: 600 }}>Block: </strong>
|
||||
{stripGlobalRough}
|
||||
</div>
|
||||
) : null}
|
||||
{stripBullets.length > 0 ? (
|
||||
<ul
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingLeft: '1.05rem',
|
||||
fontSize: '0.74rem',
|
||||
color: 'var(--text2)',
|
||||
}}
|
||||
>
|
||||
{stripBullets.map((line, bi) => (
|
||||
<li key={`combo-strip-${sIdx}-${iIdx}-${bi}`} style={{ marginBottom: 2 }}>
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', fontStyle: 'italic' }}>
|
||||
Stationen laden oder noch keine Kombi-Stationen im Katalog …
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
style={{ flexShrink: 0 }}
|
||||
aria-haspopup="dialog"
|
||||
aria-label="Ablaufprofil Kombination für diese Planung bearbeiten"
|
||||
onClick={() => setComboPlanningModal({ sIdx, iIdx })}
|
||||
|
|
|
|||
|
|
@ -2892,8 +2892,12 @@ function TrainingPlanningPage() {
|
|||
})
|
||||
setExercisePickerOpen(true)
|
||||
}}
|
||||
onPeekExercise={(id, variantId) =>
|
||||
setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null })
|
||||
onPeekExercise={(id, variantId, peekExtras) =>
|
||||
setPlanningPeekCtx({
|
||||
exerciseId: id,
|
||||
variantId: variantId ?? null,
|
||||
peekExtras: peekExtras ?? null,
|
||||
})
|
||||
}
|
||||
showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
|
||||
/>
|
||||
|
|
@ -3078,6 +3082,7 @@ function TrainingPlanningPage() {
|
|||
open={planningPeekCtx != null}
|
||||
exerciseId={planningPeekCtx?.exerciseId}
|
||||
variantId={planningPeekCtx?.variantId ?? undefined}
|
||||
peekExtras={planningPeekCtx?.peekExtras ?? undefined}
|
||||
onClose={() => setPlanningPeekCtx(null)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -242,6 +242,47 @@ export function readSlotProfilesV1(profileObj) {
|
|||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
/** Kurztext für Listen/strip (Coach „Plan:“ — gleiche Logik). */
|
||||
export function summarizeSlotProfileBrief(r) {
|
||||
if (!r) return null
|
||||
const adv = r.advance_mode || 'timed'
|
||||
const bits = []
|
||||
if (adv === 'timed') {
|
||||
bits.push('Zeit')
|
||||
if (r.load_sec != null) bits.push(`${r.load_sec}s Arbeit`)
|
||||
} else if (adv === 'rep') {
|
||||
bits.push('Ziel‑Wdh.')
|
||||
const nSer = r.rep_series_count != null && r.rep_series_count >= 1 ? r.rep_series_count : 1
|
||||
if (r.consecutive_reps != null) {
|
||||
if (nSer >= 2) bits.push(`${nSer} Serien à ${r.consecutive_reps}×`)
|
||||
else bits.push(`${r.consecutive_reps}×`)
|
||||
}
|
||||
} else {
|
||||
bits.push('Coach')
|
||||
const nSerMan = r.rep_series_count != null && r.rep_series_count >= 2 ? r.rep_series_count : 1
|
||||
if (r.consecutive_reps != null) {
|
||||
if (nSerMan >= 2) bits.push(`${nSerMan} Serien à Richtwert ${r.consecutive_reps}×`)
|
||||
else bits.push(`Richtwert ${r.consecutive_reps}×`)
|
||||
} else if (r.rep_series_count != null && r.rep_series_count >= 2) {
|
||||
bits.push(`${r.rep_series_count} Serien`)
|
||||
}
|
||||
}
|
||||
if (r.intra_rep_rest_sec != null) {
|
||||
if (adv === 'timed') bits.push(`Pause ${r.intra_rep_rest_sec}s`)
|
||||
else if (adv === 'rep' && r.rep_series_count != null && r.rep_series_count >= 2)
|
||||
bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
|
||||
else if (
|
||||
adv === 'manual' &&
|
||||
r.rep_series_count != null &&
|
||||
r.rep_series_count >= 2
|
||||
) {
|
||||
bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
|
||||
}
|
||||
}
|
||||
if (r.transition_after_sec != null) bits.push(`Wechsel ${r.transition_after_sec}s`)
|
||||
return bits.join(' · ')
|
||||
}
|
||||
|
||||
function normalizeOptionalNonNegInt(v) {
|
||||
if (v === '' || v === undefined || v === null) return undefined
|
||||
const n = typeof v === 'number' ? v : parseInt(String(v), 10)
|
||||
|
|
|
|||
|
|
@ -276,6 +276,48 @@ export async function enrichSectionsWithVariants(sections) {
|
|||
}
|
||||
})
|
||||
)
|
||||
|
||||
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) => {
|
||||
|
|
@ -306,7 +348,7 @@ export async function enrichSectionsWithVariants(sections) {
|
|||
exercise_club_id: c.club_id,
|
||||
exercise_created_by: c.created_by,
|
||||
exercise_status: c.status,
|
||||
...(isCombo ? { combination_slots: c.combination_slots || [] } : {}),
|
||||
...(isCombo ? { combination_slots: c.combination_slots || [], combo_member_title_by_id: comboMemberTitleByIdForSlots(c.combination_slots || []) } : {}),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user