Skip to content

Commit bb64b62

Browse files
authored
feat: power ramp intervals in workout editor (W and % FTP) (#4809)
1 parent 5af8046 commit bb64b62

5 files changed

Lines changed: 267 additions & 61 deletions

File tree

src/inner_templates/workouteditor/workout-editor-app.js

Lines changed: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131
{ key: 'resistance', labelKey: 'workoutEditor.resistance', label: 'Resistance', type: 'number', step: 1, min: 0, max: 100, group: 'basic', devices: ['bike', 'elliptical'], defaultValue: 20 },
3232
{ key: 'cadence', labelKey: 'workoutEditor.cadence', label: 'Cadence', type: 'number', unitSuffix: 'rpm', min: 0, max: 240, group: 'basic', devices: ['bike', 'elliptical', 'rower'], defaultValue: 80 },
3333
{ key: 'power', labelKey: 'workoutEditor.power', label: 'Power', type: 'number', unitSuffix: 'W', min: 0, max: 2000, group: 'basic', devices: ['bike', 'rower'], defaultValue: 150 },
34+
{ key: 'powerrampunit', labelKey: 'workoutEditor.powerRampUnit', label: 'Ramp Unit', type: 'select', options: ['W', '% FTP'], group: 'advanced', devices: ['bike', 'rower'], defaultValue: 'W', noToggle: true },
35+
{ key: 'powerfrom', labelKey: 'workoutEditor.powerRampFrom', label: 'Ramp From', type: 'number', min: 0, max: 2000, group: 'advanced', devices: ['bike', 'rower'], defaultValue: 100 },
36+
{ key: 'powerto', labelKey: 'workoutEditor.powerRampTo', label: 'Ramp To', type: 'number', min: 0, max: 2000, group: 'advanced', devices: ['bike', 'rower'], defaultValue: 200 },
3437
{ key: 'forcespeed', labelKey: 'workoutEditor.forceSpeed', label: 'Force Speed', type: 'bool', group: 'basic', devices: ['treadmill'], linkedTo: 'speed' },
3538
{ key: 'fanspeed', labelKey: 'workoutEditor.fan', label: 'Fan', type: 'number', min: 0, max: 8, group: 'advanced', devices: 'all', defaultValue: 0 },
3639
{ key: 'requested_peloton_resistance', labelKey: 'workoutEditor.pelotonResistance', label: 'Peloton Res.', type: 'number', min: -1, max: 100, group: 'advanced', devices: ['bike'] },
@@ -52,14 +55,14 @@
5255
bike: [
5356
{ key: 'resistance', label: () => t('workoutEditor.resistance', 'Resistance'), color: '#ab47bc', unit: () => 'lvl', axis: 'resistanceAxis', axisLabel: () => t('workoutEditor.resistance', 'Resistance'), axisPosition: 'left' },
5457
{ key: 'cadence', label: () => t('workoutEditor.cadence', 'Cadence'), color: '#29b6f6', unit: () => 'rpm', axis: 'cadenceAxis', axisLabel: () => t('workoutEditor.cadenceRpm', 'Cadence (rpm)'), axisPosition: 'right' },
55-
{ key: 'power', label: () => t('workoutEditor.power', 'Power'), color: '#ef6c00', unit: () => 'W', axis: 'powerAxis', axisLabel: () => t('workoutEditor.powerW', 'Power (W)'), axisPosition: 'left' }
58+
{ key: 'power', label: () => t('workoutEditor.power', 'Power'), color: '#ef6c00', unit: () => 'W', axis: 'powerAxis', axisLabel: () => t('workoutEditor.powerW', 'Power (W)'), axisPosition: 'left', stepped: false }
5659
],
5760
elliptical: [
5861
{ key: 'resistance', label: () => t('workoutEditor.resistance', 'Resistance'), color: '#7e57c2', unit: () => 'lvl', axis: 'resistanceAxis', axisLabel: () => t('workoutEditor.resistance', 'Resistance'), axisPosition: 'left' },
5962
{ key: 'inclination', label: () => t('workoutEditor.ramp', 'Ramp'), color: '#66bb6a', unit: () => '%', axis: 'inclineAxis', axisLabel: () => t('workoutEditor.rampPercent', 'Ramp (%)'), axisPosition: 'right' }
6063
],
6164
rower: [
62-
{ key: 'power', label: () => t('workoutEditor.power', 'Power'), color: '#fb8c00', unit: () => 'W', axis: 'powerAxis', axisLabel: () => t('workoutEditor.powerW', 'Power (W)'), axisPosition: 'left' },
65+
{ key: 'power', label: () => t('workoutEditor.power', 'Power'), color: '#fb8c00', unit: () => 'W', axis: 'powerAxis', axisLabel: () => t('workoutEditor.powerW', 'Power (W)'), axisPosition: 'left', stepped: false },
6366
{ key: 'cadence', label: () => t('workoutEditor.strokeRate', 'Stroke Rate'), color: '#26a69a', unit: () => 'spm', axis: 'cadenceAxis', axisLabel: () => t('workoutEditor.strokesPerMinute', 'Strokes/min'), axisPosition: 'right' }
6467
],
6568
jumprope: [],
@@ -340,6 +343,9 @@
340343
return;
341344
}
342345
state.miles = !!content.miles;
346+
if (content.ftp !== undefined) {
347+
state.ftp = Number(content.ftp);
348+
}
343349
state.translations = content.translations || {};
344350
if (window.qzSetTranslations) {
345351
window.qzSetTranslations(state.translations);
@@ -611,6 +617,21 @@
611617
out['__enabled_' + def.key] = false;
612618
}
613619
});
620+
// FTP% ramp (from backend that collapsed the rows)
621+
if (row.powerzonefrom !== undefined && row.powerzonefrom !== null && Number(row.powerzonefrom) >= 0) {
622+
out.powerrampunit = '% FTP';
623+
out['__enabled_powerrampunit'] = true;
624+
out.powerfrom = Math.round(Number(row.powerzonefrom) * 100);
625+
out['__enabled_powerfrom'] = true;
626+
out.powerto = Math.round(Number(row.powerzoneto) * 100);
627+
out['__enabled_powerto'] = true;
628+
}
629+
// Watts ramp
630+
else if (row.powerfrom !== undefined && row.powerfrom !== null && Number(row.powerfrom) >= 0) {
631+
out.powerrampunit = 'W';
632+
out['__enabled_powerrampunit'] = true;
633+
// powerfrom/powerto already read by the FIELD_DEFS loop above
634+
}
614635
out.__enabled_duration = out.__enabled_distance === true ? false : true;
615636
out.__selected = false;
616637
return out;
@@ -800,8 +821,8 @@
800821
return;
801822
}
802823
const value = row[field.key];
803-
// For pace field, use speed's enabled state
804-
const isEnabled = field.syncWith ? (row['__enabled_' + field.syncWith] !== false) : (row['__enabled_' + field.key] !== false);
824+
// For pace field, use speed's enabled state; fields without a toggle are always enabled
825+
const isEnabled = field.noToggle ? true : (field.syncWith ? (row['__enabled_' + field.syncWith] !== false) : (row['__enabled_' + field.key] !== false));
805826
const fieldWrap = document.createElement('div');
806827
fieldWrap.className = 'field';
807828
if (!isEnabled) {
@@ -813,7 +834,7 @@
813834
const labelWrap = document.createElement('label');
814835
labelWrap.className = 'field-label';
815836

816-
const allowToggle = field.key !== 'name' && !field.linkedTo && !field.syncWith;
837+
const allowToggle = field.key !== 'name' && !field.linkedTo && !field.syncWith && !field.noToggle;
817838
if (allowToggle) {
818839
const enableCheckbox = document.createElement('input');
819840
enableCheckbox.type = 'checkbox';
@@ -859,7 +880,7 @@
859880
}
860881

861882
const labelText = document.createElement('span');
862-
labelText.textContent = resolveFieldLabel(field);
883+
labelText.textContent = resolveFieldLabel(field, row);
863884
labelWrap.appendChild(labelText);
864885
fieldWrap.appendChild(labelWrap);
865886

@@ -872,6 +893,24 @@
872893
checkbox.dataset.type = field.type;
873894
checkbox.addEventListener('change', handleFieldChange);
874895
fieldWrap.appendChild(checkbox);
896+
} else if (field.type === 'select') {
897+
const sel = document.createElement('select');
898+
sel.className = 'field-input field-select';
899+
(field.options || []).forEach(opt => {
900+
const option = document.createElement('option');
901+
option.value = opt;
902+
option.textContent = opt;
903+
const currentVal = String(row[field.key] !== undefined ? row[field.key] : (field.defaultValue !== undefined ? field.defaultValue : ''));
904+
if (currentVal === opt) {
905+
option.selected = true;
906+
}
907+
sel.appendChild(option);
908+
});
909+
sel.addEventListener('change', () => {
910+
row[field.key] = sel.value;
911+
renderIntervals();
912+
});
913+
fieldWrap.appendChild(sel);
875914
} else {
876915
const inputWrapper = document.createElement('div');
877916
inputWrapper.className = 'field-with-buttons';
@@ -946,7 +985,7 @@
946985
return Array.isArray(field.devices) && field.devices.indexOf(state.device) >= 0;
947986
}
948987

