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.
|
**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
|
### 6.4 Slot- und Pool-Logik
|
||||||
|
|
||||||
Slots können fest oder variabel sein.
|
Slots können fest oder variabel sein.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.105"
|
APP_VERSION = "0.8.106"
|
||||||
BUILD_DATE = "2026-05-12"
|
BUILD_DATE = "2026-05-12"
|
||||||
DB_SCHEMA_VERSION = "20260512057"
|
DB_SCHEMA_VERSION = "20260512057"
|
||||||
|
|
||||||
|
|
@ -21,7 +21,7 @@ MODULE_VERSIONS = {
|
||||||
"groups": "0.1.0",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.0",
|
"skills": "0.1.0",
|
||||||
"methods": "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_units": "0.2.0",
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.9.2", # Kombi: planning_method_profile auf Sektions-Items (Migration 057); Form-Payload + Coach-PUT
|
"planning": "0.9.2", # Kombi: planning_method_profile auf Sektions-Items (Migration 057); Form-Payload + Coach-PUT
|
||||||
|
|
@ -35,6 +35,13 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.105",
|
||||||
"date": "2026-05-12",
|
"date": "2026-05-12",
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,28 @@ import {
|
||||||
combinationArchetypeLabel,
|
combinationArchetypeLabel,
|
||||||
sortCombinationSlotsForDisplay,
|
sortCombinationSlotsForDisplay,
|
||||||
} from '../constants/combinationArchetypes'
|
} 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 }) {
|
export default function CombinationCoachSlots({ combinationSlots, methodArchetype, methodProfile }) {
|
||||||
const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
|
|
||||||
|
|
||||||
const candidateIds = useMemo(() => {
|
const candidateIds = useMemo(() => {
|
||||||
const set = new Set()
|
const set = new Set()
|
||||||
|
|
@ -76,6 +95,23 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
|
||||||
const archeKey = methodArchetype != null ? String(methodArchetype).trim() : ''
|
const archeKey = methodArchetype != null ? String(methodArchetype).trim() : ''
|
||||||
const archDisplay = archeKey ? combinationArchetypeLabel(archeKey) : null
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
className="card"
|
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' }}>
|
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: 'var(--text3)', textTransform: 'uppercase', marginBottom: '6px' }}>
|
||||||
Geplantes Ablaufprofil (Katalog)
|
Geplantes Ablaufprofil (Katalog)
|
||||||
</div>
|
</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 }}>
|
<dl style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.45 }}>
|
||||||
{Object.entries(methodProfile)
|
{methodProfileKvSansSlots.map(([k, val]) => (
|
||||||
.sort(([a], [b]) => a.localeCompare(b, 'de'))
|
|
||||||
.map(([k, val]) => (
|
|
||||||
<div key={k} style={{ marginBottom: '4px', display: 'grid', gridTemplateColumns: 'minmax(72px,1fr) minmax(0,2fr)', gap: '6px 10px' }}>
|
<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>
|
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)', wordBreak: 'break-all' }}>{k}</dt>
|
||||||
<dd style={{ margin: 0, color: 'var(--text1)' }}>
|
<dd style={{ margin: 0, color: 'var(--text1)' }}>
|
||||||
|
|
@ -144,6 +183,7 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -162,11 +202,19 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
|
||||||
(candIds.length <= 1 && slot.candidates?.[0]?.title) ||
|
(candIds.length <= 1 && slot.candidates?.[0]?.title) ||
|
||||||
`Station ${slot.slot_index != null ? Number(slot.slot_index) + 1 : si + 1}`
|
`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 (
|
return (
|
||||||
<li key={`${slot.slot_index ?? si}-${slotTitle}`} style={{ lineHeight: 1.45 }}>
|
<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}
|
{slotTitle}
|
||||||
</div>
|
</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 ? (
|
{candIds.length === 0 ? (
|
||||||
<p style={{ margin: 0, color: 'var(--text3)', fontSize: '0.86rem' }}>Keine Übung zugeordnet.</p>
|
<p style={{ margin: 0, color: 'var(--text3)', fontSize: '0.86rem' }}>Keine Übung zugeordnet.</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import {
|
||||||
patchMethodProfile,
|
patchMethodProfile,
|
||||||
readSlotProfilesV1,
|
readSlotProfilesV1,
|
||||||
patchSlotTimingField,
|
patchSlotTimingField,
|
||||||
|
patchSlotAdvanceMode,
|
||||||
|
normalizeAdvanceMode,
|
||||||
applyCircuitRotateQuickRatio,
|
applyCircuitRotateQuickRatio,
|
||||||
applyIntervalDomainQuickRatio,
|
applyIntervalDomainQuickRatio,
|
||||||
} from '../utils/combinationMethodProfileUi'
|
} from '../utils/combinationMethodProfileUi'
|
||||||
|
|
@ -105,6 +107,15 @@ export default function CombinationMethodProfileEditor({
|
||||||
setPresetHint(null)
|
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 runCircuitPreset = (presetId) => {
|
||||||
const r = patchMethodProfile(methodProfileJson || '{}', (draft) => {
|
const r = patchMethodProfile(methodProfileJson || '{}', (draft) => {
|
||||||
const pr = applyCircuitRotateQuickRatio(draft, presetId)
|
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)' }}>
|
<div style={{ fontSize: '0.88rem', fontWeight: 700, marginBottom: '10px', color: 'var(--text1)' }}>
|
||||||
Pro Station / Slot (Zeiten in Sekunden)
|
Pro Station / Slot — Steuerung & Sekunden
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '0 0 12px', lineHeight: 1.45 }}>
|
<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
|
<strong>Steuerung:</strong> zeitlich (Arbeits‑Countdown), Zielzahl Wiederholungen oder Coach‑geführt ohne
|
||||||
zur nächsten Station. Felder können leer bleiben — z. B. nutzt der Zirkel oben erst die globalen Arbeit‑/
|
Arbeitsuhr. Pausen/Wechsel bleiben unabhängig planbar. Felder können leer bleiben — z. B. nutzt der
|
||||||
Rotations‑Sekunden.
|
Zirkel erst die globalen Arbeit‑Sekunden.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
{outlineSorted.map((slot) => {
|
{outlineSorted.map((slot) => {
|
||||||
|
|
@ -280,6 +291,9 @@ export default function CombinationMethodProfileEditor({
|
||||||
if (!Number.isFinite(si)) return null
|
if (!Number.isFinite(si)) return null
|
||||||
const row = lookupSlotTiming(si)
|
const row = lookupSlotTiming(si)
|
||||||
const ttl = ((slot.title || '').trim() || `Station ${si}`).trim()
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`slot-timing-${si}`}
|
key={`slot-timing-${si}`}
|
||||||
|
|
@ -294,14 +308,30 @@ export default function CombinationMethodProfileEditor({
|
||||||
Station {si}
|
Station {si}
|
||||||
<span style={{ fontWeight: 400, marginLeft: 8, color: 'var(--text2)', fontSize: '0.86rem' }}>{ttl}</span>
|
<span style={{ fontWeight: 400, marginLeft: 8, color: 'var(--text2)', fontSize: '0.86rem' }}>{ttl}</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||||
gap: '10px',
|
gap: '10px',
|
||||||
alignItems: 'end',
|
alignItems: 'end',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{slotAdv === 'timed' ? (
|
||||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
<label className="form-label" style={{ fontSize: '11px' }}>
|
<label className="form-label" style={{ fontSize: '11px' }}>
|
||||||
Belastung (s)
|
Belastung (s)
|
||||||
|
|
@ -315,15 +345,16 @@ export default function CombinationMethodProfileEditor({
|
||||||
onChange={(e) => onSlotField(si, 'load_sec', e.target.value)}
|
onChange={(e) => onSlotField(si, 'load_sec', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
<label className="form-label" style={{ fontSize: '11px' }}>
|
<label className="form-label" style={{ fontSize: '11px' }}>
|
||||||
Wdh. ohne Wechsel
|
{serieLabel}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={slotAdv === 'rep' ? 1 : undefined}
|
||||||
className="form-input"
|
className="form-input"
|
||||||
placeholder="oft 1"
|
placeholder={slotAdv === 'manual' ? 'optional' : 'oft 1'}
|
||||||
value={row.consecutive_reps != null ? String(row.consecutive_reps) : ''}
|
value={row.consecutive_reps != null ? String(row.consecutive_reps) : ''}
|
||||||
onChange={(e) => onSlotField(si, 'consecutive_reps', e.target.value)}
|
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 { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { COMBINATION_ARCHETYPE_OPTIONS } from '../constants/combinationArchetypes'
|
import { COMBINATION_ARCHETYPE_OPTIONS } from '../constants/combinationArchetypes'
|
||||||
import { readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
|
import { readSlotProfilesV1, normalizeAdvanceMode } from '../utils/combinationMethodProfileUi'
|
||||||
import { GripVertical } from 'lucide-react'
|
import { GripVertical } from 'lucide-react'
|
||||||
|
|
||||||
const INTENSITY_OPTIONS = [
|
const INTENSITY_OPTIONS = [
|
||||||
|
|
@ -38,11 +38,23 @@ const VARIANT_DIFFICULTY = [
|
||||||
/** HTML5-DnD für Kombi-Stationen (Reihenfolge = Ablauf). */
|
/** HTML5-DnD für Kombi-Stationen (Reihenfolge = Ablauf). */
|
||||||
const DND_EXERCISE_COMBO_STATION = 'application/x-shinkan-exercise-combo-station-v1'
|
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() {
|
function emptyComboSlotRow() {
|
||||||
return {
|
return {
|
||||||
title: '',
|
title: '',
|
||||||
candidate_exercise_ids: [],
|
candidate_exercise_ids: [],
|
||||||
exercise_title_by_id: {},
|
exercise_title_by_id: {},
|
||||||
|
advance_mode: 'timed',
|
||||||
load_sec: '',
|
load_sec: '',
|
||||||
consecutive_reps: '',
|
consecutive_reps: '',
|
||||||
intra_rep_rest_sec: '',
|
intra_rep_rest_sec: '',
|
||||||
|
|
@ -75,6 +87,7 @@ function comboSlotsFromDetail(exercise) {
|
||||||
title: s.title != null ? String(s.title) : '',
|
title: s.title != null ? String(s.title) : '',
|
||||||
candidate_exercise_ids: cands,
|
candidate_exercise_ids: cands,
|
||||||
exercise_title_by_id: {},
|
exercise_title_by_id: {},
|
||||||
|
advance_mode: normalizeAdvanceMode(st.advance_mode),
|
||||||
load_sec: st.load_sec != null ? String(st.load_sec) : '',
|
load_sec: st.load_sec != null ? String(st.load_sec) : '',
|
||||||
consecutive_reps: st.consecutive_reps != null ? String(st.consecutive_reps) : '',
|
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) : '',
|
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) => {
|
const mergePickedExercisesIntoSlot = (slotIdx, pickedList) => {
|
||||||
if (!Array.isArray(pickedList) || !pickedList.length) return
|
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)
|
setFormDirty(true)
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
const rows = [...(prev.combination_slots || [])]
|
const rows = [...(prev.combination_slots || [])]
|
||||||
const row = rows[slotIdx] || emptyComboSlotRow()
|
const row = rows[slotIdx] || emptyComboSlotRow()
|
||||||
const nextSet = new Set((row.candidate_exercise_ids || []).map((n) => Number(n)))
|
|
||||||
const labels =
|
const labels =
|
||||||
row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
|
row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
|
||||||
pickedList.forEach((ex) => {
|
pickedList.forEach((ex) => {
|
||||||
if (ex && ex.id != null) {
|
if (ex && ex.id != null) {
|
||||||
const id = Number(ex.id)
|
const id = Number(ex.id)
|
||||||
nextSet.add(id)
|
|
||||||
const t = (ex.title || '').trim()
|
const t = (ex.title || '').trim()
|
||||||
if (t) labels[id] = t
|
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 }
|
return { ...prev, combination_slots: rows }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1154,16 +1183,22 @@ function ExerciseFormPage() {
|
||||||
) : null}
|
) : null}
|
||||||
<div style={{ paddingTop: '4px', borderTop: '1px dashed var(--border)', marginBottom: '12px' }}>
|
<div style={{ paddingTop: '4px', borderTop: '1px dashed var(--border)', marginBottom: '12px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: '8px', flexWrap: 'wrap' }}>
|
<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)' }}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text2)', margin: '6px 0 12px', lineHeight: 1.48 }}>
|
<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>
|
</p>
|
||||||
{(formData.combination_slots || []).map((row, idx) => {
|
{(formData.combination_slots || []).map((row, idx) => {
|
||||||
const candIds = Array.isArray(row.candidate_exercise_ids) ? row.candidate_exercise_ids : []
|
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 =
|
const lbl =
|
||||||
row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object'
|
row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object'
|
||||||
? row.exercise_title_by_id
|
? row.exercise_title_by_id
|
||||||
|
|
@ -1234,13 +1269,13 @@ function ExerciseFormPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row" style={{ flex: '1 1 200px', marginBottom: 0 }}>
|
<div className="form-row" style={{ flex: '1 1 200px', marginBottom: 0 }}>
|
||||||
<label className="form-label" style={{ fontSize: '12px' }}>
|
<label className="form-label" style={{ fontSize: '12px' }}>
|
||||||
Station {idx + 1} — Titel
|
Name (St. {idx + 1})
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
value={row.title || ''}
|
value={row.title || ''}
|
||||||
placeholder={`z. B. Station ${idx + 1}`}
|
placeholder="z. B. Liegestütz"
|
||||||
onChange={(e) => patchComboSlotRow(idx, { title: e.target.value })}
|
onChange={(e) => patchComboSlotRow(idx, { title: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1248,31 +1283,39 @@ function ExerciseFormPage() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
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)}
|
onClick={() => setComboStationPickerIx(idx)}
|
||||||
>
|
>
|
||||||
Einzelübungen wählen…
|
+ Übung
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn framework-ctrl framework-ctrl--xs"
|
className="btn framework-ctrl framework-ctrl--xs"
|
||||||
style={{ fontSize: '12px' }}
|
style={{ fontSize: '12px' }}
|
||||||
title="Station entfernen"
|
title="Diese Station entfernen"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const prev = formData.combination_slots || []
|
const prev = formData.combination_slots || []
|
||||||
const next = prev.filter((_, j) => j !== idx)
|
const next = prev.filter((_, j) => j !== idx)
|
||||||
updateFormField('combination_slots', next.length ? next : [emptyComboSlotRow()])
|
updateFormField('combination_slots', next.length ? next : [emptyComboSlotRow()])
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Station entfernen
|
Entfernen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginBottom: '12px' }}>
|
<div style={{ marginBottom: '12px' }}>
|
||||||
<span className="form-label" style={{ fontSize: '11px', display: 'block', marginBottom: '6px' }}>
|
<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>
|
</span>
|
||||||
{candIds.length === 0 ? (
|
{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' }}>
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||||
{candIds.map((id) => (
|
{candIds.map((id) => (
|
||||||
|
|
@ -1306,62 +1349,95 @@ function ExerciseFormPage() {
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fill, minmax(5.25rem, 1fr))',
|
||||||
gap: '10px',
|
gap: '8px 10px',
|
||||||
alignItems: 'end',
|
alignItems: 'end',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{slotAdv === 'timed' ? (
|
||||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
<label className="form-label" style={{ fontSize: '11px' }}>
|
<label className="form-label" style={{ fontSize: '10px' }}>
|
||||||
Belastung an Station (s)
|
Arbeit (s)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
className="form-input"
|
className="form-input"
|
||||||
placeholder="optional"
|
style={comboTinyNumberInputSx}
|
||||||
|
placeholder="–"
|
||||||
value={row.load_sec || ''}
|
value={row.load_sec || ''}
|
||||||
onChange={(e) => patchComboSlotRow(idx, { load_sec: e.target.value })}
|
onChange={(e) => patchComboSlotRow(idx, { load_sec: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
<label className="form-label" style={{ fontSize: '11px' }}>
|
<label className="form-label" style={{ fontSize: '10px' }}>
|
||||||
Wdh. ohne Wechsel
|
{serieLabel}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={slotAdv === 'rep' ? 1 : undefined}
|
||||||
className="form-input"
|
className="form-input"
|
||||||
placeholder="oft 1"
|
style={comboTinyNumberInputSx}
|
||||||
|
placeholder={seriePlaceholder}
|
||||||
value={row.consecutive_reps || ''}
|
value={row.consecutive_reps || ''}
|
||||||
onChange={(e) => patchComboSlotRow(idx, { consecutive_reps: e.target.value })}
|
onChange={(e) => patchComboSlotRow(idx, { consecutive_reps: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
<label className="form-label" style={{ fontSize: '11px' }}>
|
<label className="form-label" style={{ fontSize: '10px' }}>
|
||||||
Pause zwischen Wdh. (s)
|
Pause (s)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
className="form-input"
|
className="form-input"
|
||||||
placeholder="optional"
|
style={comboTinyNumberInputSx}
|
||||||
|
placeholder="–"
|
||||||
value={row.intra_rep_rest_sec || ''}
|
value={row.intra_rep_rest_sec || ''}
|
||||||
onChange={(e) => patchComboSlotRow(idx, { intra_rep_rest_sec: e.target.value })}
|
onChange={(e) => patchComboSlotRow(idx, { intra_rep_rest_sec: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
<label className="form-label" style={{ fontSize: '11px' }}>
|
<label className="form-label" style={{ fontSize: '10px' }}>
|
||||||
Pause / nächste Station (s)
|
Wechsel (s)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
className="form-input"
|
className="form-input"
|
||||||
placeholder="optional"
|
style={comboTinyNumberInputSx}
|
||||||
|
placeholder="–"
|
||||||
value={row.transition_after_sec || ''}
|
value={row.transition_after_sec || ''}
|
||||||
onChange={(e) => patchComboSlotRow(idx, { transition_after_sec: e.target.value })}
|
onChange={(e) => patchComboSlotRow(idx, { transition_after_sec: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1403,11 +1479,11 @@ function ExerciseFormPage() {
|
||||||
style={{ fontSize: '12px', marginTop: '4px' }}
|
style={{ fontSize: '12px', marginTop: '4px' }}
|
||||||
onClick={() => updateFormField('combination_slots', [...(formData.combination_slots || []), emptyComboSlotRow()])}
|
onClick={() => updateFormField('combination_slots', [...(formData.combination_slots || []), emptyComboSlotRow()])}
|
||||||
>
|
>
|
||||||
+ Station hinzufügen
|
+ Station
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Ablaufprofil (Globale Zeiten & Gesamtdurchläufe)</label>
|
<label className="form-label">Ablaufprofil (Runden & global)</label>
|
||||||
<CombinationMethodProfileEditor
|
<CombinationMethodProfileEditor
|
||||||
methodArchetype={formData.method_archetype || ''}
|
methodArchetype={formData.method_archetype || ''}
|
||||||
methodProfileJson={formData.method_profile_json || '{}'}
|
methodProfileJson={formData.method_profile_json || '{}'}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { stripHtmlToText } from './htmlUtils'
|
import { stripHtmlToText } from './htmlUtils'
|
||||||
|
import { normalizeAdvanceMode } from './combinationMethodProfileUi'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
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) {
|
for (let i = 0; i < slotRows.length; i += 1) {
|
||||||
const row = slotRows[i] || {}
|
const row = slotRows[i] || {}
|
||||||
const o = { slot_index: 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 load = parseTimingField(row.load_sec)
|
||||||
const crs = parseTimingField(row.consecutive_reps)
|
const crs = parseTimingField(row.consecutive_reps)
|
||||||
const intra = parseTimingField(row.intra_rep_rest_sec)
|
const intra = parseTimingField(row.intra_rep_rest_sec)
|
||||||
const tran = parseTimingField(row.transition_after_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 (crs !== undefined && crs >= 1) o.consecutive_reps = Math.round(crs)
|
||||||
if (intra !== undefined && intra >= 0) o.intra_rep_rest_sec = Math.round(intra)
|
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)
|
if (tran !== undefined && tran >= 0) o.transition_after_sec = Math.round(tran)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,16 @@
|
||||||
|
|
||||||
const INT_MAX = 86400
|
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) {
|
function parseProfileJson(raw) {
|
||||||
if (typeof raw !== 'string' || !raw.trim()) return { ok: true, obj: {} }
|
if (typeof raw !== 'string' || !raw.trim()) return { ok: true, obj: {} }
|
||||||
try {
|
try {
|
||||||
|
|
@ -209,13 +219,16 @@ export function readSlotProfilesV1(profileObj) {
|
||||||
return raw.map((row) => {
|
return raw.map((row) => {
|
||||||
if (!row || typeof row !== 'object') return null
|
if (!row || typeof row !== 'object') return null
|
||||||
const si = Number(row.slot_index)
|
const si = Number(row.slot_index)
|
||||||
return {
|
const mode = normalizeAdvanceMode(row.advance_mode)
|
||||||
|
const out = {
|
||||||
slot_index: Number.isFinite(si) ? si : 0,
|
slot_index: Number.isFinite(si) ? si : 0,
|
||||||
|
advance_mode: mode,
|
||||||
load_sec: normalizeOptionalNonNegInt(row.load_sec),
|
load_sec: normalizeOptionalNonNegInt(row.load_sec),
|
||||||
consecutive_reps: normalizeOptionalPositiveInt(row.consecutive_reps),
|
consecutive_reps: normalizeOptionalPositiveInt(row.consecutive_reps),
|
||||||
intra_rep_rest_sec: normalizeOptionalNonNegInt(row.intra_rep_rest_sec),
|
intra_rep_rest_sec: normalizeOptionalNonNegInt(row.intra_rep_rest_sec),
|
||||||
transition_after_sec: normalizeOptionalNonNegInt(row.transition_after_sec),
|
transition_after_sec: normalizeOptionalNonNegInt(row.transition_after_sec),
|
||||||
}
|
}
|
||||||
|
return out
|
||||||
}).filter(Boolean)
|
}).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,6 +253,55 @@ const SLOT_TIMING_FIELDS = /** @type {const} */ ([
|
||||||
'transition_after_sec',
|
'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 */
|
/** '', null = Feld entfernen; sonst gültige Zahl setzen */
|
||||||
export function patchSlotTimingField(profileDraft, slotIndex, field, rawInput) {
|
export function patchSlotTimingField(profileDraft, slotIndex, field, rawInput) {
|
||||||
if (!SLOT_TIMING_FIELDS.includes(field)) return
|
if (!SLOT_TIMING_FIELDS.includes(field)) return
|
||||||
|
|
@ -270,22 +332,19 @@ export function patchSlotTimingField(profileDraft, slotIndex, field, rawInput) {
|
||||||
else nextRow[field] = n
|
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 (found >= 0) {
|
||||||
if (!hasTiming) {
|
if (!keep) {
|
||||||
arr = arr.filter((_, i) => i !== found)
|
arr = arr.filter((_, i) => i !== found)
|
||||||
} else {
|
} else {
|
||||||
arr[found] = nextRow
|
arr[found] = nextRow
|
||||||
}
|
}
|
||||||
} else if (hasTiming) {
|
} else if (keep) {
|
||||||
arr.push(nextRow)
|
arr.push(nextRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
arr.sort((a, b) => Number(a.slot_index) - Number(b.slot_index))
|
writeSlotProfilesV1Arr(profileDraft, arr)
|
||||||
|
|
||||||
if (arr.length === 0) delete profileDraft.slot_profiles_v1
|
|
||||||
else profileDraft.slot_profiles_v1 = arr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rotierender Zirkel: typische Ableitungen (setzt Sekunden konkret). */
|
/** Rotierender Zirkel: typische Ableitungen (setzt Sekunden konkret). */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user