running-tools

A collection of tools for runners and their coaches
git clone https://git.ashermorgan.net/running-tools/
Log | Files | Refs | README

commit cedfddc830eee593b31732900a421953551b70d2
parent 783bec010a56da3ca441605b034b29225180849a
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Thu, 29 May 2025 20:02:57 -0700

Implement custom workout target names

Diffstat:
Msrc/components/TargetEditor.vue | 18++++++++++++++++--
Msrc/components/TargetSetSelector.vue | 11++++++++++-
Msrc/utils/calculators.js | 2+-
Msrc/views/BatchCalculator.vue | 3++-
Msrc/views/WorkoutCalculator.vue | 3++-
Mtests/unit/components/TargetEditor.spec.js | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/unit/components/TargetSetSelector.spec.js | 17+++++++++++++++++
Mtests/unit/utils/calculators.spec.js | 28++++++++++++++++++++++++++++
8 files changed, 141 insertions(+), 7 deletions(-)

diff --git a/src/components/TargetEditor.vue b/src/components/TargetEditor.vue @@ -23,6 +23,11 @@ <tbody> <tr v-for="(item, index) in internalValue.targets" :key="index"> <td> + <span v-if="setType === 'workout' && customWorkoutNames"> + <input v-model="item.customName" :placeholder="workoutTargetToString(item)" + aria-label="Custom target name"/>: + </span> + <span v-if="setType === 'workout'"> <decimal-input v-model="item.splitValue" aria-label="Split distance value" :min="0" :digits="2"/> @@ -86,6 +91,7 @@ import { watch, ref } from 'vue'; import VueFeather from 'vue-feather'; +import { workoutTargetToString } from '@/utils/targets'; import { DISTANCE_UNITS, getDefaultDistanceUnit } from '@/utils/units'; import DecimalInput from '@/components/DecimalInput.vue'; @@ -104,9 +110,9 @@ const model = defineModel({ const props = defineProps({ /** - * Whether the target set is a custom or default set + * Whether to allow custom names for workout targets */ - isCustomSet: { + customWorkoutNames: { type: Boolean, default: false, }, @@ -120,6 +126,14 @@ const props = defineProps({ }, /** + * Whether the target set is a custom or default set + */ + isCustomSet: { + type: Boolean, + default: false, + }, + + /** * The target set type ('standard', 'split', or 'workout') */ setType: { diff --git a/src/components/TargetSetSelector.vue b/src/components/TargetSetSelector.vue @@ -13,7 +13,8 @@ <dialog ref="dialogElement" class="target-set-editor-dialog" aria-label="Edit target set"> <target-editor @close="sortTargetSet(); dialogElement.close()" - @revert="revertTargetSet" :default-unit-system="defaultUnitSystem" :setType="setType" + @revert="revertTargetSet" :customWorkoutNames="customWorkoutNames" + :default-unit-system="defaultUnitSystem" :setType="setType" v-model="targetSets[internalValue]" :isCustomSet="!internalValue.startsWith('_')"/> </dialog> </span> @@ -46,6 +47,14 @@ const targetSets = defineModel('targetSets', { defineProps({ /** + * Whether to allow custom names for workout targets + */ + customWorkoutNames: { + type: Boolean, + default: false, + }, + + /** * The unit system to use when creating distance targets */ defaultUnitSystem: { diff --git a/src/utils/calculators.js b/src/utils/calculators.js @@ -169,7 +169,7 @@ export function calculateWorkoutResults(input, target, options, preciseDurations // Return result return { - key: workoutTargetToString(target), + key: target.customName || workoutTargetToString(target), value: formatDuration(t3, 3, preciseDurations ? 2 : 0, true), pace: '', // Pace not used in workout calculator result: 'value', diff --git a/src/views/BatchCalculator.vue b/src/views/BatchCalculator.vue @@ -38,7 +38,8 @@ Target Set: <target-set-selector v-model:selectedTargetSet="selectedTargetSet" :setType="options.calculator === 'workout' ? 'workout' : 'standard'" - v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/> + :customWorkoutNames="true" v-model:targetSets="targetSets" + :default-unit-system="defaultUnitSystem"/> </div> <race-options v-if="options.calculator !== 'pace'" v-model="advancedOptions"/> </details> diff --git a/src/views/WorkoutCalculator.vue b/src/views/WorkoutCalculator.vue @@ -19,7 +19,8 @@ <div> Target Set: <target-set-selector v-model:selectedTargetSet="selectedTargetSet" setType="workout" - v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/> + :customWorkoutNames="true" v-model:targetSets="targetSets" + :default-unit-system="defaultUnitSystem"/> </div> <race-options v-model="options"/> </details> diff --git a/tests/unit/components/TargetEditor.spec.js b/tests/unit/components/TargetEditor.spec.js @@ -15,16 +15,20 @@ test('should correctly render standard target set', async () => { ], }, setType: 'standard', + customWorkoutNames: true, // name input should not be rendered }, }); // Assert target set correctly rendered expect(wrapper.find('input').element.value).to.equal('My target set'); const rows = wrapper.findAll('tbody tr'); + expect(rows[0].findAll('input').length).to.equal(0); expect(rows[0].findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1.61); expect(rows[0].find('select').element.value).to.equal('kilometers'); + expect(rows[1].findAll('input').length).to.equal(0); expect(rows[1].findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(3.11); expect(rows[1].find('select').element.value).to.equal('miles'); + expect(rows[2].findAll('input').length).to.equal(0); expect(rows[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(600); expect(rows.length).to.equal(3); }); @@ -47,14 +51,16 @@ test('should correctly render split target set', async () => { // Assert target set correctly rendered expect(wrapper.find('input').element.value).to.equal('My target set'); const rows = wrapper.findAll('tbody tr'); + expect(rows[0].findAll('input').length).to.equal(0); expect(rows[0].findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1.61); expect(rows[0].find('select').element.value).to.equal('kilometers'); + expect(rows[1].findAll('input').length).to.equal(0); expect(rows[1].findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(3.11); expect(rows[1].find('select').element.value).to.equal('miles'); expect(rows.length).to.equal(2); }); -test('should correctly render workout target set', async () => { +test('should correctly render workout target set without custom names', async () => { // Initialize component const wrapper = shallowMount(TargetEditor, { propsData: { @@ -85,13 +91,71 @@ test('should correctly render workout target set', async () => { // Assert target set correctly rendered expect(wrapper.find('input').element.value).to.equal('My target set'); const rows = wrapper.findAll('tbody tr'); + expect(rows[0].findAll('input').length).to.equal(0); expect(rows[0].findAllComponents({ name: 'decimal-input' })[0].vm.modelValue).to.equal(400); expect(rows[0].findAll('select')[0].element.value).to.equal('meters'); expect(rows[0].findAllComponents({ name: 'decimal-input' })[1].vm.modelValue).to.equal(2); expect(rows[0].findAll('select')[1].element.value).to.equal('miles'); + expect(rows[1].findAll('input').length).to.equal(0); expect(rows[1].findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(2); expect(rows[1].find('select').element.value).to.equal('kilometers'); expect(rows[1].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(6000); + expect(rows[2].findAll('input').length).to.equal(0); + expect(rows[2].findAllComponents({ name: 'decimal-input' })[0].vm.modelValue).to.equal(1); + expect(rows[2].findAll('select')[0].element.value).to.equal('miles'); + expect(rows[2].findAllComponents({ name: 'decimal-input' })[1].vm.modelValue).to.equal(5); + expect(rows[2].findAll('select')[1].element.value).to.equal('kilometers'); + expect(rows.length).to.equal(3); +}); + +test('should correctly render workout target set with custom names', async () => { + // Initialize component + const wrapper = shallowMount(TargetEditor, { + propsData: { + modelValue: { + name: 'My target set', + targets: [ + { + // customName is undefined + distanceUnit: 'miles', distanceValue: 2, + splitUnit: 'meters', splitValue: 400, + type: 'distance', + }, + { + customName: '', + time: 6000, + splitUnit: 'kilometers', splitValue: 2, + type: 'time', + }, + { + customName: 'my custom name', + distanceUnit: 'kilometers', distanceValue: 5, + splitUnit: 'miles', splitValue: 1, + type: 'distance' + }, + ], + }, + setType: 'workout', + customWorkoutNames: true, + }, + }); + + // Assert target set correctly rendered + expect(wrapper.find('input').element.value).to.equal('My target set'); + const rows = wrapper.findAll('tbody tr'); + expect(rows[0].find('input').element.value).to.equal(''); + expect(rows[0].find('input').element.placeholder).to.equal('400 m @ 2 mi'); + expect(rows[0].findAllComponents({ name: 'decimal-input' })[0].vm.modelValue).to.equal(400); + expect(rows[0].findAll('select')[0].element.value).to.equal('meters'); + expect(rows[0].findAllComponents({ name: 'decimal-input' })[1].vm.modelValue).to.equal(2); + expect(rows[0].findAll('select')[1].element.value).to.equal('miles'); + expect(rows[1].find('input').element.value).to.equal(''); + expect(rows[1].find('input').element.placeholder).to.equal('2 km @ 1:40:00'); + expect(rows[1].findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(2); + expect(rows[1].find('select').element.value).to.equal('kilometers'); + expect(rows[1].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(6000); + expect(rows[2].find('input').element.value).to.equal('my custom name'); + expect(rows[2].find('input').element.placeholder).to.equal('1 mi @ 5 km'); expect(rows[2].findAllComponents({ name: 'decimal-input' })[0].vm.modelValue).to.equal(1); expect(rows[2].findAll('select')[0].element.value).to.equal('miles'); expect(rows[2].findAllComponents({ name: 'decimal-input' })[1].vm.modelValue).to.equal(5); diff --git a/tests/unit/components/TargetSetSelector.spec.js b/tests/unit/components/TargetSetSelector.spec.js @@ -336,3 +336,20 @@ test('should correctly pass setType prop to TargetEditor', async () => { // Assert target editor props are correct expect(wrapper.findComponent({ name: 'target-editor' }).vm.setType).to.equal('foo'); }); + +test('should correctly pass customWorkoutNames prop to TargetEditor', async () => { + const wrapper = shallowMount(TargetSetSelector, { + propsData: { + customWorkoutNames: false, + } + }); + + // Assert target editor props are correct + expect(wrapper.findComponent({ name: 'target-editor' }).vm.customWorkoutNames).to.equal(false); + + // Update customWorkoutNames prop + await wrapper.setProps({ customWorkoutNames: true }); + + // Assert target editor props are correct + expect(wrapper.findComponent({ name: 'target-editor' }).vm.customWorkoutNames).to.equal(true); +}); diff --git a/tests/unit/utils/calculators.spec.js b/tests/unit/utils/calculators.spec.js @@ -174,6 +174,34 @@ test('should correctly calculate distance-based workouts according to race optio expect(result.sort).to.be.closeTo(206.35, 0.01); }); +test('should correctly calculate distance-based workouts with custom names', () => { + const input = { + distanceValue: 2, + distanceUnit: 'miles', + time: 630, + }; + const target = { + distanceValue: 5, + distanceUnit: 'kilometers', // 5k split is ~17:11.77 + splitValue: 1000, + splitUnit: 'meters', + type: 'distance', + customName: 'my custom name', + }; + const options = { + model: 'RiegelModel', + riegelExponent: 1.12, + } + + const result = calculatorUtils.calculateWorkoutResults(input, target, options); + + expect(result.key).to.equal('my custom name'); + expect(result.value).to.equal('3:26.35'); + expect(result.pace).to.equal(''); + expect(result.result).to.equal('value'); + expect(result.sort).to.be.closeTo(206.35, 0.01); +}); + test('should correctly calculate time-based workouts', () => { const input = { distanceValue: 5,