949-
function resolveFieldLabel(field) {
988+
function resolveFieldLabel(field, interval) {
950989
if (typeof field.label === 'function') {
951990
return field.label();
952991
}
@@ -960,6 +999,10 @@
960999
if (field.unitKey === 'pace') {
9611000
return `${label} (${state.miles ? 'min/mi' : 'min/km'})`;
9621001
}
1002+
if ((field.key === 'powerfrom' || field.key === 'powerto') && interval) {
1003+
const unit = interval.powerrampunit || 'W';
1004+
return `${label} (${unit})`;
1005+
}
9631006
if (field.unitSuffix) {
9641007
return `${label} (${field.unitSuffix})`;
9651008
}
@@ -1284,6 +1327,11 @@
12841327
return;
12851328
}
12861329

1330+
// Power ramp fields are handled separately after this loop
1331+
if (['powerrampunit', 'powerfrom', 'powerto'].includes(field.key)) {
1332+
return;
1333+
}
1334+
12871335
// Check if field is valid for current device type
12881336
if (!isFieldValidForDevice(field, state.device)) {
12891337
return;
@@ -1324,6 +1372,22 @@
13241372
row[field.key] = finalValue;
13251373
}
13261374
});
1375+
1376+
// Power ramp
1377+
const rampFromEnabled = interval['__enabled_powerfrom'] !== false;
1378+
const rampToEnabled = interval['__enabled_powerto'] !== false;
1379+
if (rampFromEnabled && rampToEnabled &&
1380+
isFieldValidForDevice({ devices: ['bike', 'rower'] }, state.device) &&
1381+
interval.powerfrom !== undefined && interval.powerto !== undefined) {
1382+
const unit = interval.powerrampunit || 'W';
1383+
if (unit === '% FTP') {
1384+
row.powerzonefrom = Number(interval.powerfrom) / 100;
1385+
row.powerzoneto = Number(interval.powerto) / 100;
1386+
} else {
1387+
row.powerfrom = Number(interval.powerfrom);
1388+
row.powerto = Number(interval.powerto);
1389+
}
1390+
}
13271391
list.push(row);
13281392
}
13291393
return {
@@ -1381,6 +1445,7 @@
13811445
axis: def.axis,
13821446
axisLabel: typeof def.axisLabel === 'function' ? def.axisLabel() : def.axisLabel,
13831447
axisPosition: def.axisPosition,
1448+
stepped: def.stepped !== false,
13841449
points: []
13851450
}));
13861451

