feat(version): bump to 0.8.106 and enhance combination exercise features
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 55s
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 55s
- Updated app version to 0.8.106, reflecting recent improvements in combination exercise handling. - Introduced `advance_mode` for slot profiles, allowing for flexible timing options (timed, repetitions, manual) in the CombinationMethodProfileEditor. - Enhanced the CombinationCoachSlots component to display timing summaries based on the selected advance mode. - Updated ExerciseFormPage to manage combination slots with new validation and user feedback for exercise selection. - Documented changes in the changelog for better tracking of feature enhancements. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ce63d46cf4
commit
38d84ecdf6
|
|
@ -423,6 +423,8 @@ Alle diese Angaben sind **Anweisungen an den Trainer** und **Coach‑Assistenz**
|
|||
|
||||
**Geplantes kanonisches Zeitmodell:** Globale Eckwerte (z. B. Anzahl der Durchläufe / Runden, optionale Gesamt-/Einführungszeit als Ziel oder Rechenhilfe) und **pro Platz (Slot)** die Dimensionen „Belastung“, „wie viele gleiche Übung hintereinander“, „kurze Pause dazwischen“, „Übergangszeit zur nächsten Übung/arbeitstation“ — dokumentiert für die technische Angleichung in **`.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md`** (Felder **`slot_profiles_v1`**, `timing_schema`). Archetypen können **Strukturen und typische Schnellwahlen** vorgeben (z. B. Zirkel: Relation Belastungszeit = Übergangszeit oder Erholungsanteil ≈ 2/3 der Belastung); der Archetyp **Freier Methodenblock** bildet den **Maximal‑Pfad** ohne stärkere stille Annahmen. **Pyramidale/abhängige Pausen** (Pause abhängig von vorheriger Belastung) sind **nicht Teil des aktuellen Umsetzungspfads**, können später als eigener Untertyp ergänzt werden.
|
||||
|
||||
**Fortschritt pro Slot (Stand 0.8.106):** optional **`advance_mode`** je Eintrag in **`slot_profiles_v1`**: `timed` — Standard (`load_sec` = geplante Arbeitsdauer für Timer im Coach; fehlende Angabe entspricht `timed` ohne Sekundenfeld), **`rep`** — mengenorientiert (Zielzahl über **`consecutive_reps`**; keine verbindliche Arbeitsuhr), **`manual`** — coachgeführt (Fortschritt bewusst per Schritt später im Coach, optional Richtwert über **`consecutive_reps`**). **`intra_rep_rest_sec`** und **`transition_after_sec`** bleiben unabhängig vom Modus nutzbar. **`load_sec`** wird nur im Modus `timed` persistiert.
|
||||
|
||||
### 6.4 Slot- und Pool-Logik
|
||||
|
||||
Slots können fest oder variabel sein.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.105"
|
||||
APP_VERSION = "0.8.106"
|
||||
BUILD_DATE = "2026-05-12"
|
||||
DB_SCHEMA_VERSION = "20260512057"
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ MODULE_VERSIONS = {
|
|||
"groups": "0.1.0",
|
||||
"skills": "0.1.0",
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.25.0", # Kombi: slot_profiles_v1 + Schnellwahl Belastung/Erholung; keine Nutzer‑JSON‑Pflicht; Übungsform Stationen vor Ablaufprofil
|
||||
"exercises": "2.26.0", # Kombi: advance_mode je Station (slot_profiles_v1: timed|rep|manual); Payload/Coach-Lesetext
|
||||
"training_units": "0.2.0",
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.9.2", # Kombi: planning_method_profile auf Sektions-Items (Migration 057); Form-Payload + Coach-PUT
|
||||
|
|
@ -35,6 +35,13 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.106",
|
||||
"date": "2026-05-12",
|
||||
"changes": [
|
||||
"Kombinationsübung: Stationssteuerung `advance_mode` in `slot_profiles_v1` (zeitlich / Ziel‑Wiederholungen / Coach ohne Arbeitsuhr); Übungsformular + Planungs‑Profil‑Editor; API‑Payload verwirft Arbeit‑Sekunden außer bei Zeitmodus; Coach zeigt verkürzte Planzeile je Station.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.105",
|
||||
"date": "2026-05-12",
|
||||
|
|
|
|||
|
|
@ -10,9 +10,28 @@ 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.')
|
||||
if (r.consecutive_reps != null) bits.push(`${r.consecutive_reps}×`)
|
||||
} else {
|
||||
bits.push('Coach')
|
||||
if (r.consecutive_reps != null) bits.push(`Richtwert ${r.consecutive_reps}×`)
|
||||
}
|
||||
if (r.intra_rep_rest_sec != null) bits.push(`Pause ${r.intra_rep_rest_sec}s`)
|
||||
if (r.transition_after_sec != null) bits.push(`Wechsel ${r.transition_after_sec}s`)
|
||||
return bits.join(' · ')
|
||||
}
|
||||
|
||||
export default function CombinationCoachSlots({ combinationSlots, methodArchetype, methodProfile }) {
|
||||
const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
|
||||
|
||||
const candidateIds = useMemo(() => {
|
||||
const set = new Set()
|
||||
|
|
@ -76,6 +95,23 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
|
|||
const archeKey = methodArchetype != null ? String(methodArchetype).trim() : ''
|
||||
const archDisplay = archeKey ? combinationArchetypeLabel(archeKey) : null
|
||||
|
||||
const slotTimingByIx = useMemo(() => {
|
||||
if (!methodProfile || typeof methodProfile !== 'object' || Array.isArray(methodProfile)) return new Map()
|
||||
const rows = readSlotProfilesV1(methodProfile)
|
||||
const m = new Map()
|
||||
for (const r of rows) {
|
||||
m.set(Number(r.slot_index), r)
|
||||
}
|
||||
return m
|
||||
}, [methodProfile])
|
||||
|
||||
const methodProfileKvSansSlots = useMemo(() => {
|
||||
if (!methodProfile || typeof methodProfile !== 'object' || Array.isArray(methodProfile)) return []
|
||||
return Object.entries(methodProfile)
|
||||
.filter(([k]) => k !== 'slot_profiles_v1')
|
||||
.sort(([a], [b]) => a.localeCompare(b, 'de'))
|
||||
}, [methodProfile])
|
||||
|
||||
return (
|
||||
<section
|
||||
className="card"
|
||||
|
|
@ -124,10 +160,13 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
|
|||
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: 'var(--text3)', textTransform: 'uppercase', marginBottom: '6px' }}>
|
||||
Geplantes Ablaufprofil (Katalog)
|
||||
</div>
|
||||
{methodProfileKvSansSlots.length === 0 ? (
|
||||
<p style={{ margin: 0, fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.42 }}>
|
||||
Nur stationsbezogene Daten (Zeiten/Zähl‑Steuerung) — siehe je Station unter der Überschrift „Plan:“.
|
||||
</p>
|
||||
) : (
|
||||
<dl style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.45 }}>
|
||||
{Object.entries(methodProfile)
|
||||
.sort(([a], [b]) => a.localeCompare(b, 'de'))
|
||||
.map(([k, val]) => (
|
||||
{methodProfileKvSansSlots.map(([k, val]) => (
|
||||
<div key={k} style={{ marginBottom: '4px', display: 'grid', gridTemplateColumns: 'minmax(72px,1fr) minmax(0,2fr)', gap: '6px 10px' }}>
|
||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)', wordBreak: 'break-all' }}>{k}</dt>
|
||||
<dd style={{ margin: 0, color: 'var(--text1)' }}>
|
||||
|
|
@ -144,6 +183,7 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
|
|||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -162,11 +202,19 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
|
|||
(candIds.length <= 1 && slot.candidates?.[0]?.title) ||
|
||||
`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))
|
||||
|
||||
return (
|
||||
<li key={`${slot.slot_index ?? si}-${slotTitle}`} style={{ lineHeight: 1.45 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: '0.92rem', marginBottom: candIds.length > 1 ? '6px' : '8px' }}>
|
||||
<div style={{ fontWeight: 700, fontSize: '0.92rem', marginBottom: timingSummary ? '4px' : candIds.length > 1 ? '6px' : '8px' }}>
|
||||
{slotTitle}
|
||||
</div>
|
||||
{timingSummary ? (
|
||||
<p style={{ margin: '0 0 8px', fontSize: '0.78rem', color: 'var(--text2)', lineHeight: 1.42 }}>
|
||||
Plan: <span style={{ color: 'var(--text1)', fontWeight: 600 }}>{timingSummary}</span>
|
||||
</p>
|
||||
) : null}
|
||||
{candIds.length === 0 ? (
|
||||
<p style={{ margin: 0, color: 'var(--text3)', fontSize: '0.86rem' }}>Keine Übung zugeordnet.</p>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import {
|
|||
patchMethodProfile,
|
||||
readSlotProfilesV1,
|
||||
patchSlotTimingField,
|
||||
patchSlotAdvanceMode,
|
||||
normalizeAdvanceMode,
|
||||
applyCircuitRotateQuickRatio,
|
||||
applyIntervalDomainQuickRatio,
|
||||
} from '../utils/combinationMethodProfileUi'
|
||||
|
|
@ -105,6 +107,15 @@ export default function CombinationMethodProfileEditor({
|
|||
setPresetHint(null)
|
||||
}
|
||||
|
||||
const onSlotAdvanceChange = (slotIx, rawMode) => {
|
||||
const patched = patchMethodProfile(methodProfileJson || '{}', (d) =>
|
||||
patchSlotAdvanceMode(d, slotIx, rawMode)
|
||||
)
|
||||
if (!patched.ok) return
|
||||
onChangeMethodProfileJson(patched.json)
|
||||
setPresetHint(null)
|
||||
}
|
||||
|
||||
const runCircuitPreset = (presetId) => {
|
||||
const r = patchMethodProfile(methodProfileJson || '{}', (draft) => {
|
||||
const pr = applyCircuitRotateQuickRatio(draft, presetId)
|
||||
|
|
@ -265,12 +276,12 @@ export default function CombinationMethodProfileEditor({
|
|||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.88rem', fontWeight: 700, marginBottom: '10px', color: 'var(--text1)' }}>
|
||||
Pro Station / Slot (Zeiten in Sekunden)
|
||||
Pro Station / Slot — Steuerung & Sekunden
|
||||
</div>
|
||||
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '0 0 12px', lineHeight: 1.45 }}>
|
||||
Belastungsdauer, wie oft die Übung an der gleichen Station hintereinander, kurze Pause dazwischen, Zeit bis
|
||||
zur nächsten Station. Felder können leer bleiben — z. B. nutzt der Zirkel oben erst die globalen Arbeit‑/
|
||||
Rotations‑Sekunden.
|
||||
<strong>Steuerung:</strong> zeitlich (Arbeits‑Countdown), Zielzahl Wiederholungen oder Coach‑geführt ohne
|
||||
Arbeitsuhr. Pausen/Wechsel bleiben unabhängig planbar. Felder können leer bleiben — z. B. nutzt der
|
||||
Zirkel erst die globalen Arbeit‑Sekunden.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{outlineSorted.map((slot) => {
|
||||
|
|
@ -280,6 +291,9 @@ export default function CombinationMethodProfileEditor({
|
|||
if (!Number.isFinite(si)) return null
|
||||
const row = lookupSlotTiming(si)
|
||||
const ttl = ((slot.title || '').trim() || `Station ${si}`).trim()
|
||||
const slotAdv = normalizeAdvanceMode(row.advance_mode)
|
||||
const serieLabel =
|
||||
slotAdv === 'timed' ? 'Wdh. ohne Wechsel' : slotAdv === 'rep' ? 'Ziel‑Wdh.' : 'Richtwert'
|
||||
return (
|
||||
<div
|
||||
key={`slot-timing-${si}`}
|
||||
|
|
@ -294,14 +308,30 @@ export default function CombinationMethodProfileEditor({
|
|||
Station {si}
|
||||
<span style={{ fontWeight: 400, marginLeft: 8, color: 'var(--text2)', fontSize: '0.86rem' }}>{ttl}</span>
|
||||
</div>
|
||||
<div className="form-row" style={{ marginBottom: '10px', maxWidth: '22rem' }}>
|
||||
<label className="form-label" style={{ fontSize: '11px' }}>
|
||||
Steuerung
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ fontSize: '0.8125rem' }}
|
||||
value={slotAdv}
|
||||
onChange={(e) => onSlotAdvanceChange(si, e.target.value)}
|
||||
>
|
||||
<option value="timed">Zeit (Arbeit in Sekunden)</option>
|
||||
<option value="rep">Wiederholungen (Ziel)</option>
|
||||
<option value="manual">Coach (ohne Arbeitsuhr)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: '10px',
|
||||
alignItems: 'end',
|
||||
}}
|
||||
>
|
||||
{slotAdv === 'timed' ? (
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label" style={{ fontSize: '11px' }}>
|
||||
Belastung (s)
|
||||
|
|
@ -315,15 +345,16 @@ export default function CombinationMethodProfileEditor({
|
|||
onChange={(e) => onSlotField(si, 'load_sec', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label" style={{ fontSize: '11px' }}>
|
||||
Wdh. ohne Wechsel
|
||||
{serieLabel}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
min={slotAdv === 'rep' ? 1 : undefined}
|
||||
className="form-input"
|
||||
placeholder="oft 1"
|
||||
placeholder={slotAdv === 'manual' ? 'optional' : 'oft 1'}
|
||||
value={row.consecutive_reps != null ? String(row.consecutive_reps) : ''}
|
||||
onChange={(e) => onSlotField(si, 'consecutive_reps', e.target.value)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
|
|||
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { COMBINATION_ARCHETYPE_OPTIONS } from '../constants/combinationArchetypes'
|
||||
import { readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
|
||||
import { readSlotProfilesV1, normalizeAdvanceMode } from '../utils/combinationMethodProfileUi'
|
||||
import { GripVertical } from 'lucide-react'
|
||||
|
||||
const INTENSITY_OPTIONS = [
|
||||
|
|
@ -38,11 +38,23 @@ const VARIANT_DIFFICULTY = [
|
|||
/** HTML5-DnD für Kombi-Stationen (Reihenfolge = Ablauf). */
|
||||
const DND_EXERCISE_COMBO_STATION = 'application/x-shinkan-exercise-combo-station-v1'
|
||||
|
||||
/** Pro Station meist 1 Übung; bis zu 3 wenn kurzer Auswahl-Pool sinnvoll ist. */
|
||||
const MAX_COMBO_CANDIDATES_PER_STATION = 3
|
||||
|
||||
const comboTinyNumberInputSx = {
|
||||
width: '3.5rem',
|
||||
maxWidth: '100%',
|
||||
padding: '4px 6px',
|
||||
fontSize: '0.8125rem',
|
||||
textAlign: 'center',
|
||||
}
|
||||
|
||||
function emptyComboSlotRow() {
|
||||
return {
|
||||
title: '',
|
||||
candidate_exercise_ids: [],
|
||||
exercise_title_by_id: {},
|
||||
advance_mode: 'timed',
|
||||
load_sec: '',
|
||||
consecutive_reps: '',
|
||||
intra_rep_rest_sec: '',
|
||||
|
|
@ -75,6 +87,7 @@ function comboSlotsFromDetail(exercise) {
|
|||
title: s.title != null ? String(s.title) : '',
|
||||
candidate_exercise_ids: cands,
|
||||
exercise_title_by_id: {},
|
||||
advance_mode: normalizeAdvanceMode(st.advance_mode),
|
||||
load_sec: st.load_sec != null ? String(st.load_sec) : '',
|
||||
consecutive_reps: st.consecutive_reps != null ? String(st.consecutive_reps) : '',
|
||||
intra_rep_rest_sec: st.intra_rep_rest_sec != null ? String(st.intra_rep_rest_sec) : '',
|
||||
|
|
@ -651,22 +664,38 @@ function ExerciseFormPage() {
|
|||
|
||||
const mergePickedExercisesIntoSlot = (slotIdx, pickedList) => {
|
||||
if (!Array.isArray(pickedList) || !pickedList.length) return
|
||||
const rowNow = (formData.combination_slots || [])[slotIdx] || emptyComboSlotRow()
|
||||
const existingIds = Array.isArray(rowNow.candidate_exercise_ids)
|
||||
? rowNow.candidate_exercise_ids.map((n) => Number(n)).filter((n) => Number.isFinite(n))
|
||||
: []
|
||||
const ordered = [...existingIds]
|
||||
pickedList.forEach((ex) => {
|
||||
if (ex?.id == null) return
|
||||
const id = Number(ex.id)
|
||||
if (!Number.isFinite(id)) return
|
||||
if (!ordered.includes(id)) ordered.push(id)
|
||||
})
|
||||
let nextIds = ordered
|
||||
if (nextIds.length > MAX_COMBO_CANDIDATES_PER_STATION) {
|
||||
window.alert(
|
||||
`Pro Station höchstens ${MAX_COMBO_CANDIDATES_PER_STATION} Übungen — üblich eine feste Übung; zwei bis drei nur als kleiner Wechsel‑Pool. Überschüssige Auswahl wurde abgeschnitten.`,
|
||||
)
|
||||
nextIds = nextIds.slice(0, MAX_COMBO_CANDIDATES_PER_STATION)
|
||||
}
|
||||
setFormDirty(true)
|
||||
setFormData((prev) => {
|
||||
const rows = [...(prev.combination_slots || [])]
|
||||
const row = rows[slotIdx] || emptyComboSlotRow()
|
||||
const nextSet = new Set((row.candidate_exercise_ids || []).map((n) => Number(n)))
|
||||
const labels =
|
||||
row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
|
||||
pickedList.forEach((ex) => {
|
||||
if (ex && ex.id != null) {
|
||||
const id = Number(ex.id)
|
||||
nextSet.add(id)
|
||||
const t = (ex.title || '').trim()
|
||||
if (t) labels[id] = t
|
||||
}
|
||||
})
|
||||
rows[slotIdx] = { ...row, candidate_exercise_ids: [...nextSet], exercise_title_by_id: labels }
|
||||
rows[slotIdx] = { ...row, candidate_exercise_ids: nextIds, exercise_title_by_id: labels }
|
||||
return { ...prev, combination_slots: rows }
|
||||
})
|
||||
}
|
||||
|
|
@ -1154,16 +1183,22 @@ function ExerciseFormPage() {
|
|||
) : null}
|
||||
<div style={{ paddingTop: '4px', borderTop: '1px dashed var(--border)', marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<strong style={{ fontSize: '14px' }}>Stationen und Übungs‑Pool</strong>
|
||||
<strong style={{ fontSize: '14px' }}>Stationen</strong>
|
||||
<span style={{ fontSize: '11px', color: 'var(--text3)' }}>
|
||||
Reihenfolge = Ablauf · ziehen oder Pfeile · nur Einzelübungen wählbar
|
||||
Ablauf = Reihenfolge · ziehen / Pfeile · Einzelübungen · max. {MAX_COMBO_CANDIDATES_PER_STATION}/Station
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '12px', color: 'var(--text2)', margin: '6px 0 12px', lineHeight: 1.48 }}>
|
||||
Jede Station: Titel (optional), am Ort wählbare <strong>Einzelübungen</strong> sowie die typischen Zeiten für genau diese Station (Belastungsdauer, Wiederholungsbündel, Pausen).
|
||||
Pro Station oft <strong>eine</strong> feste Übung; höchstens <strong>drei</strong> als kleiner Auswahl‑Pool.
|
||||
Unter <strong>Steuerung</strong> wählen: zeitlich, nach Wiederholungszahl oder ohne Arbeitsuhr (Coach führt).
|
||||
</p>
|
||||
{(formData.combination_slots || []).map((row, idx) => {
|
||||
const candIds = Array.isArray(row.candidate_exercise_ids) ? row.candidate_exercise_ids : []
|
||||
const comboPoolFull = candIds.length >= MAX_COMBO_CANDIDATES_PER_STATION
|
||||
const slotAdv = normalizeAdvanceMode(row.advance_mode)
|
||||
const serieLabel =
|
||||
slotAdv === 'timed' ? 'Serie' : slotAdv === 'rep' ? 'Ziel‑Wdh.' : 'Richtwert'
|
||||
const seriePlaceholder = slotAdv === 'rep' ? '10' : slotAdv === 'manual' ? '–' : '1'
|
||||
const lbl =
|
||||
row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object'
|
||||
? row.exercise_title_by_id
|
||||
|
|
@ -1234,13 +1269,13 @@ function ExerciseFormPage() {
|
|||
</div>
|
||||
<div className="form-row" style={{ flex: '1 1 200px', marginBottom: 0 }}>
|
||||
<label className="form-label" style={{ fontSize: '12px' }}>
|
||||
Station {idx + 1} — Titel
|
||||
Name (St. {idx + 1})
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={row.title || ''}
|
||||
placeholder={`z. B. Station ${idx + 1}`}
|
||||
placeholder="z. B. Liegestütz"
|
||||
onChange={(e) => patchComboSlotRow(idx, { title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1248,31 +1283,39 @@ function ExerciseFormPage() {
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
disabled={comboPoolFull}
|
||||
title={
|
||||
comboPoolFull
|
||||
? `Max. ${MAX_COMBO_CANDIDATES_PER_STATION} Übungen — eine entfernen, um weitere zu wählen.`
|
||||
: 'Einzelübung zur Station hinzufügen'
|
||||
}
|
||||
onClick={() => setComboStationPickerIx(idx)}
|
||||
>
|
||||
Einzelübungen wählen…
|
||||
+ Übung
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn framework-ctrl framework-ctrl--xs"
|
||||
style={{ fontSize: '12px' }}
|
||||
title="Station entfernen"
|
||||
title="Diese Station entfernen"
|
||||
onClick={() => {
|
||||
const prev = formData.combination_slots || []
|
||||
const next = prev.filter((_, j) => j !== idx)
|
||||
updateFormField('combination_slots', next.length ? next : [emptyComboSlotRow()])
|
||||
}}
|
||||
>
|
||||
Station entfernen
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<span className="form-label" style={{ fontSize: '11px', display: 'block', marginBottom: '6px' }}>
|
||||
Gewählte Einzelübungen (Pool für diese Station)
|
||||
Übungen ({candIds.length}/{MAX_COMBO_CANDIDATES_PER_STATION})
|
||||
</span>
|
||||
{candIds.length === 0 ? (
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>Noch keine Übung gewählt — mindestens eine erforderlich zum Speichern.</p>
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
|
||||
Mindestens eine Übung — mit „+ Übung“ wählen.
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{candIds.map((id) => (
|
||||
|
|
@ -1306,62 +1349,95 @@ function ExerciseFormPage() {
|
|||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row" style={{ marginBottom: '8px', maxWidth: '22rem' }}>
|
||||
<label className="form-label" style={{ fontSize: '11px' }}>
|
||||
Steuerung
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ fontSize: '0.8125rem' }}
|
||||
value={slotAdv}
|
||||
onChange={(e) => {
|
||||
const m = normalizeAdvanceMode(e.target.value)
|
||||
const patch = { advance_mode: m }
|
||||
if (m !== 'timed') patch.load_sec = ''
|
||||
patchComboSlotRow(idx, patch)
|
||||
}}
|
||||
>
|
||||
<option value="timed">Zeit (Arbeit in Sekunden)</option>
|
||||
<option value="rep">Wiederholungen (Ziel)</option>
|
||||
<option value="manual">Coach (Weiter nach Freigabe)</option>
|
||||
</select>
|
||||
</div>
|
||||
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '0 0 10px', lineHeight: 1.42 }}>
|
||||
{slotAdv === 'timed'
|
||||
? 'Arbeit (s): geplantes Ende nach Countdown möglich. Serie: Wiederholungen ohne Stationswechsel innerhalb einer Phase.'
|
||||
: slotAdv === 'rep'
|
||||
? 'Ohne Pflicht-Arbeits-Timer: Ziel über Wiederholungen; Pause/Wechsel können weiter automatisch unterstützt werden.'
|
||||
: 'Ohne feste Arbeitsuhr auf dieser Station — Fortschritt im Coach später per Tippschritt; Pause/Wechsel optional weiter mit Sekunden.'}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||
gap: '10px',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(5.25rem, 1fr))',
|
||||
gap: '8px 10px',
|
||||
alignItems: 'end',
|
||||
}}
|
||||
>
|
||||
{slotAdv === 'timed' ? (
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label" style={{ fontSize: '11px' }}>
|
||||
Belastung an Station (s)
|
||||
<label className="form-label" style={{ fontSize: '10px' }}>
|
||||
Arbeit (s)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="form-input"
|
||||
placeholder="optional"
|
||||
style={comboTinyNumberInputSx}
|
||||
placeholder="–"
|
||||
value={row.load_sec || ''}
|
||||
onChange={(e) => patchComboSlotRow(idx, { load_sec: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label" style={{ fontSize: '11px' }}>
|
||||
Wdh. ohne Wechsel
|
||||
<label className="form-label" style={{ fontSize: '10px' }}>
|
||||
{serieLabel}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
min={slotAdv === 'rep' ? 1 : undefined}
|
||||
className="form-input"
|
||||
placeholder="oft 1"
|
||||
style={comboTinyNumberInputSx}
|
||||
placeholder={seriePlaceholder}
|
||||
value={row.consecutive_reps || ''}
|
||||
onChange={(e) => patchComboSlotRow(idx, { consecutive_reps: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label" style={{ fontSize: '11px' }}>
|
||||
Pause zwischen Wdh. (s)
|
||||
<label className="form-label" style={{ fontSize: '10px' }}>
|
||||
Pause (s)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="form-input"
|
||||
placeholder="optional"
|
||||
style={comboTinyNumberInputSx}
|
||||
placeholder="–"
|
||||
value={row.intra_rep_rest_sec || ''}
|
||||
onChange={(e) => patchComboSlotRow(idx, { intra_rep_rest_sec: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label" style={{ fontSize: '11px' }}>
|
||||
Pause / nächste Station (s)
|
||||
<label className="form-label" style={{ fontSize: '10px' }}>
|
||||
Wechsel (s)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="form-input"
|
||||
placeholder="optional"
|
||||
style={comboTinyNumberInputSx}
|
||||
placeholder="–"
|
||||
value={row.transition_after_sec || ''}
|
||||
onChange={(e) => patchComboSlotRow(idx, { transition_after_sec: e.target.value })}
|
||||
/>
|
||||
|
|
@ -1403,11 +1479,11 @@ function ExerciseFormPage() {
|
|||
style={{ fontSize: '12px', marginTop: '4px' }}
|
||||
onClick={() => updateFormField('combination_slots', [...(formData.combination_slots || []), emptyComboSlotRow()])}
|
||||
>
|
||||
+ Station hinzufügen
|
||||
+ Station
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Ablaufprofil (Globale Zeiten & Gesamtdurchläufe)</label>
|
||||
<label className="form-label">Ablaufprofil (Runden & global)</label>
|
||||
<CombinationMethodProfileEditor
|
||||
methodArchetype={formData.method_archetype || ''}
|
||||
methodProfileJson={formData.method_profile_json || '{}'}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import { stripHtmlToText } from './htmlUtils'
|
||||
import { normalizeAdvanceMode } from './combinationMethodProfileUi'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
|
|
@ -541,11 +542,13 @@ export function buildExerciseApiPayload(formData, extras = {}) {
|
|||
for (let i = 0; i < slotRows.length; i += 1) {
|
||||
const row = slotRows[i] || {}
|
||||
const o = { slot_index: i }
|
||||
const advanceMode = normalizeAdvanceMode(row.advance_mode)
|
||||
if (advanceMode !== 'timed') o.advance_mode = advanceMode
|
||||
const load = parseTimingField(row.load_sec)
|
||||
const crs = parseTimingField(row.consecutive_reps)
|
||||
const intra = parseTimingField(row.intra_rep_rest_sec)
|
||||
const tran = parseTimingField(row.transition_after_sec)
|
||||
if (load !== undefined && load >= 0) o.load_sec = Math.round(load)
|
||||
if (advanceMode === 'timed' && load !== undefined && load >= 0) o.load_sec = Math.round(load)
|
||||
if (crs !== undefined && crs >= 1) o.consecutive_reps = Math.round(crs)
|
||||
if (intra !== undefined && intra >= 0) o.intra_rep_rest_sec = Math.round(intra)
|
||||
if (tran !== undefined && tran >= 0) o.transition_after_sec = Math.round(tran)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,16 @@
|
|||
|
||||
const INT_MAX = 86400
|
||||
|
||||
/** Pro Station: zeitlich (Standard), mengenorientiert oder coachgeführt (ohne Arbeits-Countdown). */
|
||||
export const SLOT_ADVANCE_MODES = Object.freeze(['timed', 'rep', 'manual'])
|
||||
|
||||
export function normalizeAdvanceMode(v) {
|
||||
const s = typeof v === 'string' ? v.trim().toLowerCase() : ''
|
||||
if (s === 'rep' || s === 'reps' || s === 'count') return 'rep'
|
||||
if (s === 'manual' || s === 'coach' || s === 'coach_led') return 'manual'
|
||||
return 'timed'
|
||||
}
|
||||
|
||||
function parseProfileJson(raw) {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return { ok: true, obj: {} }
|
||||
try {
|
||||
|
|
@ -209,13 +219,16 @@ export function readSlotProfilesV1(profileObj) {
|
|||
return raw.map((row) => {
|
||||
if (!row || typeof row !== 'object') return null
|
||||
const si = Number(row.slot_index)
|
||||
return {
|
||||
const mode = normalizeAdvanceMode(row.advance_mode)
|
||||
const out = {
|
||||
slot_index: Number.isFinite(si) ? si : 0,
|
||||
advance_mode: mode,
|
||||
load_sec: normalizeOptionalNonNegInt(row.load_sec),
|
||||
consecutive_reps: normalizeOptionalPositiveInt(row.consecutive_reps),
|
||||
intra_rep_rest_sec: normalizeOptionalNonNegInt(row.intra_rep_rest_sec),
|
||||
transition_after_sec: normalizeOptionalNonNegInt(row.transition_after_sec),
|
||||
}
|
||||
return out
|
||||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
|
|
@ -240,6 +253,55 @@ const SLOT_TIMING_FIELDS = /** @type {const} */ ([
|
|||
'transition_after_sec',
|
||||
])
|
||||
|
||||
function slotProfileRowShouldKeep(nextRow) {
|
||||
if (!nextRow || typeof nextRow !== 'object') return false
|
||||
const mode = normalizeAdvanceMode(nextRow.advance_mode)
|
||||
if (mode !== 'timed') return true
|
||||
return SLOT_TIMING_FIELDS.some((k) => nextRow[k] !== undefined && nextRow[k] !== null)
|
||||
}
|
||||
|
||||
function writeSlotProfilesV1Arr(profileDraft, arr) {
|
||||
const sorted = [...arr].sort((a, b) => Number(a.slot_index) - Number(b.slot_index))
|
||||
if (sorted.length === 0) delete profileDraft.slot_profiles_v1
|
||||
else profileDraft.slot_profiles_v1 = sorted
|
||||
}
|
||||
|
||||
/** Steuert Ende der Arbeitsphase: Zeit, Wiederholungsziel oder nur manuell weiter. */
|
||||
export function patchSlotAdvanceMode(profileDraft, slotIndex, modeRaw) {
|
||||
const ix =
|
||||
typeof slotIndex === 'number' && Number.isFinite(slotIndex)
|
||||
? slotIndex
|
||||
: parseInt(String(slotIndex), 10)
|
||||
if (!Number.isFinite(ix)) return
|
||||
|
||||
const mode = normalizeAdvanceMode(modeRaw)
|
||||
let arr = Array.isArray(profileDraft.slot_profiles_v1) ? [...profileDraft.slot_profiles_v1] : []
|
||||
const found = arr.findIndex((r) => r && typeof r === 'object' && Number(r.slot_index) === ix)
|
||||
|
||||
const nextRow = {}
|
||||
if (found >= 0 && arr[found] && typeof arr[found] === 'object') {
|
||||
Object.assign(nextRow, arr[found])
|
||||
}
|
||||
nextRow.slot_index = ix
|
||||
if (mode === 'timed') delete nextRow.advance_mode
|
||||
else {
|
||||
nextRow.advance_mode = mode
|
||||
delete nextRow.load_sec
|
||||
}
|
||||
|
||||
let nextArr
|
||||
if (!slotProfileRowShouldKeep(nextRow)) {
|
||||
nextArr = found >= 0 ? arr.filter((_, i) => i !== found) : arr
|
||||
} else if (found >= 0) {
|
||||
nextArr = [...arr]
|
||||
nextArr[found] = nextRow
|
||||
} else {
|
||||
nextArr = [...arr, nextRow]
|
||||
}
|
||||
|
||||
writeSlotProfilesV1Arr(profileDraft, nextArr)
|
||||
}
|
||||
|
||||
/** '', null = Feld entfernen; sonst gültige Zahl setzen */
|
||||
export function patchSlotTimingField(profileDraft, slotIndex, field, rawInput) {
|
||||
if (!SLOT_TIMING_FIELDS.includes(field)) return
|
||||
|
|
@ -270,22 +332,19 @@ export function patchSlotTimingField(profileDraft, slotIndex, field, rawInput) {
|
|||
else nextRow[field] = n
|
||||
}
|
||||
|
||||
const hasTiming = SLOT_TIMING_FIELDS.some((k) => nextRow[k] !== undefined && nextRow[k] !== null)
|
||||
const keep = slotProfileRowShouldKeep(nextRow)
|
||||
|
||||
if (found >= 0) {
|
||||
if (!hasTiming) {
|
||||
if (!keep) {
|
||||
arr = arr.filter((_, i) => i !== found)
|
||||
} else {
|
||||
arr[found] = nextRow
|
||||
}
|
||||
} else if (hasTiming) {
|
||||
} else if (keep) {
|
||||
arr.push(nextRow)
|
||||
}
|
||||
|
||||
arr.sort((a, b) => Number(a.slot_index) - Number(b.slot_index))
|
||||
|
||||
if (arr.length === 0) delete profileDraft.slot_profiles_v1
|
||||
else profileDraft.slot_profiles_v1 = arr
|
||||
writeSlotProfilesV1Arr(profileDraft, arr)
|
||||
}
|
||||
|
||||
/** Rotierender Zirkel: typische Ableitungen (setzt Sekunden konkret). */
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user