|
31 | 31 | { key: 'resistance', labelKey: 'workoutEditor.resistance', label: 'Resistance', type: 'number', step: 1, min: 0, max: 100, group: 'basic', devices: ['bike', 'elliptical'], defaultValue: 20 }, |
32 | 32 | { key: 'cadence', labelKey: 'workoutEditor.cadence', label: 'Cadence', type: 'number', unitSuffix: 'rpm', min: 0, max: 240, group: 'basic', devices: ['bike', 'elliptical', 'rower'], defaultValue: 80 }, |
33 | 33 | { 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 }, |
34 | 37 | { key: 'forcespeed', labelKey: 'workoutEditor.forceSpeed', label: 'Force Speed', type: 'bool', group: 'basic', devices: ['treadmill'], linkedTo: 'speed' }, |
35 | 38 | { key: 'fanspeed', labelKey: 'workoutEditor.fan', label: 'Fan', type: 'number', min: 0, max: 8, group: 'advanced', devices: 'all', defaultValue: 0 }, |
36 | 39 | { key: 'requested_peloton_resistance', labelKey: 'workoutEditor.pelotonResistance', label: 'Peloton Res.', type: 'number', min: -1, max: 100, group: 'advanced', devices: ['bike'] }, |
|
52 | 55 | bike: [ |
53 | 56 | { key: 'resistance', label: () => t('workoutEditor.resistance', 'Resistance'), color: '#ab47bc', unit: () => 'lvl', axis: 'resistanceAxis', axisLabel: () => t('workoutEditor.resistance', 'Resistance'), axisPosition: 'left' }, |
54 | 57 | { 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 } |
56 | 59 | ], |
57 | 60 | elliptical: [ |
58 | 61 | { key: 'resistance', label: () => t('workoutEditor.resistance', 'Resistance'), color: '#7e57c2', unit: () => 'lvl', axis: 'resistanceAxis', axisLabel: () => t('workoutEditor.resistance', 'Resistance'), axisPosition: 'left' }, |
59 | 62 | { key: 'inclination', label: () => t('workoutEditor.ramp', 'Ramp'), color: '#66bb6a', unit: () => '%', axis: 'inclineAxis', axisLabel: () => t('workoutEditor.rampPercent', 'Ramp (%)'), axisPosition: 'right' } |
60 | 63 | ], |
61 | 64 | 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 }, |
63 | 66 | { key: 'cadence', label: () => t('workoutEditor.strokeRate', 'Stroke Rate'), color: '#26a69a', unit: () => 'spm', axis: 'cadenceAxis', axisLabel: () => t('workoutEditor.strokesPerMinute', 'Strokes/min'), axisPosition: 'right' } |
64 | 67 | ], |
65 | 68 | jumprope: [], |
|
340 | 343 | return; |
341 | 344 | } |
342 | 345 | state.miles = !!content.miles; |
| 346 | + if (content.ftp !== undefined) { |
| 347 | + state.ftp = Number(content.ftp); |
| 348 | + } |
343 | 349 | state.translations = content.translations || {}; |
344 | 350 | if (window.qzSetTranslations) { |
345 | 351 | window.qzSetTranslations(state.translations); |
|
611 | 617 | out['__enabled_' + def.key] = false; |
612 | 618 | } |
613 | 619 | }); |
| 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 | + } |
614 | 635 | out.__enabled_duration = out.__enabled_distance === true ? false : true; |
615 | 636 | out.__selected = false; |
616 | 637 | return out; |
|
800 | 821 | return; |
801 | 822 | } |
802 | 823 | 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)); |
805 | 826 | const fieldWrap = document.createElement('div'); |
806 | 827 | fieldWrap.className = 'field'; |
807 | 828 | if (!isEnabled) { |
|
813 | 834 | const labelWrap = document.createElement('label'); |
814 | 835 | labelWrap.className = 'field-label'; |
815 | 836 |
|
816 | | - const allowToggle = field.key !== 'name' && !field.linkedTo && !field.syncWith; |
| 837 | + const allowToggle = field.key !== 'name' && !field.linkedTo && !field.syncWith && !field.noToggle; |
817 | 838 | if (allowToggle) { |
818 | 839 | const enableCheckbox = document.createElement('input'); |
819 | 840 | enableCheckbox.type = 'checkbox'; |
|
859 | 880 | } |
860 | 881 |
|
861 | 882 | const labelText = document.createElement('span'); |
862 | | - labelText.textContent = resolveFieldLabel(field); |
| 883 | + labelText.textContent = resolveFieldLabel(field, row); |
863 | 884 | labelWrap.appendChild(labelText); |
864 | 885 | fieldWrap.appendChild(labelWrap); |
865 | 886 |
|
|
872 | 893 | checkbox.dataset.type = field.type; |
873 | 894 | checkbox.addEventListener('change', handleFieldChange); |
874 | 895 | 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); |
875 | 914 | } else { |
876 | 915 | const inputWrapper = document.createElement('div'); |
877 | 916 | inputWrapper.className = 'field-with-buttons'; |
|
946 | 985 | return Array.isArray(field.devices) && field.devices.indexOf(state.device) >= 0; |
947 | 986 | } |
948 | 987 |
|
949 | | - function resolveFieldLabel(field) { |
| 988 | + function resolveFieldLabel(field, interval) { |
950 | 989 | if (typeof field.label === 'function') { |
951 | 990 | return field.label(); |
952 | 991 | } |
|
960 | 999 | if (field.unitKey === 'pace') { |
961 | 1000 | return `${label} (${state.miles ? 'min/mi' : 'min/km'})`; |
962 | 1001 | } |
| 1002 | + if ((field.key === 'powerfrom' || field.key === 'powerto') && interval) { |
| 1003 | + const unit = interval.powerrampunit || 'W'; |
| 1004 | + return `${label} (${unit})`; |
| 1005 | + } |
963 | 1006 | if (field.unitSuffix) { |
964 | 1007 | return `${label} (${field.unitSuffix})`; |
965 | 1008 | } |
|
1284 | 1327 | return; |
1285 | 1328 | } |
1286 | 1329 |
|
| 1330 | + // Power ramp fields are handled separately after this loop |
| 1331 | + if (['powerrampunit', 'powerfrom', 'powerto'].includes(field.key)) { |
| 1332 | + return; |
| 1333 | + } |
| 1334 | + |
1287 | 1335 | // Check if field is valid for current device type |
1288 | 1336 | if (!isFieldValidForDevice(field, state.device)) { |
1289 | 1337 | return; |
|
1324 | 1372 | row[field.key] = finalValue; |
1325 | 1373 | } |
1326 | 1374 | }); |
| 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 | + } |
1327 | 1391 | list.push(row); |
1328 | 1392 | } |
1329 | 1393 | return { |
|
1381 | 1445 | axis: def.axis, |
1382 | 1446 | axisLabel: typeof def.axisLabel === 'function' ? def.axisLabel() : def.axisLabel, |
1383 | 1447 | axisPosition: def.axisPosition, |
| 1448 | + stepped: def.stepped !== false, |
1384 | 1449 | points: [] |
1385 | 1450 | })); |
1386 | 1451 |
|
|
1413 | 1478 | }); |
1414 | 1479 | rows.push(Object.assign({ start, durationSeconds: duration }, intervalCopy)); |
1415 | 1480 | 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 | + } |
1416 | 1497 | // Skip disabled fields in the chart |
1417 | 1498 | const isEnabled = interval['__enabled_' + serie.key] !== false; |
1418 | 1499 | if (!isEnabled) { |
|
0 commit comments