@@ -1413,6 +1478,22 @@
14131478
});
14141479
rows.push(Object.assign({ start, durationSeconds: duration }, intervalCopy));
14151480
series.forEach(serie => {
1481+
// For the power series, check if we have a power ramp (powerfrom/powerto)
1482+
if (serie.key === 'power' &&
1483+
interval['__enabled_powerfrom'] !== false &&
1484+
interval['__enabled_powerto'] !== false &&
1485+
interval.powerfrom !== undefined && interval.powerto !== undefined) {
1486+
const rampUnit = interval.powerrampunit || 'W';
1487+
const fromVal = rampUnit === '% FTP'
1488+
? Number(interval.powerfrom) / 100 * (state.ftp || 200)
1489+
: Number(interval.powerfrom);
1490+
const toVal = rampUnit === '% FTP'
1491+
? Number(interval.powerto) / 100 * (state.ftp || 200)
1492+
: Number(interval.powerto);
1493+
serie.points.push({ x: start, y: fromVal });
1494+
serie.points.push({ x: end, y: toVal });
1495+
return;
1496+
}
14161497
// Skip disabled fields in the chart
14171498
const isEnabled = interval['__enabled_' + serie.key] !== false;
14181499
if (!isEnabled) {

src/inner_templates/workouteditor/workout-editor.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@
197197
backgroundColor: fillColor,
198198
unit: series.unit || '',
199199
yAxisID: series.axis || 'y',
200-
stepped: true,
200+
stepped: series.stepped !== false,
201201
borderWidth: series.lineWidth || 2,
202202
fill: Boolean(series.fill),
203203
tension: 0,

0 commit comments

Comments
 (0)