running-tools

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

commit bebba6bd1ebdcd83b8cd20d80e3fbed51db5d616
parent 2bb3da51e0a295e2d450d8dbeef4cc8546253030
Author: ashermorgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Mon, 13 Nov 2023 20:52:05 -0800

Merge pull request #6 from ashermorgan/global-target-sets

Implement global target sets
Diffstat:
Msrc/App.vue | 2--
Msrc/assets/global.css | 9+++------
Asrc/assets/target-calculator.css | 42++++++++++++++++++++++++++++++++++++++++++
Asrc/components/Modal.vue | 45+++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/SimpleTargetTable.vue | 89++++++-------------------------------------------------------------------------
Msrc/components/TargetEditor.vue | 33+++++++++++++--------------------
Asrc/components/TargetSetEditor.vue | 221+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/utils/targets.js | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/views/PaceCalculator.vue | 149+++++++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/views/RaceCalculator.vue | 142++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Msrc/views/SplitCalculator.vue | 204++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mtests/unit/components/SimpleTargetTable.spec.js | 2+-
Mtests/unit/components/TargetEditor.spec.js | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Atests/unit/components/TargetSetEditor.spec.js | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/unit/views/SplitCalculator.spec.js | 24+++++++++++++++---------
15 files changed, 925 insertions(+), 392 deletions(-)

