Verbesserung UX für Übungen #44

Merged
Lars merged 5 commits from develop into main 2026-05-21 15:02:15 +02:00
3 changed files with 384 additions and 174 deletions
Showing only changes of commit 13a1d3a060 - Show all commits

View File

@ -6548,9 +6548,110 @@ html.modal-scroll-locked .app-main {
min-width: 0; min-width: 0;
} }
/* Übungsformular — Register-Tabs & farbige Bereiche */
.exercise-form-edit {
padding-top: 4px;
}
.exercise-form-edit__tabbar {
display: flex;
align-items: stretch;
margin: 0 0 16px;
padding: 0 0 12px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 6;
background: var(--surface);
}
.exercise-form-edit__tabbar .admin-page-subtabs {
flex: 1;
min-width: 0;
}
.exercise-form-panel {
padding: 4px 0 8px 14px;
margin-bottom: 4px;
border-left: 3px solid var(--border);
}
.exercise-form-panel--basics {
border-left-color: var(--accent);
}
.exercise-form-panel--guide {
border-left-color: color-mix(in srgb, #2563eb 70%, var(--accent));
}
.exercise-form-panel--classify {
border-left-color: color-mix(in srgb, #7c3aed 65%, var(--accent));
}
.exercise-form-panel--combo {
border-left-color: color-mix(in srgb, #d97706 70%, var(--accent-dark));
}
.exercise-form-panel--variants {
border-left-color: color-mix(in srgb, #0891b2 70%, var(--accent));
}
.exercise-form-panel--media {
border-left-color: color-mix(in srgb, var(--text3) 55%, var(--border));
}
.exercise-form-panel__title {
margin: 0 0 4px;
font-size: 1.05rem;
font-weight: 700;
}
.exercise-form-panel__hint {
margin: 0 0 14px;
font-size: 12px;
color: var(--text3);
line-height: 1.45;
}
.exercise-form-panel__body {
min-width: 0;
}
.exercise-form-type-box {
padding: 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface2);
margin-bottom: 12px;
}
.exercise-form-type-box__hint {
margin: 8px 0 0;
font-size: 12px;
color: var(--text2);
line-height: 1.45;
}
.exercise-form-inline-tab-link {
padding: 0;
border: none;
background: none;
color: var(--accent-dark);
font: inherit;
font-weight: 700;
text-decoration: underline;
cursor: pointer;
}
.exercise-form-subsection {
padding: 12px 0;
border-top: 1px dashed var(--border);
margin-top: 8px;
}
.exercise-form-subsection:first-child {
border-top: none;
margin-top: 0;
padding-top: 0;
}
.exercise-form-subsection__title {
margin: 0 0 6px;
font-size: 0.95rem;
font-weight: 700;
}
.exercise-form-subsection__hint {
margin: 0 0 10px;
font-size: 12px;
color: var(--text3);
line-height: 1.4;
}
/* Übungsformular — Klassifikation & Meta-Chips */ /* Übungsformular — Klassifikation & Meta-Chips */
.exercise-form-meta-panel { .exercise-form-meta-panel {
margin: 4px 0 16px; margin: 0;
padding: 14px; padding: 14px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;

View File

@ -0,0 +1,32 @@
import React from 'react'
import PageSectionNav from '../PageSectionNav'
export function ExerciseFormTabBar({ activeTab, onChange, items }) {
return (
<div className="exercise-form-edit__tabbar">
<PageSectionNav
ariaLabel="Übungsbereiche"
value={activeTab}
onChange={onChange}
items={items}
className="page-section-nav--embedded exercise-form-edit__section-nav"
/>
</div>
)
}
export function ExerciseFormPanel({ tab, activeTab, tone = 'default', title, hint, children }) {
if (activeTab !== tab) return null
return (
<section
id={`exercise-form-panel-${tab}`}
className={`exercise-form-panel exercise-form-panel--${tone}`}
role="tabpanel"
aria-labelledby={`exercise-form-tab-${tab}`}
>
{title ? <h3 className="exercise-form-panel__title">{title}</h3> : null}
{hint ? <p className="exercise-form-panel__hint">{hint}</p> : null}
<div className="exercise-form-panel__body">{children}</div>
</section>
)
}

View File

@ -26,9 +26,10 @@ import {
} from '../../utils/activeClub' } from '../../utils/activeClub'
import { COMBINATION_ARCHETYPE_OPTIONS, ARCHETYPE_DEFAULT_REP_SERIES_COUNT, defaultRepSeriesCountForArchetype } from '../../constants/combinationArchetypes' import { COMBINATION_ARCHETYPE_OPTIONS, ARCHETYPE_DEFAULT_REP_SERIES_COUNT, defaultRepSeriesCountForArchetype } from '../../constants/combinationArchetypes'
import { readSlotProfilesV1, normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../../utils/combinationMethodProfileUi' import { readSlotProfilesV1, normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../../utils/combinationMethodProfileUi'
import { GripVertical } from 'lucide-react' import { GripVertical, FileText, BookOpen, Tags, Layers, GitBranch, Image as ImageIcon } from 'lucide-react'
import UnsavedChangesPrompt from '../UnsavedChangesPrompt' import UnsavedChangesPrompt from '../UnsavedChangesPrompt'
import PageFormEditorChrome from '../PageFormEditorChrome' import PageFormEditorChrome from '../PageFormEditorChrome'
import { ExerciseFormTabBar, ExerciseFormPanel } from './ExerciseFormLayout'
import { useNavReturn } from '../../hooks/useNavReturn' import { useNavReturn } from '../../hooks/useNavReturn'
import { import {
EXERCISES_LIST_PATH, EXERCISES_LIST_PATH,
@ -499,9 +500,45 @@ function ExerciseFormPageRoot() {
const [variantSavingId, setVariantSavingId] = useState(null) const [variantSavingId, setVariantSavingId] = useState(null)
const [variantBusy, setVariantBusy] = useState(false) const [variantBusy, setVariantBusy] = useState(false)
const [variantEditSelection, setVariantEditSelection] = useState(null) const [variantEditSelection, setVariantEditSelection] = useState(null)
const variantsDetailsRef = useRef(null) const [activeFormTab, setActiveFormTab] = useState('stammdaten')
const variantsSavedSnapshotRef = useRef({}) const variantsSavedSnapshotRef = useRef({})
const exerciseFormTabs = useMemo(() => {
const tabs = [
{ id: 'stammdaten', label: 'Stammdaten', icon: FileText },
{ id: 'anleitung', label: 'Anleitung', icon: BookOpen },
{ id: 'einordnung', label: 'Einordnung', icon: Tags },
]
if (formData.exercise_kind === 'combination') {
tabs.push({ id: 'kombination', label: 'Kombination', icon: Layers })
}
if (isEdit) {
if (formData.exercise_kind !== 'combination') {
tabs.push({
id: 'varianten',
label: variants.length > 0 ? `Varianten (${variants.length})` : 'Varianten',
icon: GitBranch,
})
}
tabs.push({ id: 'medien', label: 'Medien & Mehr', icon: ImageIcon })
} else {
tabs.push({ id: 'varianten', label: 'Varianten', icon: GitBranch, disabled: true })
tabs.push({ id: 'medien', label: 'Medien & Mehr', icon: ImageIcon, disabled: true })
}
return tabs
}, [formData.exercise_kind, isEdit, variants.length])
useEffect(() => {
const allowed = new Set(exerciseFormTabs.filter((t) => !t.disabled).map((t) => t.id))
if (!allowed.has(activeFormTab)) setActiveFormTab('stammdaten')
}, [exerciseFormTabs, activeFormTab])
useEffect(() => {
if (formData.exercise_kind === 'combination' && activeFormTab === 'varianten') {
setActiveFormTab('kombination')
}
}, [formData.exercise_kind, activeFormTab])
const syncVariantsSavedSnapshot = useCallback((rows) => { const syncVariantsSavedSnapshot = useCallback((rows) => {
const snap = {} const snap = {}
for (const v of rows || []) { for (const v of rows || []) {
@ -657,10 +694,10 @@ function ExerciseFormPageRoot() {
}, [variants, variantEditSelection]) }, [variants, variantEditSelection])
useEffect(() => { useEffect(() => {
if (variantEditSelection != null && variantsDetailsRef.current) { if (variantEditSelection != null && isEdit && formData.exercise_kind !== 'combination') {
variantsDetailsRef.current.open = true setActiveFormTab('varianten')
} }
}, [variantEditSelection]) }, [variantEditSelection, isEdit, formData.exercise_kind])
const updateFormField = (field, value) => { const updateFormField = (field, value) => {
setFormDirty(true) setFormDirty(true)
@ -1246,8 +1283,25 @@ function ExerciseFormPageRoot() {
</p> </p>
) : null} ) : null}
<div className="card"> <div className="card exercise-form-edit">
<form id="exercise-form" onSubmit={handleSubmit}> <form id="exercise-form" onSubmit={handleSubmit}>
<ExerciseFormTabBar
activeTab={activeFormTab}
onChange={setActiveFormTab}
items={exerciseFormTabs}
/>
<ExerciseFormPanel
tab="stammdaten"
activeTab={activeFormTab}
tone="basics"
title="Stammdaten"
hint={
isEdit
? 'Titel, Rahmendaten und Sichtbarkeit — Inhalt und Einordnung in den anderen Tabs.'
: 'Titel und Rahmendaten. Varianten, Medien und Progressionsgraph sind nach dem ersten Speichern verfügbar.'
}
>
<div className="form-row"> <div className="form-row">
<label className="form-label">Titel *</label> <label className="form-label">Titel *</label>
<input <input
@ -1273,16 +1327,7 @@ function ExerciseFormPageRoot() {
/> />
</div> </div>
<div <div className="exercise-form-type-box">
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
marginBottom: '12px',
}}
>
<h3 style={{ marginTop: 0, marginBottom: '10px', fontSize: '1rem' }}>Übungstyp</h3>
<div className="form-row"> <div className="form-row">
<label className="form-label">Art</label> <label className="form-label">Art</label>
<select <select
@ -1302,12 +1347,144 @@ function ExerciseFormPageRoot() {
} }
: {}), : {}),
})) }))
if (nk === 'combination') setActiveFormTab('kombination')
}} }}
> >
<option value="simple">Einzelübung</option> <option value="simple">Einzelübung</option>
<option value="combination">Kombinationsübung (Stationen / Pool)</option> <option value="combination">Kombinationsübung (Stationen / Pool)</option>
</select> </select>
</div> </div>
{formData.exercise_kind === 'combination' ? (
<p className="exercise-form-type-box__hint">
Stationen und Ablaufprofil im Tab{' '}
<button type="button" className="exercise-form-inline-tab-link" onClick={() => setActiveFormTab('kombination')}>
Kombination
</button>
.
</p>
) : null}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Dauer Min</label>
<input
type="number"
className="form-input"
value={formData.duration_min}
onChange={(e) =>
updateFormField('duration_min', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
<div className="form-row">
<label className="form-label">Dauer Max</label>
<input
type="number"
className="form-input"
value={formData.duration_max}
onChange={(e) =>
updateFormField('duration_max', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Gruppe Min</label>
<input
type="number"
className="form-input"
value={formData.group_size_min}
onChange={(e) =>
updateFormField('group_size_min', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
<div className="form-row">
<label className="form-label">Gruppe Max</label>
<input
type="number"
className="form-input"
value={formData.group_size_max}
onChange={(e) =>
updateFormField('group_size_max', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Material (eine Zeile oder kommagetrennt)</label>
<textarea
className="form-input"
rows={3}
value={formData.equipmentLines}
onChange={(e) => updateFormField('equipmentLines', e.target.value)}
placeholder="Matten&#10;Pratzen"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={formData.visibility}
onChange={(e) => updateFormField('visibility', e.target.value)}
>
<option value="private">Privat</option>
<option value="club">Verein</option>
{isSuperadmin ? <option value="official">Offiziell</option> : null}
</select>
</div>
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={formData.status}
onChange={(e) => updateFormField('status', e.target.value)}
>
<option value="draft">Entwurf</option>
<option value="in_review">In Prüfung</option>
<option value="approved">Freigegeben</option>
<option value="archived">Archiviert</option>
</select>
</div>
</div>
{formData.visibility === 'club' && visibilityClubChoices.length > 0 ? (
<div className="form-row" style={{ marginTop: '10px' }}>
<label className="form-label">Verein (Sichtbarkeit)</label>
<select
className="form-input"
value={formData.club_id != null && formData.club_id !== '' ? String(formData.club_id) : ''}
onChange={(e) => {
const v = e.target.value
updateFormField('club_id', v === '' ? null : Number(v))
}}
>
{visibilityClubChoices.map((c) => (
<option key={c.id} value={String(c.id)}>
{(c.name || '').trim() || `Verein #${c.id}`}
</option>
))}
</select>
<p style={{ margin: '6px 0 0', fontSize: '12px', color: 'var(--text3)', lineHeight: 1.4 }}>
Standard ist der aktive Verein aus der Navigation. Bei Plattform-Admins sind alle Vereine wählbar.
</p>
</div>
) : null}
</ExerciseFormPanel>
<ExerciseFormPanel
tab="kombination"
activeTab={activeFormTab}
tone="combo"
title="Kombinationsübung"
hint="Stationen, Übungs-Pools und globales Ablaufprofil für Coach und Planung."
>
{formData.exercise_kind === 'combination' ? ( {formData.exercise_kind === 'combination' ? (
<> <>
<div className="form-row"> <div className="form-row">
@ -1721,9 +1898,20 @@ function ExerciseFormPageRoot() {
/> />
</div> </div>
</> </>
) : null} ) : (
</div> <p className="exercise-form-panel__hint" style={{ margin: 0 }}>
Wähle unter <strong>Stammdaten</strong> die Art Kombinationsübung, um Stationen zu planen.
</p>
)}
</ExerciseFormPanel>
<ExerciseFormPanel
tab="anleitung"
activeTab={activeFormTab}
tone="guide"
title="Anleitung"
hint="Ziel, Ablauf und Hinweise — Medien kannst du in die Texte einbetten (Symbolleiste)."
>
<div className="form-row"> <div className="form-row">
<label className="form-label">Ziel *</label> <label className="form-label">Ziel *</label>
<RichTextEditor <RichTextEditor
@ -1775,72 +1963,16 @@ function ExerciseFormPageRoot() {
onExerciseMediaListChanged={refreshMedia} onExerciseMediaListChanged={refreshMedia}
/> />
</div> </div>
</ExerciseFormPanel>
<div className="form-row"> <ExerciseFormPanel
<label className="form-label">Material (eine Zeile oder kommagetrennt)</label> tab="einordnung"
<textarea activeTab={activeFormTab}
className="form-input" tone="classify"
rows={3} title="Einordnung"
value={formData.equipmentLines} hint="Fokus, Stile, Zielgruppen und Fähigkeiten für Suche, Filter und Skill-Profil."
onChange={(e) => updateFormField('equipmentLines', e.target.value)} >
placeholder="Matten&#10;Pratzen" <section className="exercise-form-meta-panel" aria-label="Klassifikation">
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Dauer Min</label>
<input
type="number"
className="form-input"
value={formData.duration_min}
onChange={(e) =>
updateFormField('duration_min', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
<div className="form-row">
<label className="form-label">Dauer Max</label>
<input
type="number"
className="form-input"
value={formData.duration_max}
onChange={(e) =>
updateFormField('duration_max', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Gruppe Min</label>
<input
type="number"
className="form-input"
value={formData.group_size_min}
onChange={(e) =>
updateFormField('group_size_min', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
<div className="form-row">
<label className="form-label">Gruppe Max</label>
<input
type="number"
className="form-input"
value={formData.group_size_max}
onChange={(e) =>
updateFormField('group_size_max', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
</div>
<section className="exercise-form-meta-panel" aria-labelledby="exercise-meta-heading">
<h3 id="exercise-meta-heading" className="exercise-form-meta-panel__title">
Klassifikation &amp; Zielgruppe
</h3>
<div className="exercise-form-meta-panel__grid"> <div className="exercise-form-meta-panel__grid">
<ExerciseCatalogAssocEditor <ExerciseCatalogAssocEditor
title="Fokusbereiche" title="Fokusbereiche"
@ -1893,77 +2025,16 @@ function ExerciseFormPageRoot() {
onUpdateField={updateSkillField} onUpdateField={updateSkillField}
/> />
</section> </section>
</ExerciseFormPanel>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}> {isEdit && formData.exercise_kind !== 'combination' ? (
<div className="form-row"> <ExerciseFormPanel
<label className="form-label">Sichtbarkeit</label> tab="varianten"
<select activeTab={activeFormTab}
className="form-input" tone="variants"
value={formData.visibility} title="Übungsvarianten"
onChange={(e) => updateFormField('visibility', e.target.value)} hint="Pro Durchgang eine Variante. Änderungen werden mit Speichern in der Aktionsleiste mitgesichert."
> >
<option value="private">Privat</option>
<option value="club">Verein</option>
{isSuperadmin ? <option value="official">Offiziell</option> : null}
</select>
</div>
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={formData.status}
onChange={(e) => updateFormField('status', e.target.value)}
>
<option value="draft">Entwurf</option>
<option value="in_review">In Prüfung</option>
<option value="approved">Freigegeben</option>
<option value="archived">Archiviert</option>
</select>
</div>
</div>
{formData.visibility === 'club' && visibilityClubChoices.length > 0 ? (
<div className="form-row" style={{ marginTop: '10px' }}>
<label className="form-label">Verein (Sichtbarkeit)</label>
<select
className="form-input"
value={formData.club_id != null && formData.club_id !== '' ? String(formData.club_id) : ''}
onChange={(e) => {
const v = e.target.value
updateFormField('club_id', v === '' ? null : Number(v))
}}
>
{visibilityClubChoices.map((c) => (
<option key={c.id} value={String(c.id)}>
{(c.name || '').trim() || `Verein #${c.id}`}
</option>
))}
</select>
<p style={{ margin: '6px 0 0', fontSize: '12px', color: 'var(--text3)', lineHeight: 1.4 }}>
Standard ist der aktive Verein aus der Navigation. Bei Plattform-Admins sind alle Vereine wählbar.
</p>
</div>
) : null}
</form>
</div>
{isEdit && formData.exercise_kind !== 'combination' && (
<details ref={variantsDetailsRef} className="card exercise-variants-details" style={{ marginTop: '16px' }}>
<summary className="exercise-variants-summary">
<span className="exercise-variants-summary__title">Übungsvarianten</span>
<span className="exercise-variants-summary__badge">
{variants.length === 0
? 'keine'
: `${variants.length} ${variants.length === 1 ? 'Variante' : 'Varianten'}`}
</span>
</summary>
<div className="exercise-variants-details__body">
<p className="exercise-variants-hint">
Pro Durchgang nur eine Variante bearbeiten weniger Scrollen. Reihenfolge entspricht Planung und Auswahl im
Training; Voraussetzung nutzt ihr später für Progressions-Serien. Offene Varianten-Änderungen werden mit
Speichern in der Aktionsleiste (oder in der Sicherheitsabfrage beim Verlassen) automatisch mitgespeichert.
</p>
{variants.length > 0 && ( {variants.length > 0 && (
<div className="form-row"> <div className="form-row">
<label className="form-label" htmlFor="variant-edit-select"> <label className="form-label" htmlFor="variant-edit-select">
@ -2106,25 +2177,19 @@ function ExerciseFormPageRoot() {
Wähle eine Variante zum Bearbeiten oder Neue Variante anlegen. Wähle eine Variante zum Bearbeiten oder Neue Variante anlegen.
</p> </p>
)} )}
</div> </ExerciseFormPanel>
</details> ) : null}
)}
{isEdit && formData.exercise_kind !== 'combination' && ( {isEdit ? (
<details className="card exercise-variants-details" style={{ marginTop: '16px' }}> <ExerciseFormPanel
<summary className="exercise-variants-summary"> tab="medien"
<span className="exercise-variants-summary__title">Progressionsgraph</span> activeTab={activeFormTab}
<span className="exercise-variants-summary__badge">Übung Übung</span> tone="media"
</summary> title="Medien & Erweiterungen"
<div className="exercise-variants-details__body"> hint="Verknüpfte Dateien, Progressionsgraph und Medienarchiv."
<ExerciseProgressionGraphPanel anchorExerciseId={exerciseId} anchorTitle={formData.title} /> >
</div> <div className="exercise-form-subsection exercise-form-subsection--media">
</details> <h4 className="exercise-form-subsection__title">Medien</h4>
)}
{isEdit && (
<div className="card" style={{ marginTop: '16px' }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
<p style={{ color: 'var(--text2)', fontSize: '13px', marginBottom: '6px' }}> <p style={{ color: 'var(--text2)', fontSize: '13px', marginBottom: '6px' }}>
Neue Uploads oder Embeds über die Textfeld-Symbolleiste (Medien im Text / Embed im Text). Hier Neue Uploads oder Embeds über die Textfeld-Symbolleiste (Medien im Text / Embed im Text). Hier
verwaltest du Verknüpfungen Kachel in ein Textfeld ziehen, um sie an der Cursorposition einzufügen verwaltest du Verknüpfungen Kachel in ein Textfeld ziehen, um sie an der Cursorposition einzufügen
@ -2268,6 +2333,15 @@ function ExerciseFormPageRoot() {
Verknüpfungen bleiben nötig (u. a. Zugriff, Orphan-Hinweise): Im Fließtext verweist du gezielt über Verknüpfungen bleiben nötig (u. a. Zugriff, Orphan-Hinweise): Im Fließtext verweist du gezielt über
Platzhalter. Ohne Verknüpfung gäbe es keine exercise_media-ID zum Einbetten. Platzhalter. Ohne Verknüpfung gäbe es keine exercise_media-ID zum Einbetten.
</p> </p>
</div>
{formData.exercise_kind !== 'combination' ? (
<div className="exercise-form-subsection exercise-form-subsection--graph">
<h4 className="exercise-form-subsection__title">Progressionsgraph</h4>
<p className="exercise-form-subsection__hint">Übergänge zu anderen Übungen für Progressions-Serien.</p>
<ExerciseProgressionGraphPanel anchorExerciseId={exerciseId} anchorTitle={formData.title} />
</div>
) : null}
{archiveOpen && ( {archiveOpen && (
<div <div
role="dialog" role="dialog"
@ -2408,8 +2482,11 @@ function ExerciseFormPageRoot() {
onClose={() => setReportTarget(null)} onClose={() => setReportTarget(null)}
/> />
)} )}
</div> </ExerciseFormPanel>
)} ) : null}
</form>
</div>
<ExercisePickerModal <ExercisePickerModal
open={comboStationPickerIx !== null} open={comboStationPickerIx !== null}