commit a92e735b3d3b9b181371ab9fcc66aa4aff7603e6
parent f56663164536e45c17c4fe3ecde74adc014cd3d5
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date: Sat, 31 May 2025 13:07:33 -0700
Merge branch custom-workout-target-names into dev
Add support for custom workout target names.
Diffstat:
18 files changed, 586 insertions(+), 226 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/composables/useStorage.js b/src/composables/useStorage.js
@@ -1,7 +1,6 @@
import { ref, onActivated, watchEffect } from 'vue';
-// The global localStorage prefix
-const prefix = 'running-tools';
+import * as storage from '@/utils/storage';
/*
* Create a reactive value that is synced with a localStorage item
@@ -14,12 +13,7 @@ export default function useStorage(key, defaultValue) {
// (Re)load value from localStorage
function updateValue() {
- let parsedValue;
- try {
- parsedValue = JSON.parse(localStorage.getItem(`${prefix}.${key}`));
- } catch {
- parsedValue = null;
- }
+ let parsedValue = storage.get(key);
if (parsedValue !== null) value.value = parsedValue;
}
updateValue();
@@ -28,7 +22,7 @@ export default function useStorage(key, defaultValue) {
// Save value to localStorage when modified
watchEffect(() => {
if (typeof localStorage !== 'undefined') {
- localStorage.setItem(`${prefix}.${key}`, JSON.stringify(value.value));
+ storage.set(key, value.value);
}
})
diff --git a/src/main.js b/src/main.js
@@ -1,11 +1,13 @@
-import './assets/global.css';
-
import { createApp } from 'vue';
-import App from './App.vue';
-import router from './router';
-const app = createApp(App);
+import App from '@/App.vue';
+import router from '@/router';
+import * as storage from '@/utils/storage';
-app.use(router);
+import '@/assets/global.css';
+storage.migrate();
+
+const app = createApp(App);
+app.use(router);
app.mount('#app');
diff --git a/src/utils/calculators.js b/src/utils/calculators.js
@@ -1,6 +1,7 @@
import { formatDuration, formatNumber } from '@/utils/format';
import * as paceUtils from '@/utils/paces';
import * as raceUtils from '@/utils/races';
+import { workoutTargetToString } from '@/utils/targets';
import { DISTANCE_UNITS, convertDistance, getDefaultDistanceUnit } from '@/utils/units';
/**
@@ -140,39 +141,35 @@ export function calculateRaceStats(input) {
* Predict workout results from a target
* @param {Object} input The input race
* @param {Object} target The workout target
- * @param {Object} options The race prediction options
+ * @param {Object} options The workout options
* @param {Boolean} preciseDurations Whether to return precise, unrounded, durations
* @returns {Object} The result
*/
export function calculateWorkoutResults(input, target, options, preciseDurations = true) {
+ // Initialize distance and time variables
const d1 = convertDistance(input.distanceValue, input.distanceUnit, 'meters');
const t1 = input.time;
const d3 = convertDistance(target.splitValue, target.splitUnit, 'meters');
let d2, t2, t3;
- // Calculate pace
- let key = formatNumber(target.splitValue, 0, 2, false) + ' ' +
- DISTANCE_UNITS[target.splitUnit].symbol;
+ // Calculate result
if (target.type === 'distance') {
// Convert target distance into meters
d2 = convertDistance(target.distanceValue, target.distanceUnit, 'meters');
+
+ // Get workout split prediction
t2 = raceUtils.predictTime(d1, input.time, d2, options.model, options.riegelExponent);
- if (target.distanceValue != target.splitValue || target.distanceUnit != target.splitUnit) {
- key += ' @ ' + formatNumber(target.distanceValue, 0, 2, false) + ' ' +
- DISTANCE_UNITS[target.distanceUnit].symbol;
- }
} else {
t2 = target.time;
- d2 = raceUtils.predictDistance(t1, d1, t2, options.model,
- options.riegelExponent);
- key += ' @ ' + formatDuration(target.time, 3, 2, false);
- }
+ // Get workout split prediction
+ d2 = raceUtils.predictDistance(t1, d1, t2, options.model, options.riegelExponent);
+ }
t3 = paceUtils.calculateTime(d2, t2, d3);
- // Calculate time
+ // Return result
return {
- key: key,
+ key: (options.customTargetNames && 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/utils/storage.js b/src/utils/storage.js
@@ -0,0 +1,36 @@
+// The global localStorage prefix
+const prefix = 'running-tools';
+
+/**
+ * Read an object from a localStorage item
+ * @param {String} key The localStorage item's key
+ * @returns {Object} The object
+ */
+export function get(key) {
+ try {
+ return JSON.parse(localStorage.getItem(`${prefix}.${key}`));
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Write an object to a localStorage item
+ * @param {String} key The localStorage item's key
+ * @param {Object} value The object to write
+ */
+export function set(key, value) {
+ localStorage.setItem(`${prefix}.${key}`, JSON.stringify(value));
+}
+
+/**
+ * Migrate outdated localStorage options
+ */
+export function migrate() {
+ // Add customTargetNames property to workout options (>1.4.1)
+ let workoutOptions = get('workout-calculator-options');
+ if (workoutOptions !== null && workoutOptions.customTargetNames === undefined) {
+ workoutOptions.customTargetNames = false;
+ set('workout-calculator-options', workoutOptions);
+ }
+}
diff --git a/src/utils/targets.js b/src/utils/targets.js
@@ -1,4 +1,5 @@
-import { convertDistance } from '@/utils/units';
+import { formatDuration, formatNumber } from '@/utils/format';
+import { DISTANCE_UNITS, convertDistance } from '@/utils/units';
/**
* Sort an array of targets
@@ -16,6 +17,23 @@ export function sort(targets) {
];
}
+/**
+ * Generate a string description of a workout target
+ * @param {Object} target The workout target
+ * @return {String} The string description
+ */
+export function workoutTargetToString(target) {
+ let result = formatNumber(target.splitValue, 0, 2, false) + ' ' +
+ DISTANCE_UNITS[target.splitUnit].symbol;
+ if (target.type === 'time') {
+ result += ' @ ' + formatDuration(target.time, 3, 2, false);
+ } else if (target.distanceValue != target.splitValue || target.distanceUnit != target.splitUnit) {
+ result += ' @ ' + formatNumber(target.distanceValue, 0, 2, false) + ' ' +
+ DISTANCE_UNITS[target.distanceUnit].symbol;
+ }
+ return result;
+}
+
export const defaultTargetSets = {
'_pace_targets': {
name: 'Common Pace Targets',
diff --git a/src/views/BatchCalculator.vue b/src/views/BatchCalculator.vue
@@ -38,7 +38,15 @@
Target Set:
<target-set-selector v-model:selectedTargetSet="selectedTargetSet"
:setType="options.calculator === 'workout' ? 'workout' : 'standard'"
- v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/>
+ :customWorkoutNames="advancedOptions.customTargetNames" v-model:targetSets="targetSets"
+ :default-unit-system="defaultUnitSystem"/>
+ </div>
+ <div v-if="options.calculator === 'workout'">
+ Target Name Customization:
+ <select v-model="advancedOptions.customTargetNames" aria-label="Target name customization">
+ <option :value="false">Disabled</option>
+ <option :value="true">Enabled</option>
+ </select>
</div>
<race-options v-if="options.calculator !== 'pace'" v-model="advancedOptions"/>
</details>
@@ -117,6 +125,7 @@ const raceOptions = useStorage('race-calculator-options', {
riegelExponent: 1.06,
});
const workoutOptions = useStorage('workout-calculator-options', {
+ customTargetNames: false,
model: 'AverageModel',
riegelExponent: 1.06,
});
diff --git a/src/views/WorkoutCalculator.vue b/src/views/WorkoutCalculator.vue
@@ -19,7 +19,15 @@
<div>
Target Set:
<target-set-selector v-model:selectedTargetSet="selectedTargetSet" setType="workout"
- v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/>
+ :customWorkoutNames="options.customTargetNames" v-model:targetSets="targetSets"
+ :default-unit-system="defaultUnitSystem"/>
+ </div>
+ <div>
+ Target Name Customization:
+ <select v-model="options.customTargetNames" aria-label="Target name customization">
+ <option :value="false">Disabled</option>
+ <option :value="true">Enabled</option>
+ </select>
</div>
<race-options v-model="options"/>
</details>
@@ -61,6 +69,7 @@ const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSys
* The race prediction options
*/
const options = useStorage('workout-calculator-options', {
+ customTargetNames: false,
model: 'AverageModel',
riegelExponent: 1.06,
});
diff --git a/tests/e2e/batch-calculator.spec.js b/tests/e2e/batch-calculator.spec.js
@@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test';
test('Batch calculator', async ({ page }) => {
// Structure:
- // - Test workout batch results, including modified prediction model
+ // - Test workout batch results, including modified prediction model and custom target names
// - Test pace batch results, including modified default units
// - Test race batch results, including modified Riegel exponent
// - Reload page
@@ -40,9 +40,10 @@ test('Batch calculator', async ({ page }) => {
await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(5);
await expect(page.getByRole('row')).toHaveCount(16);
- // Change prediction model
+ // Change prediction model and enable customized target names
await page.getByText('Advanced Options').click();
await page.getByLabel('Prediction model').selectOption('Riegel\'s Model');
+ await page.getByLabel('Target name customization').selectOption('Enabled');
// Assert workout results are correct
await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi');
@@ -160,6 +161,7 @@ test('Batch calculator', async ({ page }) => {
// Assert workout results are correct (inputs and options not reset)
await page.getByLabel('Calculator').selectOption('Workout Calculator');
+ await expect(page.getByLabel('Target name customization')).toHaveValue("true");
await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi');
await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m @ 5 km');
await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(5);
diff --git a/tests/e2e/cross-calculator.spec.js b/tests/e2e/cross-calculator.spec.js
@@ -107,9 +107,10 @@ test('Cross-calculator', async ({ page }) => {
await page.getByLabel('Input race duration minutes').fill('5');
await page.getByLabel('Input race duration seconds').fill('1');
- // Change prediction model
+ // Change prediction model and enable target name customization
await page.getByText('Advanced Options').click();
await page.getByLabel('Prediction model').selectOption('V̇O₂ Max Model');
+ await page.getByLabel('Target name customization').selectOption('Enabled');
// Change default units (should update on other calculators too)
await page.getByLabel('Default units').selectOption('Kilometers');
@@ -145,6 +146,7 @@ test('Cross-calculator', async ({ page }) => {
// Assert workout results are correct (new workout options loaded)
await page.getByLabel('Calculator').selectOption('Workout Calculator');
+ await expect(page.getByLabel('Target name customization')).toHaveValue("true");
await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi');
await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m @ 5 km');
await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(5);
diff --git a/tests/e2e/workout-calculator.spec.js b/tests/e2e/workout-calculator.spec.js
@@ -47,6 +47,7 @@ test('Workout Calculator', async ({ page }) => {
// Edit default target set
await page.getByRole('button', { name: 'Edit target set' }).click();
await page.getByLabel('Target set label').fill('Less-common Workout Targets');
+ await expect(page.getByLabel('Custom target name')).toHaveCount(0);
await page.getByLabel('Split distance value').nth(0).fill('401');
await page.getByLabel('Target distance value').nth(0).fill('2');
await page.getByRole('button', { name: 'Add distance target' }).click();
@@ -73,6 +74,9 @@ test('Workout Calculator', async ({ page }) => {
await expect(page.getByRole('row').nth(7)).toHaveText('2 mi' + '10:30.00');
await expect(page.getByRole('row')).toHaveCount(8);
+ // Enable target name customization
+ await page.getByLabel('Target name customization').selectOption('Enabled');
+
// Create custom target set
await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]');
await expect(page.getByRole('row').nth(4)).toHaveText('There aren\'t any targets in this set yet.');
@@ -83,6 +87,7 @@ test('Workout Calculator', async ({ page }) => {
await expect(page.getByLabel('Target set label')).toHaveValue('New target set');
await page.getByLabel('Target set label').fill('Workout Target Set #2');
await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Custom target name').last().fill('800m Interval');
await page.getByLabel('Split distance value').last().fill('800');
await page.getByLabel('Split distance unit').last().selectOption('Meters');
await page.getByLabel('Target distance value').last().fill('5');
@@ -95,7 +100,7 @@ test('Workout Calculator', async ({ page }) => {
await page.getByRole('button', { name: 'Close' }).click();
// Assert workout splits are correct
- await expect(page.getByRole('row').nth(1)).toHaveText('800 m @ 5 km' + '2:45.08');
+ await expect(page.getByRole('row').nth(1)).toHaveText('800m Interval' + '2:45.08');
await expect(page.getByRole('row').nth(2)).toHaveText('1600 m @ 10 km' + '5:58.80');
await expect(page.getByRole('row')).toHaveCount(3);
@@ -103,7 +108,7 @@ test('Workout Calculator', async ({ page }) => {
await page.reload();
// Assert workout splits are correct (custom targets and model settings not reset)
- await expect(page.getByRole('row').nth(1)).toHaveText('800 m @ 5 km' + '2:45.08');
+ await expect(page.getByRole('row').nth(1)).toHaveText('800m Interval' + '2:45.08');
await expect(page.getByRole('row').nth(2)).toHaveText('1600 m @ 10 km' + '5:58.80');
await expect(page.getByRole('row')).toHaveCount(3);
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
@@ -1,201 +1,255 @@
-import { test, expect } from 'vitest';
+import { describe, test, expect } from 'vitest';
import * as calculatorUtils from '@/utils/calculators';
-test('should correctly calculate pace times', () => {
- const input = {
- distanceValue: 1,
- distanceUnit: 'kilometers',
- time: 100,
- };
- const target = {
- distanceValue: 20,
- distanceUnit: 'meters',
- type: 'distance',
- };
-
- const result = calculatorUtils.calculatePaceResults(input, target, 'metric');
-
- expect(result).to.deep.equal({
- key: '20 m',
- value: '0:02.00',
- pace: '1:40 / km',
- result: 'value',
- sort: 2,
+describe('calculatePaceResults method', () => {
+ test('should correctly calculate pace times', () => {
+ const input = {
+ distanceValue: 1,
+ distanceUnit: 'kilometers',
+ time: 100,
+ };
+ const target = {
+ distanceValue: 20,
+ distanceUnit: 'meters',
+ type: 'distance',
+ };
+
+ const result = calculatorUtils.calculatePaceResults(input, target, 'metric');
+
+ expect(result).to.deep.equal({
+ key: '20 m',
+ value: '0:02.00',
+ pace: '1:40 / km',
+ result: 'value',
+ sort: 2,
+ });
});
-});
-test('should correctly calculate pace distances according to default units setting', () => {
- const input = {
- distanceValue: 2,
- distanceUnit: 'miles',
- time: 1200,
- };
- const target = {
- time: 600,
- type: 'time',
- };
-
- const result1 = calculatorUtils.calculatePaceResults(input, target, 'metric');
- const result2 = calculatorUtils.calculatePaceResults(input, target, 'imperial');
-
- expect(result1.key).to.equal('1.61 km');
- expect(result1.value).to.equal('10:00');
- expect(result1.pace).to.equal('6:13 / km');
- expect(result1.result).to.equal('key');
- expect(result1.sort).to.be.closeTo(600, 0.01);
-
- expect(result2.key).to.equal('1.00 mi');
- expect(result2.value).to.equal('10:00');
- expect(result2.pace).to.equal('10:00 / mi');
- expect(result2.result).to.equal('key');
- expect(result2.sort).to.be.closeTo(600, 0.01);
+ test('should correctly calculate pace distances according to default units setting', () => {
+ const input = {
+ distanceValue: 2,
+ distanceUnit: 'miles',
+ time: 1200,
+ };
+ const target = {
+ time: 600,
+ type: 'time',
+ };
+
+ const result1 = calculatorUtils.calculatePaceResults(input, target, 'metric');
+ const result2 = calculatorUtils.calculatePaceResults(input, target, 'imperial');
+
+ expect(result1.key).to.equal('1.61 km');
+ expect(result1.value).to.equal('10:00');
+ expect(result1.pace).to.equal('6:13 / km');
+ expect(result1.result).to.equal('key');
+ expect(result1.sort).to.be.closeTo(600, 0.01);
+
+ expect(result2.key).to.equal('1.00 mi');
+ expect(result2.value).to.equal('10:00');
+ expect(result2.pace).to.equal('10:00 / mi');
+ expect(result2.result).to.equal('key');
+ expect(result2.sort).to.be.closeTo(600, 0.01);
+ });
});
-test('should correctly predict race times', () => {
- const input = {
- distanceValue: 5,
- distanceUnit: 'kilometers',
- time: 1200,
- };
- const target = {
- distanceValue: 10,
- distanceUnit: 'kilometers',
- type: 'distance',
- };
- const options = {
- model: 'AverageModel',
- riegelExponent: 1.06,
- }
-
- const result = calculatorUtils.calculateRaceResults(input, target, options, 'imperial');
-
- expect(result.key).to.equal('10 km');
- expect(result.value).to.equal('41:34.80');
- expect(result.pace).to.equal('6:42 / mi');
- expect(result.result).to.equal('value');
- expect(result.sort).to.be.closeTo(2494.80, 0.01);
-});
+describe('calculateRaceResults method', () => {
+ test('should correctly predict race times', () => {
+ const input = {
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ };
+ const target = {
+ distanceValue: 10,
+ distanceUnit: 'kilometers',
+ type: 'distance',
+ };
+ const options = {
+ model: 'AverageModel',
+ riegelExponent: 1.06,
+ }
+
+ const result = calculatorUtils.calculateRaceResults(input, target, options, 'imperial');
+
+ expect(result.key).to.equal('10 km');
+ expect(result.value).to.equal('41:34.80');
+ expect(result.pace).to.equal('6:42 / mi');
+ expect(result.result).to.equal('value');
+ expect(result.sort).to.be.closeTo(2494.80, 0.01);
+ });
-test('should correctly calculate race distances according to default units setting', () => {
- const input = {
- distanceValue: 5,
- distanceUnit: 'kilometers',
- time: 1200,
- };
- const target = {
- time: 2495,
- type: 'time',
- };
- const options = {
- model: 'AverageModel',
- riegelExponent: 1.06,
- }
-
- const result1 = calculatorUtils.calculateRaceResults(input, target, options, 'metric');
- const result2 = calculatorUtils.calculateRaceResults(input, target, options, 'imperial');
-
- expect(result1.key).to.equal('10.00 km');
- expect(result1.value).to.equal('41:35');
- expect(result1.pace).to.equal('4:09 / km');
- expect(result1.result).to.equal('key');
- expect(result1.sort).to.equal(2495);
-
- expect(result2.key).to.equal('6.21 mi');
- expect(result2.value).to.equal('41:35');
- expect(result2.pace).to.equal('6:41 / mi');
- expect(result2.result).to.equal('key');
- expect(result2.sort).to.equal(2495);
-});
+ test('should correctly calculate race distances according to default units setting', () => {
+ const input = {
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ };
+ const target = {
+ time: 2495,
+ type: 'time',
+ };
+ const options = {
+ model: 'AverageModel',
+ riegelExponent: 1.06,
+ }
+
+ const result1 = calculatorUtils.calculateRaceResults(input, target, options, 'metric');
+ const result2 = calculatorUtils.calculateRaceResults(input, target, options, 'imperial');
+
+ expect(result1.key).to.equal('10.00 km');
+ expect(result1.value).to.equal('41:35');
+ expect(result1.pace).to.equal('4:09 / km');
+ expect(result1.result).to.equal('key');
+ expect(result1.sort).to.equal(2495);
+
+ expect(result2.key).to.equal('6.21 mi');
+ expect(result2.value).to.equal('41:35');
+ expect(result2.pace).to.equal('6:41 / mi');
+ expect(result2.result).to.equal('key');
+ expect(result2.sort).to.equal(2495);
+ });
-test('should correctly predict race times according to race options', () => {
- const input = {
- distanceValue: 2,
- distanceUnit: 'miles',
- time: 630,
- };
- const target = {
- distanceValue: 5,
- distanceUnit: 'kilometers',
- type: 'distance',
- };
- const options = {
- model: 'RiegelModel',
- riegelExponent: 1.12,
- }
-
- const result = calculatorUtils.calculateRaceResults(input, target, options, 'imperial');
-
- expect(result.key).to.equal('5 km');
- expect(result.value).to.equal('17:11.77');
- expect(result.pace).to.equal('5:32 / mi');
- expect(result.result).to.equal('value');
- expect(result.sort).to.be.closeTo(1031.77, 0.01);
+ test('should correctly predict race times according to race options', () => {
+ const input = {
+ distanceValue: 2,
+ distanceUnit: 'miles',
+ time: 630,
+ };
+ const target = {
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ type: 'distance',
+ };
+ const options = {
+ model: 'RiegelModel',
+ riegelExponent: 1.12,
+ }
+
+ const result = calculatorUtils.calculateRaceResults(input, target, options, 'imperial');
+
+ expect(result.key).to.equal('5 km');
+ expect(result.value).to.equal('17:11.77');
+ expect(result.pace).to.equal('5:32 / mi');
+ expect(result.result).to.equal('value');
+ expect(result.sort).to.be.closeTo(1031.77, 0.01);
+ });
});
-test('should correctly calculate race statistics', () => {
- const input = {
- distanceValue: 5,
- distanceUnit: 'kilometers',
- time: 1200,
- };
+describe('calculateRaceStats method', () => {
+ test('should correctly calculate race statistics', () => {
+ const input = {
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ };
- const results = calculatorUtils.calculateRaceStats(input);
+ const results = calculatorUtils.calculateRaceStats(input);
- expect(results.purdyPoints).to.be.closeTo(454.5, 0.1);
- expect(results.vo2).to.be.closeTo(47.4, 0.1);
- expect(results.vo2MaxPercentage).to.be.closeTo(95.3, 0.1);
- expect(results.vo2Max).to.be.closeTo(49.8, 0.1);
+ expect(results.purdyPoints).to.be.closeTo(454.5, 0.1);
+ expect(results.vo2).to.be.closeTo(47.4, 0.1);
+ expect(results.vo2MaxPercentage).to.be.closeTo(95.3, 0.1);
+ expect(results.vo2Max).to.be.closeTo(49.8, 0.1);
+ });
});
-test('should correctly calculate distance-based workouts according to race options', () => {
- 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',
- };
- const options = {
- model: 'RiegelModel',
- riegelExponent: 1.12,
- }
-
- const result = calculatorUtils.calculateWorkoutResults(input, target, options);
-
- expect(result.key).to.equal('1000 m @ 5 km');
- 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);
-});
+describe('calculateWorkoutResults method', () => {
+ test('should correctly calculate distance-based workouts according to race options', () => {
+ 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',
+ };
+ const options = {
+ customTargetNames: false,
+ model: 'RiegelModel',
+ riegelExponent: 1.12,
+ }
+
+ const result = calculatorUtils.calculateWorkoutResults(input, target, options);
+
+ expect(result.key).to.equal('1000 m @ 5 km');
+ 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,
- distanceUnit: 'kilometers',
- time: 1200,
- };
- const target = {
- time: 2495, // ~10k split is 41:35
- splitValue: 1,
- splitUnit: 'miles',
- type: 'time',
- };
- const options = {
- model: 'AverageModel',
- riegelExponent: 1.06,
- }
-
- const result = calculatorUtils.calculateWorkoutResults(input, target, options);
-
- expect(result.key).to.equal('1 mi @ 41:35');
- expect(result.value).to.equal('6:41.50');
- expect(result.pace).to.equal('');
- expect(result.result).to.equal('value');
- expect(result.sort).to.be.closeTo(401.50, 0.01);
+ test('should correctly calculate distance-based workouts according to custom names', () => {
+ const input = {
+ distanceValue: 2,
+ distanceUnit: 'miles',
+ time: 630,
+ };
+ const target_1 = {
+ distanceValue: 5,
+ distanceUnit: 'kilometers', // 5k split is ~17:11.77
+ splitValue: 1000,
+ splitUnit: 'meters',
+ type: 'distance',
+ // no custom name
+ };
+ const target_2 = {
+ distanceValue: 5,
+ distanceUnit: 'kilometers', // 5k split is ~17:11.77
+ splitValue: 1000,
+ splitUnit: 'meters',
+ type: 'distance',
+ customName: 'my custom name',
+ };
+ const options_a = {
+ customTargetNames: false,
+ model: 'RiegelModel',
+ riegelExponent: 1.12,
+ };
+ const options_b = {
+ customTargetNames: true,
+ model: 'RiegelModel',
+ riegelExponent: 1.12,
+ };
+
+ const result1a = calculatorUtils.calculateWorkoutResults(input, target_1, options_a);
+ const result1b = calculatorUtils.calculateWorkoutResults(input, target_1, options_b);
+ const result2a = calculatorUtils.calculateWorkoutResults(input, target_2, options_a);
+ const result2b = calculatorUtils.calculateWorkoutResults(input, target_2, options_b);
+
+ expect(result1a.key).to.equal('1000 m @ 5 km');
+ expect(result1b.key).to.equal('1000 m @ 5 km');
+ expect(result2a.key).to.equal('1000 m @ 5 km');
+ expect(result2b.key).to.equal('my custom name');
+ });
+
+ test('should correctly calculate time-based workouts', () => {
+ const input = {
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ };
+ const target = {
+ time: 2495, // ~10k split is 41:35
+ splitValue: 1,
+ splitUnit: 'miles',
+ type: 'time',
+ };
+ const options = {
+ customTargetNames: false,
+ model: 'AverageModel',
+ riegelExponent: 1.06,
+ }
+
+ const result = calculatorUtils.calculateWorkoutResults(input, target, options);
+
+ expect(result.key).to.equal('1 mi @ 41:35');
+ expect(result.value).to.equal('6:41.50');
+ expect(result.pace).to.equal('');
+ expect(result.result).to.equal('value');
+ expect(result.sort).to.be.closeTo(401.50, 0.01);
+ });
});
diff --git a/tests/unit/utils/storage.spec.js b/tests/unit/utils/storage.spec.js
@@ -0,0 +1,89 @@
+import { beforeEach, describe, test, expect } from 'vitest';
+import * as storage from '@/utils/storage';
+
+beforeEach(() => {
+ localStorage.clear();
+})
+
+describe('get method', () => {
+ test('should correctly parse correct localStorage item', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.foo', '{"bar":123}');
+
+ // Assert result is correct
+ expect(storage.get('foo')).to.deep.equal({ bar: 123 });
+ });
+
+ test('should return null for corrupt localStorage item', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.foo', 'invalid json');
+
+ // Assert result is correct
+ expect(storage.get('foo')).to.equal(null);
+ });
+
+ test('should return null for missing localStorage item', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.foo', '{"bar":123}');
+
+ // Assert result is correct
+ expect(storage.get('baz')).to.equal(null);
+ });
+});
+
+describe('set method', () => {
+ test('should correctly set new localStorage item', async () => {
+ // Set localStorage item
+ storage.set('foo', { baz: 456 });
+
+ // Assert result is correct
+ expect(localStorage.getItem('running-tools.foo')).to.equal('{"baz":456}');
+ });
+
+ test('should correctly override existing localStorage item', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.foo', '{"bar":123}');
+
+ // Set localStorage item
+ storage.set('foo', { baz: 456 });
+
+ // Assert result is correct
+ expect(localStorage.getItem('running-tools.foo')).to.equal('{"baz":456}');
+ });
+});
+
+describe('migrate method', () => {
+ test('should correctly migrate <=1.4.1 workout calculator options', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.workout-calculator-options',
+ '{"model":"PurdyPointsModel","riegelExponent":1.1}');
+
+ // Run migratinos
+ storage.migrate();
+
+ // Assert localStorage entries correctly migrated
+ expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal(
+ '{"model":"PurdyPointsModel","riegelExponent":1.1,"customTargetNames":false}');
+ });
+
+ test('should not modify >1.4.1 workout calculator options', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.workout-calculator-options',
+ '{"customTargetNames":true,"model":"PurdyPointsModel","riegelExponent":1.1}');
+
+ // Run migratinos
+ storage.migrate();
+
+ // Assert localStorage entries not modified
+ expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal(
+ '{"customTargetNames":true,"model":"PurdyPointsModel","riegelExponent":1.1}');
+ });
+
+ test('should not modify missing workout calculator options', async () => {
+ // Run migratinos
+ storage.migrate();
+
+ // Assert localStorage entries not modified
+ expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal(null);
+ });
+});
diff --git a/tests/unit/utils/targets.spec.js b/tests/unit/utils/targets.spec.js
@@ -19,3 +19,41 @@ describe('sort method', () => {
expect(targets.sort(input)).to.deep.equal(expected);
});
});
+
+describe('workoutTargetToString method', () => {
+ test('should correctly stringify time target', () => {
+ // Initialize original and stringified target
+ const input = {
+ splitValue: 1600, splitUnit: 'meters',
+ type: 'time', time: 3600,
+ };
+ const expected = '1600 m @ 1:00:00';
+
+ // Assert sort method sorts targets correctly
+ expect(targets.workoutTargetToString(input)).to.deep.equal(expected);
+ });
+
+ test('should correctly stringify distance target', () => {
+ // Initialize original and stringified target
+ const input = {
+ splitValue: 800, splitUnit: 'meters',
+ type: 'distance', distanceValue: 5, distanceUnit: 'kilometers',
+ };
+ const expected = '800 m @ 5 km';
+
+ // Assert sort method sorts targets correctly
+ expect(targets.workoutTargetToString(input)).to.deep.equal(expected);
+ });
+
+ test('should correctly stringify race target', () => {
+ // Initialize original and stringified target
+ const input = {
+ splitValue: 5, splitUnit: 'kilometers',
+ type: 'distance', distanceValue: 5, distanceUnit: 'kilometers',
+ };
+ const expected = '5 km';
+
+ // Assert sort method sorts targets correctly
+ expect(targets.workoutTargetToString(input)).to.deep.equal(expected);
+ });
+});
diff --git a/tests/unit/views/WorkoutCalculator.spec.js b/tests/unit/views/WorkoutCalculator.spec.js
@@ -73,6 +73,7 @@ test('should correctly calculate results according to advanced model options', a
// Calculate result
const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult;
let result = calculateResult({
+ customName: 'foo',
splitValue: 1, splitUnit: 'kilometers',
type: 'distance', distanceValue: 10, distanceUnit: 'kilometers',
});