All checks were successful
Deploy Development / deploy (push) Successful in 40s
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 1m5s
Test Suite / pytest-backend (pull_request) Successful in 34s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 11s
Test Suite / k6 /health Baseline (pull_request) Successful in 33s
Test Suite / playwright-tests (pull_request) Successful in 1m8s
- Updated CSS for training unit sections to improve layout and responsiveness, ensuring combo planning strips are displayed correctly. - Refactored CombinationPlanBracket component to accept additional class names for better customization. - Removed unused functions and streamlined imports in TrainingUnitSectionsEditor for cleaner code. - Reintroduced ExercisePickerModal with improved placement in ExerciseFormPage for better user experience.
167 lines
7.4 KiB
JavaScript
167 lines
7.4 KiB
JavaScript
/**
|
||
* Kombination: konsolidierte Darstellung globales Profil + Stationen mit Zeiten (Vorschau, Plan-Ansicht, Druck).
|
||
*/
|
||
import React, { useMemo } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import {
|
||
archetypeCoachHint,
|
||
combinationArchetypeLabel,
|
||
sortCombinationSlotsForDisplay,
|
||
} from '../constants/combinationArchetypes'
|
||
import {
|
||
describeGlobalComboProfile,
|
||
effectiveStationTimingSummary,
|
||
readSlotProfilesV1,
|
||
stationPrimaryLoadLabel,
|
||
} from '../utils/combinationMethodProfileUi'
|
||
|
||
/** @returns {{ exerciseId: number, label: string }[]} */
|
||
export function normalizeCombinationSlotCandidates(slot) {
|
||
const out = []
|
||
const cands =
|
||
slot.candidates && slot.candidates.length
|
||
? slot.candidates
|
||
: (slot.candidate_exercise_ids || []).map((id) => ({ exercise_id: id, title: null }))
|
||
for (const c of cands) {
|
||
const rawId = c.exercise_id
|
||
if (rawId == null) continue
|
||
const n = typeof rawId === 'number' ? rawId : parseInt(String(rawId), 10)
|
||
if (!Number.isFinite(n)) continue
|
||
const label = ((c.title || '').trim() || `Übung #${n}`).trim()
|
||
out.push({ exerciseId: n, label })
|
||
}
|
||
return out
|
||
}
|
||
|
||
export default function CombinationPlanBracket({
|
||
methodArchetype,
|
||
methodProfile,
|
||
combinationSlots,
|
||
planningAdjusted = false,
|
||
/** 'none' | 'link' (Router) | 'button' (z. B. ExercisePeekModal / PWA-sicher) */
|
||
candidateInteraction = 'none',
|
||
onCandidatePeek,
|
||
className,
|
||
}) {
|
||
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
|
||
const archLabel = arch ? combinationArchetypeLabel(arch) : null
|
||
const globals = describeGlobalComboProfile(arch, methodProfile || {})
|
||
const slotsSorted = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots || []), [combinationSlots])
|
||
const timingByIx = useMemo(() => {
|
||
const mp = methodProfile || {}
|
||
const rows = readSlotProfilesV1(mp)
|
||
const m = new Map()
|
||
for (const r of rows) {
|
||
m.set(Number(r.slot_index), r)
|
||
}
|
||
return m
|
||
}, [methodProfile])
|
||
|
||
const coachHint = arch ? archetypeCoachHint(arch) : ''
|
||
|
||
return (
|
||
<div className={['combo-plan-bracket', className].filter(Boolean).join(' ')}>
|
||
<div className="combo-plan-bracket__accent" aria-hidden />
|
||
<div className="combo-plan-bracket__body">
|
||
<header className="combo-plan-bracket__head">
|
||
<div className="combo-plan-bracket__head-main">
|
||
<span className="combo-plan-bracket__kicker">Kombinations‑Plan</span>
|
||
<span className="combo-plan-bracket__archetype">{archLabel || arch || 'Archetyp'}</span>
|
||
{planningAdjusted ? (
|
||
<span className="combo-plan-bracket__badge">Planung angepasst</span>
|
||
) : null}
|
||
</div>
|
||
{coachHint ? <p className="combo-plan-bracket__hint">{coachHint}</p> : null}
|
||
</header>
|
||
|
||
{globals.length > 0 ? (
|
||
<section className="combo-plan-bracket__globals" aria-label="Globale Eckdaten">
|
||
<div className="combo-plan-bracket__globals-title">Runden · Zeiten · Pausen (global)</div>
|
||
<ul className="combo-plan-bracket__chip-row">
|
||
{globals.map((g) => (
|
||
<li key={g.key} className="combo-plan-bracket__chip" title={g.detailLabel}>
|
||
<span className="combo-plan-bracket__chip-cap">{g.caption}</span>
|
||
<span className="combo-plan-bracket__chip-val">{g.value}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
) : (
|
||
<p className="combo-plan-bracket__globals-empty">
|
||
Keine globalen Zahlenfelder gesetzt — Steuerung erfolgt nur je Station oder über den Freitext der Kombination.
|
||
</p>
|
||
)}
|
||
|
||
<ol className="combo-plan-bracket__stations">
|
||
{slotsSorted.map((slot, si) => {
|
||
const siRaw = slot.slot_index
|
||
const ixParsed =
|
||
siRaw === '' || siRaw == null ? si : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10)
|
||
const stationIx = Number.isFinite(ixParsed) ? ixParsed : si
|
||
const displayStep = si + 1
|
||
const stationTitle = ((slot.title || '').trim() || `Station ${displayStep}`).trim()
|
||
const candRows = normalizeCombinationSlotCandidates(slot)
|
||
const names = candRows.length ? candRows.map((r) => r.label).join(' ↔ ') : ''
|
||
const slotProfRow = timingByIx.get(stationIx)
|
||
const loadBadge = stationPrimaryLoadLabel(slotProfRow)
|
||
const timing = effectiveStationTimingSummary(arch, methodProfile || {}, slotProfRow)
|
||
|
||
return (
|
||
<li key={`slot-${stationIx}-${si}`} className="combo-plan-bracket__station">
|
||
<div
|
||
className="combo-plan-bracket__station-load"
|
||
title={loadBadge ? 'Belastung je Station (Sekunden oder Wiederholungen)' : undefined}
|
||
>
|
||
{loadBadge || '—'}
|
||
</div>
|
||
<div className="combo-plan-bracket__station-main">
|
||
<div className="combo-plan-bracket__station-title">{stationTitle}</div>
|
||
{candidateInteraction === 'button' && typeof onCandidatePeek === 'function' && candRows.length > 0 ? (
|
||
<div className="combo-plan-bracket__station-exercises combo-plan-bracket__station-exercises--interactive">
|
||
{candRows.map((c, ci) => (
|
||
<span key={`${c.exerciseId}-${ci}`} className="combo-plan-bracket__cand-inline">
|
||
{ci > 0 ? <span className="combo-plan-bracket__cand-sep">↔</span> : null}
|
||
<button
|
||
type="button"
|
||
className="combo-plan-bracket__cand-btn"
|
||
onClick={() => onCandidatePeek(c.exerciseId, c.label)}
|
||
>
|
||
{c.label}
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : candidateInteraction === 'link' && candRows.length > 0 ? (
|
||
<div className="combo-plan-bracket__station-exercises combo-plan-bracket__station-exercises--interactive">
|
||
{candRows.map((c, ci) => (
|
||
<span key={`${c.exerciseId}-${ci}`} className="combo-plan-bracket__cand-inline">
|
||
{ci > 0 ? <span className="combo-plan-bracket__cand-sep">↔</span> : null}
|
||
<Link to={`/exercises/${c.exerciseId}`} className="combo-plan-bracket__cand-link">
|
||
{c.label}
|
||
</Link>
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="combo-plan-bracket__station-exercises">{names || '(keine Einzelübung)'}</div>
|
||
)}
|
||
{timing ? (
|
||
<div className="combo-plan-bracket__station-timing">
|
||
<span className="combo-plan-bracket__timing-label">Zeit / Steuerung</span>
|
||
<span>{timing}</span>
|
||
</div>
|
||
) : (
|
||
<div className="combo-plan-bracket__station-timing combo-plan-bracket__station-timing--muted">
|
||
Keine eigene Stations‑Zeit im Profil — ggf. nur globale Vorgaben oder Freitext.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</li>
|
||
)
|
||
})}
|
||
</ol>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|