commit 1a032514433fbeab6092e6117636a02b23f7bb8c
parent baf7d0840cf02ec30ba13068dc64deb4c28969d9
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date: Mon, 27 May 2024 14:11:01 -0700
Implement PaceInput component
Diffstat:
9 files changed, 220 insertions(+), 124 deletions(-)
diff --git a/src/assets/target-calculator.css b/src/assets/target-calculator.css
@@ -18,9 +18,6 @@ h2 {
.input>* {
margin-bottom: 5px; /* adds space between wrapped lines */
}
-.input select {
- margin-left: 5px;
-}
/* collapsable sections */
summary {
diff --git a/src/components/PaceInput.vue b/src/components/PaceInput.vue
@@ -0,0 +1,58 @@
+<template>
+ <div class="pace-input">
+ <div>
+ Distance:
+ <decimal-input v-model="model.distanceValue"
+ :aria-label="label + ' distance value'" :min="0" :digits="2"/>
+ <select v-model="model.distanceUnit" :aria-label="label + ' distance unit'">
+ <option v-for="(value, key) in distanceUnits" :key="key" :value="key">
+ {{ value.name }}
+ </option>
+ </select>
+ </div>
+ <div>
+ Time:
+ <time-input v-model="model.time" :label="label + ' duration'"/>
+ </div>
+ </div>
+</template>
+
+<script setup>
+import DecimalInput from '@/components/DecimalInput.vue';
+import TimeInput from '@/components/TimeInput.vue';
+
+import unitUtils from '@/utils/units';
+const distanceUnits = unitUtils.DISTANCE_UNITS;
+
+/**
+ * The component value
+ */
+const model = defineModel({
+ type: Object,
+ default: {
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ },
+});
+
+defineProps({
+ /**
+ * The prefix for each field's aria-label
+ */
+ label: {
+ type: String,
+ default: 'Input',
+ },
+});
+
+</script>
+
+<style scoped>
+.pace-input div + div {
+ margin-top: 5px;
+}
+.pace-input select {
+ margin-left: 5px;
+}
+</style>
diff --git a/src/views/PaceCalculator.vue b/src/views/PaceCalculator.vue
@@ -2,20 +2,7 @@
<div class="calculator">
<h2>Input Pace</h2>
<div class="input">
- <div>
- Distance:
- <decimal-input v-model="inputDistance" aria-label="Input distance value"
- :min="0" :digits="2"/>
- <select v-model="inputUnit" aria-label="Input distance unit">
- <option v-for="(value, key) in unitUtils.DISTANCE_UNITS" :key="key" :value="key">
- {{ value.name }}
- </option>
- </select>
- </div>
- <div>
- Time:
- <time-input v-model="inputTime" label="Input duration"/>
- </div>
+ <pace-input v-model="input"/>
</div>
<details>
@@ -49,27 +36,20 @@ import paceUtils from '@/utils/paces';
import targetUtils from '@/utils/targets';
import unitUtils from '@/utils/units';
-import DecimalInput from '@/components/DecimalInput.vue';
+import PaceInput from '@/components/PaceInput.vue';
import SimpleTargetTable from '@/components/SimpleTargetTable.vue';
import TargetSetSelector from '@/components/TargetSetSelector.vue';
-import TimeInput from '@/components/TimeInput.vue';
import useStorage from '@/composables/useStorage';
/**
- * The input distance value
- */
-const inputDistance = useStorage('pace-calculator-input-distance', 5);
-
-/**
- * The input distance unit
- */
-const inputUnit = useStorage('pace-calculator-input-unit', 'kilometers');
-
-/**
- * The input time value
+ * The input pace
*/
-const inputTime = useStorage('pace-calculator-input-time', 20 * 60);
+const input = useStorage('pace-calculator-input', {
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+});
/**
* The default unit system
@@ -90,8 +70,9 @@ const targetSets = useStorage('target-sets', targetUtils.defaultTargetSets);
* The input pace (in seconds per meter)
*/
const pace = computed(() => {
- const distance = unitUtils.convertDistance(inputDistance.value, inputUnit.value, 'meters');
- return paceUtils.getPace(distance, inputTime.value);
+ const distance = unitUtils.convertDistance(input.value.distanceValue, input.value.distanceUnit,
+ 'meters');
+ return paceUtils.getPace(distance, input.value.time);
});
/**
diff --git a/src/views/RaceCalculator.vue b/src/views/RaceCalculator.vue
@@ -2,19 +2,7 @@
<div class="calculator">
<h2>Input Race Result</h2>
<div class="input">
- <div>
- Distance:
- <decimal-input v-model="inputDistance" aria-label="Input distance value" :min="0" :digits="2"/>
- <select v-model="inputUnit" aria-label="Input distance unit">
- <option v-for="(value, key) in unitUtils.DISTANCE_UNITS" :key="key" :value="key">
- {{ value.name }}
- </option>
- </select>
- </div>
- <div>
- Time:
- <time-input v-model="inputTime" label="Input race duration"/>
- </div>
+ <pace-input v-model="input" label="Input race"/>
</div>
<details>
@@ -82,26 +70,20 @@ import targetUtils from '@/utils/targets';
import unitUtils from '@/utils/units';
import DecimalInput from '@/components/DecimalInput.vue';
+import PaceInput from '@/components/PaceInput.vue';
import SimpleTargetTable from '@/components/SimpleTargetTable.vue';
import TargetSetSelector from '@/components/TargetSetSelector.vue';
-import TimeInput from '@/components/TimeInput.vue';
import useStorage from '@/composables/useStorage';
/**
- * The input distance value
- */
-const inputDistance = useStorage('race-calculator-input-distance', 5);
-
-/**
- * The input distance unit
- */
-const inputUnit = useStorage('race-calculator-input-unit', 'kilometers');
-
-/**
- * The input time value
+ * The input race
*/
-const inputTime = useStorage('race-calculator-input-time', 20 * 60);
+const input = useStorage('race-calculator-input', {
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+});
/**
* The default unit system
@@ -152,21 +134,21 @@ function predictResult(target) {
switch (model.value) {
default:
case 'AverageModel':
- time = raceUtils.AverageModel.predictTime(d1.value, inputTime.value, d2,
+ time = raceUtils.AverageModel.predictTime(d1.value, input.value.time, d2,
riegelExponent.value);
break;
case 'PurdyPointsModel':
- time = raceUtils.PurdyPointsModel.predictTime(d1.value, inputTime.value, d2);
+ time = raceUtils.PurdyPointsModel.predictTime(d1.value, input.value.time, d2);
break;
case 'VO2MaxModel':
- time = raceUtils.VO2MaxModel.predictTime(d1.value, inputTime.value, d2);
+ time = raceUtils.VO2MaxModel.predictTime(d1.value, input.value.time, d2);
break;
case 'RiegelModel':
- time = raceUtils.RiegelModel.predictTime(d1.value, inputTime.value, d2,
+ time = raceUtils.RiegelModel.predictTime(d1.value, input.value.time, d2,
riegelExponent.value);
break;
case 'CameronModel':
- time = raceUtils.CameronModel.predictTime(d1.value, inputTime.value, d2);
+ time = raceUtils.CameronModel.predictTime(d1.value, input.value.time, d2);
break;
}
@@ -178,22 +160,22 @@ function predictResult(target) {
switch (model.value) {
default:
case 'AverageModel':
- distance = raceUtils.AverageModel.predictDistance(inputTime.value, d1.value, target.time,
+ distance = raceUtils.AverageModel.predictDistance(input.value.time, d1.value, target.time,
riegelExponent.value);
break;
case 'PurdyPointsModel':
- distance = raceUtils.PurdyPointsModel.predictDistance(inputTime.value, d1.value,
+ distance = raceUtils.PurdyPointsModel.predictDistance(input.value.time, d1.value,
target.time);
break;
case 'VO2MaxModel':
- distance = raceUtils.VO2MaxModel.predictDistance(inputTime.value, d1.value, target.time);
+ distance = raceUtils.VO2MaxModel.predictDistance(input.value.time, d1.value, target.time);
break;
case 'RiegelModel':
- distance = raceUtils.RiegelModel.predictDistance(inputTime.value, d1.value, target.time,
+ distance = raceUtils.RiegelModel.predictDistance(input.value.time, d1.value, target.time,
riegelExponent.value);
break;
case 'CameronModel':
- distance = raceUtils.CameronModel.predictDistance(inputTime.value, d1.value, target.time);
+ distance = raceUtils.CameronModel.predictDistance(input.value.time, d1.value, target.time);
break;
}
@@ -214,14 +196,14 @@ function predictResult(target) {
* The input distance in meters
*/
const d1 = computed(() => {
- return unitUtils.convertDistance(inputDistance.value, inputUnit.value, 'meters');
+ return unitUtils.convertDistance(input.value.distanceValue, input.value.distanceUnit, 'meters');
});
/**
* The Purdy Points for the input race
*/
const purdyPoints = computed(() => {
- const result = raceUtils.PurdyPointsModel.getPurdyPoints(d1.value, inputTime.value);
+ const result = raceUtils.PurdyPointsModel.getPurdyPoints(d1.value, input.value.time);
return result;
});
@@ -229,7 +211,7 @@ const purdyPoints = computed(() => {
* The VO2 Max calculated from the input race
*/
const vo2Max = computed(() => {
- const result = raceUtils.VO2MaxModel.getVO2Max(d1.value, inputTime.value);
+ const result = raceUtils.VO2MaxModel.getVO2Max(d1.value, input.value.time);
return result;
});
@@ -237,7 +219,7 @@ const vo2Max = computed(() => {
* The VO2 calculated from the input race
*/
const vo2 = computed(() => {
- const result = raceUtils.VO2MaxModel.getVO2(d1.value, inputTime.value);
+ const result = raceUtils.VO2MaxModel.getVO2(d1.value, input.value.time);
return result;
});
@@ -245,7 +227,7 @@ const vo2 = computed(() => {
* The percentage of VO2 Max calculated from the input race
*/
const vo2Percentage = computed(() => {
- const result = raceUtils.VO2MaxModel.getVO2Percentage(inputTime.value) * 100;
+ const result = raceUtils.VO2MaxModel.getVO2Percentage(input.value.time) * 100;
return result;
});
</script>
diff --git a/tests/e2e/cross-calculator.spec.js b/tests/e2e/cross-calculator.spec.js
@@ -24,8 +24,8 @@ test('Save and update state when navigating between calculators', async ({ page
await page.getByRole('button', { name: 'Race Calculator' }).click();
// Enter input race (2 mi in 10:30)
- await page.getByLabel('Input distance value').fill('2');
- await page.getByLabel('Input distance unit').selectOption('Miles');
+ await page.getByLabel('Input race distance value').fill('2');
+ await page.getByLabel('Input race distance unit').selectOption('Miles');
await page.getByLabel('Input race duration hours').fill('0');
await page.getByLabel('Input race duration minutes').fill('10');
await page.getByLabel('Input race duration seconds').fill('30');
diff --git a/tests/e2e/race-calculator.spec.js b/tests/e2e/race-calculator.spec.js
@@ -7,8 +7,8 @@ test('Basic usage', async ({ page }) => {
await expect(page).toHaveTitle('Race Calculator - Running Tools');
// Enter input race (2 mi in 10:30)
- await page.getByLabel('Input distance value').fill('2');
- await page.getByLabel('Input distance unit').selectOption('Miles');
+ await page.getByLabel('Input race distance value').fill('2');
+ await page.getByLabel('Input race distance unit').selectOption('Miles');
await page.getByLabel('Input race duration hours').fill('0');
await page.getByLabel('Input race duration minutes').fill('10');
await page.getByLabel('Input race duration seconds').fill('30');
@@ -56,8 +56,8 @@ test('Customize target sets', async ({ page }) => {
await page.getByRole('button', { name: 'Race Calculator' }).click();
// Enter input race (2 mi in 10:30)
- await page.getByLabel('Input distance value').fill('2');
- await page.getByLabel('Input distance unit').selectOption('Miles');
+ await page.getByLabel('Input race distance value').fill('2');
+ await page.getByLabel('Input race distance unit').selectOption('Miles');
await page.getByLabel('Input race duration hours').fill('0');
await page.getByLabel('Input race duration minutes').fill('10');
await page.getByLabel('Input race duration seconds').fill('30');
@@ -148,8 +148,8 @@ test('Save settings across page reloads', async ({ page }) => {
await page.getByRole('button', { name: 'Race Calculator' }).click();
// Enter input race (2 mi in 10:30)
- await page.getByLabel('Input distance value').fill('2');
- await page.getByLabel('Input distance unit').selectOption('Miles');
+ await page.getByLabel('Input race distance value').fill('2');
+ await page.getByLabel('Input race distance unit').selectOption('Miles');
await page.getByLabel('Input race duration hours').fill('0');
await page.getByLabel('Input race duration minutes').fill('10');
await page.getByLabel('Input race duration seconds').fill('30');
diff --git a/tests/unit/components/PaceInput.spec.js b/tests/unit/components/PaceInput.spec.js
@@ -0,0 +1,50 @@
+import { test, expect } from 'vitest';
+import { shallowMount } from '@vue/test-utils';
+import PaceInput from '@/components/PaceInput.vue';
+
+test('should be initialized to modelValue', () => {
+ // Initialize component
+ const wrapper = shallowMount(PaceInput, {
+ propsData: {
+ modelValue: {
+ distanceValue: 3,
+ distanceUnit: 'miles',
+ time: 1000,
+ }
+ },
+ });
+
+ // Assert input fields are correct
+ expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(3);
+ expect(wrapper.find('select').element.value).to.equal('miles');
+ expect(wrapper.findComponent({ name: 'time-input' }).vm.modelValue).to.equal(1000);
+});
+
+test('should update modelValue when inputs are modified', async () => {
+ // Initialize component
+ const wrapper = shallowMount(PaceInput);
+
+ // Update distance value
+ await wrapper.findComponent({ name: 'decimal-input' }).setValue(3);
+ expect(wrapper.vm.modelValue).to.deep.equal({
+ distanceValue: 3,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ });
+
+ // Update distance unit
+ await wrapper.find('select').setValue('miles');
+ expect(wrapper.vm.modelValue).to.deep.equal({
+ distanceValue: 3,
+ distanceUnit: 'miles',
+ time: 1200,
+ });
+
+ // Update time
+ await wrapper.findComponent({ name: 'time-input' }).setValue(1000);
+ expect(wrapper.vm.modelValue).to.deep.equal({
+ distanceValue: 3,
+ distanceUnit: 'miles',
+ time: 1000,
+ });
+});
diff --git a/tests/unit/views/PaceCalculator.spec.js b/tests/unit/views/PaceCalculator.spec.js
@@ -12,9 +12,11 @@ test('should correctly calculate time results', async () => {
const wrapper = shallowMount(PaceCalculator);
// Enter input pace data
- await wrapper.findComponent({ name: 'decimal-input' }).setValue(1);
- await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers');
- await wrapper.findComponent({ name: 'time-input' }).setValue(100);
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 1,
+ distanceUnit: 'kilometers',
+ time: 100,
+ });
// Calculate result
const calculateResult = wrapper.findComponent({ name: 'simple-target-table' }).vm.calculateResult;
@@ -38,9 +40,11 @@ test('should correctly calculate distance results according to default units set
const wrapper = shallowMount(PaceCalculator);
// Enter input pace data
- await wrapper.findComponent({ name: 'decimal-input' }).setValue(2);
- await wrapper.find('select[aria-label="Input distance unit"]').setValue('miles');
- await wrapper.findComponent({ name: 'time-input' }).setValue(1200);
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 2,
+ distanceUnit: 'miles',
+ time: 1200,
+ });
// Set default units
await wrapper.find('select[aria-label="Default units"]').setValue('metric');
@@ -95,17 +99,21 @@ test('should correctly handle null target set', async () => {
test('should load input pace from localStorage', async () => {
// Initialize localStorage
- localStorage.setItem('running-tools.pace-calculator-input-distance', '1');
- localStorage.setItem('running-tools.pace-calculator-input-unit', '"miles"');
- localStorage.setItem('running-tools.pace-calculator-input-time', '600');
+ localStorage.setItem('running-tools.pace-calculator-input', JSON.stringify({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ }));
// Initialize component
const wrapper = shallowMount(PaceCalculator);
// Assert data loaded
- expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1);
- expect(wrapper.find('select[aria-label="Input distance unit"]').element.value).to.equal('miles');
- expect(wrapper.findComponent({ name: 'time-input' }).vm.modelValue).to.equal(600);
+ expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ });
});
test('should save input pace to localStorage', async () => {
@@ -113,14 +121,18 @@ test('should save input pace to localStorage', async () => {
const wrapper = shallowMount(PaceCalculator);
// Enter input pace data
- await wrapper.findComponent({ name: 'decimal-input' }).setValue(1);
- await wrapper.find('select[aria-label="Input distance unit"]').setValue('miles');
- await wrapper.findComponent({ name: 'time-input' }).setValue(600);
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ });
// Assert data saved to localStorage
- expect(localStorage.getItem('running-tools.pace-calculator-input-distance')).to.equal('1');
- expect(localStorage.getItem('running-tools.pace-calculator-input-unit')).to.equal('"miles"');
- expect(localStorage.getItem('running-tools.pace-calculator-input-time')).to.equal('600');
+ expect(localStorage.getItem('running-tools.pace-calculator-input')).to.equal(JSON.stringify({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ }));
});
test('should load selected target set from localStorage', async () => {
diff --git a/tests/unit/views/RaceCalculator.spec.js b/tests/unit/views/RaceCalculator.spec.js
@@ -12,9 +12,11 @@ test('should correctly predict race times', async () => {
const wrapper = shallowMount(RaceCalculator);
// Enter input pace data
- await wrapper.findComponent({ name: 'decimal-input' }).setValue(5);
- await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers');
- await wrapper.findComponent({ name: 'time-input' }).setValue(1200);
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ });
// Calculate result
const calculateResult = wrapper.findComponent({ name: 'simple-target-table' }).vm.calculateResult;
@@ -36,9 +38,11 @@ test('should correctly calculate distance results according to default units set
const wrapper = shallowMount(RaceCalculator);
// Enter input pace data
- await wrapper.findComponent({ name: 'decimal-input' }).setValue(5);
- await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers');
- await wrapper.findComponent({ name: 'time-input' }).setValue(1200);
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ });
// Set default units
await wrapper.find('select[aria-label="Default units"]').setValue('metric');
@@ -98,9 +102,11 @@ test('should correctly calculate race statistics', async () => {
const wrapper = shallowMount(RaceCalculator);
// Enter input pace data
- await wrapper.findComponent({ name: 'decimal-input' }).setValue(5);
- await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers');
- await wrapper.findComponent({ name: 'time-input' }).setValue(1200);
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ });
// Get race statistics
const raceStats = wrapper.findAll('details')[0];
@@ -119,9 +125,11 @@ test('should correctly calculate results according to advanced model options', a
const wrapper = shallowMount(RaceCalculator);
// Enter input pace data
- await wrapper.findComponent({ name: 'decimal-input' }).setValue(5);
- await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers');
- await wrapper.findComponent({ name: 'time-input' }).setValue(1200);
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ });
// Switch model
await wrapper.find('select[aria-label="Prediction model"]').setValue('RiegelModel');
@@ -154,17 +162,21 @@ test('should correctly calculate results according to advanced model options', a
test('should load input pace from localStorage', async () => {
// Initialize localStorage
- localStorage.setItem('running-tools.race-calculator-input-distance', '1');
- localStorage.setItem('running-tools.race-calculator-input-unit', '"miles"');
- localStorage.setItem('running-tools.race-calculator-input-time', '600');
+ localStorage.setItem('running-tools.race-calculator-input', JSON.stringify({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ }));
// Initialize component
const wrapper = shallowMount(RaceCalculator);
// Assert data loaded
- expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1);
- expect(wrapper.find('select[aria-label="Input distance unit"]').element.value).to.equal('miles');
- expect(wrapper.findComponent({ name: 'time-input' }).vm.modelValue).to.equal(600);
+ expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ });
});
test('should save input pace to localStorage', async () => {
@@ -172,14 +184,18 @@ test('should save input pace to localStorage', async () => {
const wrapper = shallowMount(RaceCalculator);
// Enter input pace data
- await wrapper.findComponent({ name: 'decimal-input' }).setValue(1);
- await wrapper.find('select[aria-label="Input distance unit"]').setValue('miles');
- await wrapper.findComponent({ name: 'time-input' }).setValue(600);
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ });
// Assert data saved to localStorage
- expect(localStorage.getItem('running-tools.race-calculator-input-distance')).to.equal('1');
- expect(localStorage.getItem('running-tools.race-calculator-input-unit')).to.equal('"miles"');
- expect(localStorage.getItem('running-tools.race-calculator-input-time')).to.equal('600');
+ expect(localStorage.getItem('running-tools.race-calculator-input')).to.equal(JSON.stringify({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ }));
});
test('should load selected target set from localStorage', async () => {