diff --git a/src/App.vue b/src/App.vue @@ -43,8 +43,6 @@ header a { } ::v-deep(.feather-chevron-left) { padding: 0em; - height: 2em; - width: 2em; color: #000000; } h1 { diff --git a/src/assets/global.css b/src/assets/global.css @@ -40,14 +40,11 @@ table { table th, table td { padding: 0.2em; } -table button.icon { - height: 2em; - width: 2em; -} /* empty table message */ table .empty-message td { text-align: center !important; + padding: 0.5em; } table .empty-message svg { height: 1em; @@ -64,8 +61,8 @@ table .empty-message svg { vertical-align: middle; } .icon svg { - width: 100%; - height: 100%; + height: 2em; + width: 2em; padding: 0.3em; } diff --git a/src/assets/target-calculator.css b/src/assets/target-calculator.css @@ -0,0 +1,42 @@ +/* container */ +.calculator { + display: flex; + flex-direction: column; + align-items: center; +} + +/* headings */ +h2 { + font-size: 1.3em; + margin-bottom: 0.2em; +} +* + h2 { + margin-top: 0.5em; +} + +/* calculator input */ +.input>* { + margin-bottom: 5px; /* adds space between wrapped lines */ +} +.input select { + margin-left: 5px; +} + +/* target set */ +.target-set { + margin-bottom: 5px; +} +.target-set button { + margin-left: 3px; +} + +/* calculator output */ +.output { + min-width: 300px; +} +@media only screen and (max-width: 500px) { + .output { + width: 100%; + min-width: 0px; + } +} diff --git a/src/components/Modal.vue b/src/components/Modal.vue @@ -0,0 +1,45 @@ +<template> + <div class="modal"> + <div class="backdrop"></div> + <div class="content-container"> + <div class="content"> + <slot></slot> + </div> + </div> + </div> +</template> + +<script> +export default { + name: 'Modal', +}; +</script> + +<style scoped> +.modal .backdrop { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 1000; + background-color: #00000080; +} +.modal .content-container { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 1010; + overflow: scroll; +} + +.modal .content { + max-width: 500px; + margin: 75px auto 1em; + padding: 1em; + background-color: var(--background1); + border-radius: 10px; +} +</style> diff --git a/src/components/SimpleTargetTable.vue b/src/components/SimpleTargetTable.vue @@ -1,6 +1,6 @@ <template> <div class="simple-target-table"> - <table class="results" v-show="!inEditMode"> + <table class="results"> <thead> <tr> <th>Distance</th> @@ -8,12 +8,6 @@ <th>Time</th> <th v-if="showPace">Pace</th> - - <th> - <button class="icon" title="Edit Targets" @click="inEditMode=true" v-blur> - <vue-feather type="edit"/> - </button> - </th> </tr> </thead> @@ -24,11 +18,11 @@ {{ distanceUnits[item.distanceUnit].symbol }} </td> - <td :colspan="showPace ? 1 : 2" :class="item.result === 'time' ? 'result' : ''"> + <td :class="item.result === 'time' ? 'result' : ''"> {{ formatDuration(item.time, 3, 2, item.result === 'time') }} </td> - <td v-if="showPace" colspan="2"> + <td v-if="showPace"> {{ formatDuration(getPace(item), 3, 0, true) }} / {{ distanceUnits[getDefaultDistanceUnit()].symbol }} </td> @@ -36,17 +30,11 @@ <tr v-if="results.length === 0" class="empty-message"> <td colspan="4"> - There aren't any targets yet,<br> - click - <vue-feather type="edit"/> - to edit the list of targets + There aren't any targets in this set yet. </td> </tr> </tbody> </table> - - <target-editor v-show="inEditMode" v-model="targets" @close="inEditMode=false" - @reset="resetTargets"/> </div> </template> @@ -54,19 +42,14 @@ import VueFeather from 'vue-feather'; import formatUtils from '@/utils/format'; -import storage from '@/utils/localStorage'; -import targetUtils from '@/utils/targets'; import unitUtils from '@/utils/units'; -import TargetEditor from '@/components/TargetEditor.vue'; - import blur from '@/directives/blur'; export default { name: 'SimpleTargetTable', components: { - TargetEditor, VueFeather, }, @@ -84,22 +67,14 @@ export default { }, /** - * The default targets + * The target set */ - defaultTargets: { + targets: { type: Array, default: () => [], }, /** - * The localStorage key for the list of targets - */ - storageKey: { - type: String, - default: null, - }, - - /** * Whether to show result paces */ showPace: { @@ -129,16 +104,6 @@ export default { * The getDefaultDistanceUnit method */ getDefaultDistanceUnit: unitUtils.getDefaultDistanceUnit, - - /** - * Whether the table is in edit mode - */ - inEditMode: false, - - /** - * The target table targets - */ - targets: storage.get(this.storageKey, this.defaultTargets), }; }, @@ -162,40 +127,8 @@ export default { }, }, - watch: { - /** - * Sort targets - */ - inEditMode() { - this.targets = targetUtils.sort(this.targets); - }, - - /** - * Save targets - */ - targets: { - handler(newValue) { - if (this.storageKey !== null) { - storage.set(this.storageKey, newValue); - } - }, - deep: true, - }, - }, - methods: { /** - * Restore the default targets - */ - resetTargets() { - // Clone default targets array - this.targets = JSON.parse(JSON.stringify(this.defaultTargets)); - - // Sort targets - this.targets = targetUtils.sort(this.targets); - }, - - /** * Get the pace of a result * @param {Object} result The result */ @@ -204,21 +137,11 @@ export default { unitUtils.getDefaultDistanceUnit()); }, }, - - /** - * Close edit targets table - */ - deactivated() { - this.inEditMode = false; - }, }; </script> <style scoped> /* target table */ -.results th:last-child { - text-align: right; -} .results .result { font-weight: bold; } diff --git a/src/components/TargetEditor.vue b/src/components/TargetEditor.vue @@ -2,12 +2,12 @@ <table class="target-editor"> <thead> <tr> - <th>Edit Targets</th> + <th> + Edit + <input v-model="internalValue.name" placeholder="Target set name"/> + </th> <th> - <button class="icon" title="Reset Targets" @click="reset" v-blur> - <vue-feather type="rotate-ccw"/> - </button> <button class="icon" title="Close" @click="close" v-blur> <vue-feather type="x"/> </button> @@ -16,7 +16,7 @@ </thead> <tbody> - <tr v-for="(item, index) in internalValue" :key="index"> + <tr v-for="(item, index) in internalValue.targets" :key="index"> <td v-if="item.result === 'time'"> <decimal-input v-model="item.distanceValue" aria-label="Distance Value" :min="0" :digits="2"/> @@ -38,9 +38,9 @@ </td> </tr> - <tr v-if="internalValue.length === 0" class="empty-message"> + <tr v-if="internalValue.targets.length === 0" class="empty-message"> <td colspan="2"> - There aren't any targets yet + There aren't any targets in this set yet </td> </tr> </tbody> @@ -63,6 +63,7 @@ <script> import VueFeather from 'vue-feather'; +import targetUtils from '@/utils/targets'; import unitUtils from '@/utils/units'; import DecimalInput from '@/components/DecimalInput.vue'; @@ -88,8 +89,8 @@ export default { * The targets */ modelValue: { - type: Array, - default: () => [], + type: Object, + default: JSON.parse(JSON.stringify(targetUtils.defaultTargetSet)), }, /** @@ -141,14 +142,6 @@ export default { methods: { /** - * Restore the default targets - */ - reset() { - // Emit reset event - this.$emit('reset'); - }, - - /** * Close the target editor */ close() { @@ -160,7 +153,7 @@ export default { * Add a new distance based target */ addDistanceTarget() { - this.internalValue.push({ + this.internalValue.targets.push({ result: 'time', distanceValue: 1, distanceUnit: unitUtils.getDefaultDistanceUnit(), @@ -171,7 +164,7 @@ export default { * Add a new time based target */ addTimeTarget() { - this.internalValue.push({ + this.internalValue.targets.push({ result: 'distance', time: 600, }); @@ -182,7 +175,7 @@ export default { * @param {Number} index The index of the target */ removeTarget(index) { - this.internalValue.splice(index, 1); + this.internalValue.targets.splice(index, 1); }, }, }; diff --git a/src/components/TargetSetEditor.vue b/src/components/TargetSetEditor.vue @@ -0,0 +1,221 @@ +<template> + <div class="target-set-editor"> + <table v-show="selectedTargetSet === null"> + <thead> + <tr> + <th> + Edit Target Sets + </th> + <th> + <button class="icon" title="Restore Default Sets" @click="reset" v-blur> + <vue-feather type="rotate-ccw"/> + </button> + <button class="icon" title="Close" @click="close" v-blur> + <vue-feather type="x"/> + </button> + </th> + </tr> + </thead> + + <tbody> + <tr v-for="(item, key) in internalValue" :key="key"> + <td> + {{ item.name }} + </td> + <td> + <button class="icon" title="Edit Set" @click="editTargetSet(key)" v-blur> + <vue-feather type="edit"/> + </button> + <button class="icon" title="Delete Set" @click="removeTargetSet(key)" v-blur> + <vue-feather type="trash-2"/> + </button> + </td> + </tr> + + <tr v-if="Object.keys(internalValue).length === 0" class="empty-message"> + <td colspan="2"> + There aren't any target sets yet + </td> + </tr> + </tbody> + + <tfoot> + <tr> + <td colspan="2"> + <button title="Add Target Set" @click="addTargetSet" v-blur> + Add Target Set + </button> + </td> + </tr> + </tfoot> + </table> + + <target-editor v-model="internalValue[selectedTargetSet]" v-if="selectedTargetSet !== null" + @close="selectedTargetSet = null"/> + + <p v-if="selectedTargetSet !== null"> + Note: time targets are ignored by the Split Calculator + </p> + </div> +</template> + +<script> +import VueFeather from 'vue-feather'; + +import storage from '@/utils/localStorage'; +import targetUtils from '@/utils/targets'; +import unitUtils from '@/utils/units'; + +import DecimalInput from '@/components/DecimalInput.vue'; +import TargetEditor from '@/components/TargetEditor.vue'; +import TimeInput from '@/components/TimeInput.vue'; + +import blur from '@/directives/blur'; + +export default { + name: 'TargetSetEditor', + + components: { + DecimalInput, + TargetEditor, + TimeInput, + VueFeather, + }, + + directives: { + blur, + }, + + data() { + return { + /** + * The internal value + */ + internalValue: storage.get('target-sets', targetUtils.defaultTargetSets), + + /** + * The target set currently being edited + */ + selectedTargetSet: null, + + /** + * The distance units + */ + distanceUnits: unitUtils.DISTANCE_UNITS, + }; + }, + + watch: { + /** + * Save target sets + */ + internalValue: { + deep: true, + handler(newValue) { + storage.set('target-sets', newValue); + }, + }, + + /** + * Sort current target set + */ + selectedTargetSet(newValue, oldValue) { + let value = newValue !== null ? newValue : oldValue; + this.internalValue[value].targets = targetUtils.sort(this.internalValue[value].targets); + }, + }, + + methods: { + /** + * Restore the default target sets + */ + reset() { + let old_sets = this.internalValue; + this.internalValue = JSON.parse(JSON.stringify(targetUtils.defaultTargetSets)); + for (let key in old_sets) { + if (!this.internalValue.hasOwnProperty(key)) { + this.internalValue[key] = old_sets[key]; + } + } + }, + + /** + * Close the target editor + */ + close() { + // Emit close event + this.$emit('close'); + }, + + /** + * Add a new target set + */ + addTargetSet() { + let key = Date.now().toString() + this.internalValue[key] = { + name: 'New target set', + targets: [], + } + this.editTargetSet(key); + }, + + /** + * Edit a target set + */ + editTargetSet(key) { + this.selectedTargetSet = key; + }, + + /** + * Remove a target set + */ + removeTargetSet(key) { + delete this.internalValue[key] + }, + }, + + /** + * Close target editor + */ + deactivated() { + this.selectedTargetSet = null; + }, +}; +</script> + +<style scoped> +/* container */ +.target-set-editor { + display: flex; + flex-direction: column; + align-items: center; +} +.target-set-editor table { + max-width: 500px; +} +h2 { + font-size: 1.3em; + margin-bottom: 0.2em; +} + +/* tables */ +.target-set-editor table th:last-child, .target-set-editor table td:last-child { + text-align: right; +} +.target-set-editor table td select { + margin-left: 0.2em; + width: 8em; +} +.target-set-editor table tfoot td { + text-align: center !important; + padding: 0.5em 0.2em; +} +.target-set-editor table tfoot button { + margin: 0.5em; +} + +/* note about split calculator */ +.target-set-editor p { + margin-top: 0.5em; +} +</style> diff --git a/src/utils/targets.js b/src/utils/targets.js @@ -16,6 +16,89 @@ function sort(targets) { ]; } +const defaultTargetSets = { + '_pace_targets': { + name: 'Common Pace Targets', + targets: [ + { result: 'time', distanceValue: 100, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 200, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 300, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 400, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 600, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 800, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1000, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1200, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1500, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1600, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 3200, distanceUnit: 'meters' }, + + { result: 'time', distanceValue: 2, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 3, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 4, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 6, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 8, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' }, + + { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 5, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 6, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 8, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 10, distanceUnit: 'miles' }, + + { result: 'time', distanceValue: 0.5, distanceUnit: 'marathons' }, + { result: 'time', distanceValue: 1, distanceUnit: 'marathons' }, + + { result: 'distance', time: 600 }, + { result: 'distance', time: 1800 }, + { result: 'distance', time: 3600 }, + ], + }, + '_race_targets': { + name: 'Common Race Targets', + targets: [ + { result: 'time', distanceValue: 400, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 800, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1500, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1600, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 3000, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 3200, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, + + { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 6, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 8, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 15, distanceUnit: 'kilometers' }, + + { result: 'time', distanceValue: 0.5, distanceUnit: 'marathons' }, + { result: 'time', distanceValue: 1, distanceUnit: 'marathons' }, + + { result: 'distance', time: 600 }, + { result: 'distance', time: 3600 }, + ], + }, + '_split_targets': { + name: '5K Mile Splits', + targets: [ + { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, + ], + }, +}; + +const defaultTargetSet = { + name: 'New target set', + targets: [], +}; + export default { sort, + defaultTargetSets, + defaultTargetSet, }; diff --git a/src/views/PaceCalculator.vue b/src/views/PaceCalculator.vue @@ -1,5 +1,5 @@ <template> - <div class="pace-calculator"> + <div class="calculator"> <h2>Input Pace</h2> <div class="input"> <div> @@ -19,28 +19,57 @@ </div> <h2>Equivalent Paces</h2> + <div class="target-set"> + Target Set: + <select v-model="selectedTargetSet"> + <option v-for="(item, index) in targetSets" :key="index" :value="index"> + {{ item.name }} + </option> + </select> + <button class="icon" title="Edit Target Sets" @click="editingTargetSets = true" v-blur> + <vue-feather type="edit"/> + </button> + </div> <simple-target-table class="output" :calculate-result="calculatePace" - :default-targets="defaultTargets" storage-key="pace-calculator-targets-v2"/> + :targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []"/> + + <Modal v-show="editingTargetSets"> + <target-set-editor @close="editingTargetSets = false"/> + </Modal> </div> </template> <script> +import VueFeather from 'vue-feather'; + import paceUtils from '@/utils/paces'; import storage from '@/utils/localStorage'; +import targetUtils from '@/utils/targets'; import unitUtils from '@/utils/units'; import DecimalInput from '@/components/DecimalInput.vue'; -import TimeInput from '@/components/TimeInput.vue'; +import Modal from '@/components/Modal.vue'; import SimpleTargetTable from '@/components/SimpleTargetTable.vue'; +import TargetSetEditor from '@/components/TargetSetEditor.vue'; +import TimeInput from '@/components/TimeInput.vue'; + +import blur from '@/directives/blur'; export default { name: 'PaceCalculator', components: { DecimalInput, - TimeInput, + Modal, SimpleTargetTable, + TargetSetEditor, + TimeInput, + VueFeather, + }, + + directives: { + blur, }, data() { @@ -66,44 +95,19 @@ export default { distanceUnits: unitUtils.DISTANCE_UNITS, /** - * The default output targets + * The current selected target set + */ + selectedTargetSet: storage.get('pace-calculator-target-set', '_pace_targets'), + + /** + * The target sets + */ + targetSets: storage.get('target-sets', targetUtils.defaultTargetSets), + + /** + * Whether the target set is being edited */ - defaultTargets: [ - { result: 'time', distanceValue: 100, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 200, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 300, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 400, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 600, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 800, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 1000, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 1200, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 1500, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 1600, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 3200, distanceUnit: 'meters' }, - - { result: 'time', distanceValue: 2, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 3, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 4, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 6, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 8, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' }, - - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 5, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 6, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 8, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 10, distanceUnit: 'miles' }, - - { result: 'time', distanceValue: 0.5, distanceUnit: 'marathons' }, - { result: 'time', distanceValue: 1, distanceUnit: 'marathons' }, - - { result: 'distance', time: 600 }, - { result: 'distance', time: 1800 }, - { result: 'distance', time: 3600 }, - ], + editingTargetSets: false, }; }, @@ -128,6 +132,22 @@ export default { inputTime(newValue) { storage.set('pace-calculator-input-time', newValue); }, + + /** + * Save the current selected target set + */ + selectedTargetSet(newValue) { + storage.set('pace-calculator-target-set', newValue); + }, + + /** + * Refresh the target sets + */ + editingTargetSets(newValue) { + if (!newValue) { + this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets); + } + } }, computed: { @@ -142,6 +162,14 @@ export default { methods: { /** + * Restore the default target set + */ + resetTargetSet() { + this.targetSets[this.selectedTargetSet] = + JSON.parse(JSON.stringify(targetUtils.defaultTargetSets[this.selectedTargetSet])); + }, + + /** * Calculate paces from a target * @param {Object} target The target * @returns {Object} The result @@ -181,42 +209,13 @@ export default { return result; }, }, + + activated() { + this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets); + }, }; </script> <style scoped> -/* container */ -.pace-calculator { - display: flex; - flex-direction: column; - align-items: center; -} - -/* headings */ -h2 { - font-size: 1.3em; - margin-bottom: 0.2em; -} -* + h2 { - margin-top: 0.5em; -} - -/* calculator input */ -.input>* { - margin-bottom: 5px; /* adds space between wrapped lines */ -} -.input select { - margin-left: 5px; -} - -/* calculator output */ -.output { - min-width: 300px; -} -@media only screen and (max-width: 500px) { - .output { - width: 100%; - min-width: 0px; - } -} +@import '@/assets/target-calculator.css'; </style> diff --git a/src/views/RaceCalculator.vue b/src/views/RaceCalculator.vue @@ -1,5 +1,5 @@ <template> - <div class="race-calculator"> + <div class="calculator"> <h2>Input Race Result</h2> <div class="input"> <div> @@ -19,7 +19,7 @@ <h2> Advanced - <button class="link" @click="showAdvancedOptions=!showAdvancedOptions"> + <button class="link" @click="showAdvancedOptions=!showAdvancedOptions" v-blur> {{ showAdvancedOptions ? '[hide]' : '[show]' }} </button> </h2> @@ -53,29 +53,58 @@ </div> <h2>Equivalent Race Results</h2> + <div class="target-set"> + Target Set: + <select v-model="selectedTargetSet"> + <option v-for="(item, index) in targetSets" :key="index" :value="index"> + {{ item.name }} + </option> + </select> + <button class="icon" title="Edit Target Sets" @click="editingTargetSets = true" v-blur> + <vue-feather type="edit"/> + </button> + </div> <simple-target-table class="output" :calculate-result="predictResult" - :default-targets="defaultTargets" storage-key="race-calculator-targets-v2" show-pace/> + :targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []" show-pace/> + + <Modal v-show="editingTargetSets"> + <target-set-editor @close="editingTargetSets = false"/> + </Modal> </div> </template> <script> +import VueFeather from 'vue-feather'; + import formatUtils from '@/utils/format'; import raceUtils from '@/utils/races'; import storage from '@/utils/localStorage'; +import targetUtils from '@/utils/targets'; import unitUtils from '@/utils/units'; import DecimalInput from '@/components/DecimalInput.vue'; -import TimeInput from '@/components/TimeInput.vue'; +import Modal from '@/components/Modal.vue'; import SimpleTargetTable from '@/components/SimpleTargetTable.vue'; +import TargetSetEditor from '@/components/TargetSetEditor.vue'; +import TimeInput from '@/components/TimeInput.vue'; + +import blur from '@/directives/blur'; export default { name: 'RaceCalculator', components: { DecimalInput, - TimeInput, + Modal, SimpleTargetTable, + TargetSetEditor, + TimeInput, + VueFeather, + }, + + directives: { + blur, }, data() { @@ -121,40 +150,32 @@ export default { formatNumber: formatUtils.formatNumber, /** - * The default output targets + * The current selected target set */ - defaultTargets: [ - { result: 'time', distanceValue: 400, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 800, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 1000, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 1200, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 1500, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 1600, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 3200, distanceUnit: 'meters' }, - - { result: 'time', distanceValue: 3, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 8, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 15, distanceUnit: 'kilometers' }, - - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 5, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 10, distanceUnit: 'miles' }, - - { result: 'time', distanceValue: 0.5, distanceUnit: 'marathons' }, - { result: 'time', distanceValue: 1, distanceUnit: 'marathons' }, - - { result: 'distance', time: 600 }, - { result: 'distance', time: 3600 }, - ], + selectedTargetSet: storage.get('race-calculator-target-set', '_race_targets'), + + /** + * The target sets + */ + targetSets: storage.get('target-sets', targetUtils.defaultTargetSets), + + /** + * Whether the target set is being edited + */ + editingTargetSets: false, }; }, methods: { /** + * Restore the default target set + */ + resetTargetSet() { + this.targetSets[this.selectedTargetSet].targets = + JSON.parse(JSON.stringify(targetUtils.defaultTargetSets[this.selectedTargetSet].targets)); + }, + + /** * Predict race results from a target * @param {Object} target The target * @returns {Object} The result @@ -319,48 +340,35 @@ export default { showAdvancedOptions(newValue) { storage.set('race-calculator-show-advanced-options', newValue); }, + + /** + * Save the current selected target set + */ + selectedTargetSet(newValue) { + storage.set('pace-calculator-target-set', newValue); + }, + + /** + * Refresh the target sets + */ + editingTargetSets(newValue) { + if (!newValue) { + this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets); + } + } + }, + + activated() { + this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets); }, }; </script> <style scoped> -/* container */ -.race-calculator { - display: flex; - flex-direction: column; - align-items: center; -} - -/* headings */ -h2 { - font-size: 1.3em; - margin-bottom: 0.2em; -} -* + h2 { - margin-top: 0.5em; -} - -/* calculator input */ -.input>* { - margin-bottom: 5px; /* adds space between wrapped lines */ -} -.input select { - margin-left: 5px; -} +@import '@/assets/target-calculator.css'; /* advanced options */ .advanced-options>* { margin-bottom: 5px; } - -/* calculator output */ -.output { - min-width: 300px; -} -@media only screen and (max-width: 500px) { - .output { - width: 100%; - min-width: 0px; - } -} </style> diff --git a/src/views/SplitCalculator.vue b/src/views/SplitCalculator.vue @@ -1,7 +1,19 @@ <template> - <div class="split-calculator"> + <div class="calculator"> + <div class="target-set"> + Target Set: + <select v-model="selectedTargetSet"> + <option v-for="(item, index) in targetSets" :key="index" :value="index"> + {{ item.name }} + </option> + </select> + <button class="icon" title="Edit Target Sets" @click="editingTargetSets = true" v-blur> + <vue-feather type="edit"/> + </button> + </div> + <div class="output"> - <table class="results" v-show="!inEditMode"> + <table class="results"> <thead> <tr> <th> @@ -14,12 +26,6 @@ <th>Split</th> <th>Pace</th> - - <th> - <button class="icon" title="Edit Targets" @click="inEditMode=true" v-blur> - <vue-feather type="edit"/> - </button> - </th> </tr> </thead> @@ -34,30 +40,28 @@ {{ formatDuration(item.totalTime, 3, 2, true) }} </td> - <td> - <time-input v-model="targets[index].split" :showHours="false"/> + <td v-if="targetSets[selectedTargetSet]"> + <time-input v-model="targetSets[selectedTargetSet].targets[index].split" :showHours="false"/> </td> - <td colspan="2"> + <td> {{ formatDuration(item.pace, 3, 0, true) }} / {{ distanceUnits[getDefaultDistanceUnit()].symbol }} </td> </tr> - <tr v-if="targets.length === 0" class="empty-message"> + <tr v-if="!targetSets[selectedTargetSet] || targetSets[selectedTargetSet].targets.length === 0" class="empty-message"> <td colspan="5"> - There aren't any targets yet,<br> - click - <vue-feather type="edit"/> - to edit the list of targets + There aren't any targets in this set yet. </td> </tr> </tbody> </table> - - <target-editor v-model="targets" :time-targets="false" v-show="inEditMode" - @close="inEditMode=false" @reset="resetTargets"/> </div> + + <Modal v-show="editingTargetSets"> + <target-set-editor @close="editingTargetSets = false"/> + </Modal> </div> </template> @@ -69,24 +73,19 @@ import storage from '@/utils/localStorage'; import targetUtils from '@/utils/targets'; import unitUtils from '@/utils/units'; +import Modal from '@/components/Modal.vue'; +import TargetSetEditor from '@/components/TargetSetEditor.vue'; import TimeInput from '@/components/TimeInput.vue'; -import TargetEditor from '@/components/TargetEditor.vue'; import blur from '@/directives/blur'; -const defaultTargets = [ - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, -]; -const storageKey = 'split-calculator-targets-v2'; - export default { name: 'SplitCalculator', components: { + Modal, + TargetSetEditor, TimeInput, - TargetEditor, VueFeather, }, @@ -117,17 +116,40 @@ export default { getDefaultDistanceUnit: unitUtils.getDefaultDistanceUnit, /** - * Whether the table is in edit mode + * The current selected target set + */ + selectedTargetSet: storage.get('split-calculator-target-set', '_split_targets'), + + /** + * The default output targets */ - inEditMode: false, + targetSets: storage.get('target-sets', targetUtils.defaultTargetSets), /** - * The target table targets + * Whether the target set is being edited */ - targets: storage.get(storageKey, defaultTargets), + editingTargetSets: false, }; }, + watch: { + /** + * Save the current selected target set + */ + selectedTargetSet(newValue) { + storage.set('split-calculator-target-set', newValue); + }, + + /** + * Refresh the target sets + */ + editingTargetSets(newValue) { + if (!newValue) { + this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets); + } + } + }, + computed: { /** * The target table results @@ -136,29 +158,36 @@ export default { // Initialize results array const results = []; - for (let i = 0; i < this.targets.length; i += 1) { - // Calculate split and total times - const splitTime = this.targets[i].split || 0; - const totalTime = i === 0 ? splitTime : results[i - 1].totalTime + splitTime; - - // Calculate split and total distances - const totalDistance = unitUtils.convertDistance(this.targets[i].distanceValue, - this.targets[i].distanceUnit, 'meters'); - const splitDistance = i === 0 ? totalDistance : totalDistance - results[i - 1].distance; - - // Calculate pace - const pace = splitTime / unitUtils.convertDistance(splitDistance, 'meters', - unitUtils.getDefaultDistanceUnit()); - - // Add row to results array - results.push({ - distance: totalDistance, - distanceValue: this.targets[i].distanceValue, - distanceUnit: this.targets[i].distanceUnit, - totalTime, - splitTime, - pace, - }); + // Check for missing target set + if (!this.targetSets[this.selectedTargetSet]) return []; + + for (let i = 0; i < this.targetSets[this.selectedTargetSet].targets.length; i += 1) { + if (this.targetSets[this.selectedTargetSet].targets[i].result === 'time') { + // Calculate split and total times + const splitTime = this.targetSets[this.selectedTargetSet].targets[i].split || 0; + const totalTime = i === 0 ? splitTime : results[i - 1].totalTime + splitTime; + + // Calculate split and total distances + const totalDistance = unitUtils.convertDistance( + this.targetSets[this.selectedTargetSet].targets[i].distanceValue, + this.targetSets[this.selectedTargetSet].targets[i].distanceUnit, 'meters', + ); + const splitDistance = i === 0 ? totalDistance : totalDistance - results[i - 1].distance; + + // Calculate pace + const pace = splitTime / unitUtils.convertDistance(splitDistance, 'meters', + unitUtils.getDefaultDistanceUnit()); + + // Add row to results array + results.push({ + distance: totalDistance, + distanceValue: this.targetSets[this.selectedTargetSet].targets[i].distanceValue, + distanceUnit: this.targetSets[this.selectedTargetSet].targets[i].distanceUnit, + totalTime, + splitTime, + pace, + }); + } } // Return results array @@ -166,74 +195,37 @@ export default { }, }, - watch: { - /** - * Sort targets - */ - inEditMode() { - this.targets = targetUtils.sort(this.targets); - }, - - /** - * Save targets - */ - targets: { - handler(newValue) { - if (storageKey !== null) { - storage.set(storageKey, newValue); - } - }, - deep: true, - }, - }, - methods: { /** - * Restore the default targets + * Restore the default target set */ - resetTargets() { - // Clone default targets array - this.targets = JSON.parse(JSON.stringify(defaultTargets)); - - // Sort targets - this.targets = targetUtils.sort(this.targets); + resetTargetSet() { + this.targetSets[this.selectedTargetSet] = + JSON.parse(JSON.stringify(targetUtils.defaultTargetSets[this.selectedTargetSet])); }, }, - /** - * Close edit targets table - */ - deactivated() { - this.inEditMode = false; + activated() { + this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets); }, }; </script> <style scoped> -/* container */ -.split-calculator { - display: flex; - flex-direction: column; - align-items: center; -} +@import '@/assets/target-calculator.css'; -/* target table */ -.results th:last-child { - text-align: right; +/* Widen default calculator output */ +@media only screen and (min-width: 501px) { + .output { + min-width: 400px; + } } + +/* Show/hide mobile abbreviations */ .results th:first-child span.mobile-abbreviation { display: none; } - -/* calculator output */ -.output { - min-width: 400px; -} @media only screen and (max-width: 500px) { - .output { - width: 100%; - min-width: 0px; - } .results th:first-child span:not(.mobile-abbreviation) { display: none; } diff --git a/tests/unit/components/SimpleTargetTable.spec.js b/tests/unit/components/SimpleTargetTable.spec.js @@ -13,7 +13,7 @@ test('results should be correct and sorted by time', () => { distanceUnit: row.distanceUnit, time: row.distanceValue + 1, }), - defaultTargets: [ + targets: [ { distanceValue: 20, distanceUnit: 'meters' }, { distanceValue: 100, distanceUnit: 'meters' }, { distanceValue: 1, distanceUnit: 'kilometers' }, diff --git a/tests/unit/components/TargetEditor.spec.js b/tests/unit/components/TargetEditor.spec.js @@ -6,31 +6,60 @@ import TargetEditor from '@/components/TargetEditor.vue'; test('addDistanceTarget method should correctly add distance target', async () => { // Initialize component - const wrapper = shallowMount(TargetEditor); + const wrapper = mount(TargetEditor, { + propsData: { + modelValue: { + name: 'My target set', + targets: [ + { distanceUnit: 'miles', distanceValue: 0, result: 'time' }, + { time: 0, result: 'distance' }, + ], + }, + }, + }); // Add distance target await wrapper.vm.addDistanceTarget(); // Assert input event was emitted expect(wrapper.emitted()['update:modelValue']).to.deep.equal([ - [[ - { distanceUnit: 'miles', distanceValue: 1, result: 'time' }, - ]], + [{ + name: 'My target set', + targets: [ + { distanceUnit: 'miles', distanceValue: 0, result: 'time' }, + { time: 0, result: 'distance' }, + { distanceUnit: 'miles', distanceValue: 1, result: 'time'}, + ], + }], ]); }); test('addTimeTarget method should correctly add time target', async () => { // Initialize component - const wrapper = shallowMount(TargetEditor); + const wrapper = mount(TargetEditor, { + propsData: { + modelValue: { + name: 'My target set', + targets: [ + { distanceUnit: 'miles', distanceValue: 0, result: 'time' }, + { time: 0, result: 'distance' }, + ], + }, + }, + }); // Add time target await wrapper.vm.addTimeTarget(); // Assert input event was emitted expect(wrapper.emitted()['update:modelValue']).to.deep.equal([ - [[ - { time: 600, result: 'distance' }, - ]], + [{ name: 'My target set', + targets: [ + { distanceUnit: 'miles', distanceValue: 0, result: 'time' }, + { time: 0, result: 'distance' }, + { time: 600, result: 'distance' }, + ], + }], ]); }); @@ -38,9 +67,12 @@ test('should emit input event when targets are updated', async () => { // Initialize component const wrapper = mount(TargetEditor, { propsData: { - modelValue: [ - { distanceUnit: 'miles', distanceValue: 2, result: 'time' }, - ], + modelValue: { + name: 'My target set', + targets: [ + { distanceUnit: 'miles', distanceValue: 2, result: 'time' }, + ], + }, }, }); @@ -49,9 +81,43 @@ test('should emit input event when targets are updated', async () => { // Assert input event was emitted expect(wrapper.emitted()['update:modelValue']).to.deep.equal([ - [[ - { distanceUnit: 'miles', distanceValue: 3, result: 'time' }, - ]], + [ + { + name: 'My target set', + targets: [ + { distanceUnit: 'miles', distanceValue: 3, result: 'time' }, + ], + }, + ], + ]); +}); + +test('should emit input event when target set name is updated', async () => { + // Initialize component + const wrapper = mount(TargetEditor, { + propsData: { + modelValue: { + name: 'My target set', + targets: [ + { distanceUnit: 'miles', distanceValue: 2, result: 'time' }, + ], + }, + }, + }); + + // Update distance value + await wrapper.find('thead input').setValue('My target set #2'); + + // Assert input event was emitted + expect(wrapper.emitted()['update:modelValue']).to.deep.equal([ + [ + { + name: 'My target set #2', + targets: [ + { distanceUnit: 'miles', distanceValue: 2, result: 'time' }, + ], + }, + ], ]); }); @@ -59,11 +125,14 @@ test('removeTarget method should correctly remove target', async () => { // Initialize component const wrapper = shallowMount(TargetEditor, { propsData: { - modelValue: [ - { distanceUnit: 'miles', distanceValue: 1, result: 'time' }, - { distanceUnit: 'miles', distanceValue: 2, result: 'time' }, - { distanceUnit: 'miles', distanceValue: 3, result: 'time' }, - ], + modelValue: { + name: 'My target set', + targets: [ + { distanceUnit: 'miles', distanceValue: 1, result: 'time' }, + { distanceUnit: 'miles', distanceValue: 2, result: 'time' }, + { distanceUnit: 'miles', distanceValue: 3, result: 'time' }, + ], + }, }, }); @@ -72,9 +141,12 @@ test('removeTarget method should correctly remove target', async () => { // Assert input event was emitted expect(wrapper.emitted()['update:modelValue']).to.deep.equal([ - [[ - { distanceUnit: 'miles', distanceValue: 1, result: 'time' }, - { distanceUnit: 'miles', distanceValue: 3, result: 'time' }, - ]], + [{ + name: 'My target set', + targets: [ + { distanceUnit: 'miles', distanceValue: 1, result: 'time' }, + { distanceUnit: 'miles', distanceValue: 3, result: 'time' }, + ], + }], ]); }); diff --git a/tests/unit/components/TargetSetEditor.spec.js b/tests/unit/components/TargetSetEditor.spec.js @@ -0,0 +1,154 @@ +/* eslint-disable no-underscore-dangle */ + +import { test, expect } from 'vitest'; +import { shallowMount, mount } from '@vue/test-utils'; +import TargetSetEditor from '@/components/TargetSetEditor.vue'; +import targetUtils from '@/utils/targets'; + +test('addTargetSet method should correctly add target set', async () => { + // Initialize component + const wrapper = mount(TargetSetEditor, { + data() { + return { + internalValue: { + 'A': { + name: '1st target set', + targets: [ + { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, + ], + }, + }, + }; + }, + }); + + // Add target set + await wrapper.vm.addTargetSet(); + + // Assert target set was added + expect(wrapper.vm.internalValue['A']).to.deep.equal({ + name: '1st target set', + targets: [ + { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, + ], + }); + const keys = Object.keys(wrapper.vm.internalValue) + expect(keys.length).to.equal(2); + expect(wrapper.vm.internalValue[keys[1]]).to.deep.equal({ + name: 'New target set', + targets: [], + }); + + // Assert new target set was selected + expect(wrapper.vm.selectedTargetSet).to.equal(keys[1]); +}); + +test('reset method should correctly reset target sets', async () => { + // Initialize component + const wrapper = mount(TargetSetEditor, { + data() { + return { + internalValue: { + 'A': { + name: '1st target set', + targets: [ + { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, + ], + }, + '_split_targets': { + name: '5K Kilometer Splits', + targets: [ + { result: 'time', distanceValue: 2, distanceUnit: 'Kilometer' }, + { result: 'time', distanceValue: 4, distanceUnit: 'Kilometer' }, + { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, + ], + }, + }, + }; + }, + }); + + // Reset target sets + await wrapper.vm.reset(); + + // Assert target sets were reset + expect(wrapper.vm.internalValue).to.deep.equal({ + // Default target sets should be restored + ...targetUtils.defaultTargetSets, + + // Custom target sets should be kept + 'A': { + name: '1st target set', + targets: [ + { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, + ], + }, + }); +}); + +test('removeTargetSet method should correctly remove target set', async () => { + // Initialize component + const wrapper = mount(TargetSetEditor, { + data() { + return { + internalValue: { + 'A': { + name: '1st target set', + targets: [ + { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, + ], + }, + 'B': { + name: '2nd target set', + targets: [ + { result: 'time', distanceValue: 4, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 5, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 6, distanceUnit: 'miles' }, + ], + }, + 'C': { + name: '3rd target set', + targets: [ + { result: 'time', distanceValue: 7, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 8, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 9, distanceUnit: 'miles' }, + ], + }, + }, + }; + }, + }); + + // Remove 2nd target set + await wrapper.vm.removeTargetSet('B'); + + // Assert target set was removed + expect(wrapper.vm.internalValue).to.deep.equal({ + 'A': { + name: '1st target set', + targets: [ + { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, + ], + }, + 'C': { + name: '3rd target set', + targets: [ + { result: 'time', distanceValue: 7, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 8, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 9, distanceUnit: 'miles' }, + ], + }, + }); +}); diff --git a/tests/unit/views/SplitCalculator.spec.js b/tests/unit/views/SplitCalculator.spec.js @@ -7,15 +7,21 @@ import unitUtils from '@/utils/units'; test('should correctly calculate split paces and total times', async () => { // Initialize component - const wrapper = shallowMount(SplitCalculator); - - // Override input values - await wrapper.setData({ - targets: [ - { result: 'time', distanceValue: 2, distanceUnit: 'miles', split: 60 }, - { result: 'time', distanceValue: 4, distanceUnit: 'miles', split: 70 }, - { result: 'time', distanceValue: 10, distanceUnit: 'kilometers', split: 80 }, - ], + const wrapper = shallowMount(SplitCalculator, { + data() { + return { + targetSets: { + '_split_targets': { + name: 'Split targets', + targets: [ + { result: 'time', distanceValue: 2, distanceUnit: 'miles', split: 60 }, + { result: 'time', distanceValue: 4, distanceUnit: 'miles', split: 70 }, + { result: 'time', distanceValue: 10, distanceUnit: 'kilometers', split: 80 }, + ], + }, + }, + }; + }, }); // Assert results are correct