running-tools

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

commit 19380a53031832c5e58d30b9383dfb618b83ec1e
parent 902e2284c04f461ed3d3b3b9edd20607ea6de7e1
Author: ashermorgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Wed, 10 Nov 2021 16:36:29 -0800

Merge pull request #3 from ashermorgan/split-calculator

Add Split Calculator
Diffstat:
Msrc/assets/global.css | 14++++++++++++++
Asrc/components/SimpleTargetTable.vue | 232+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/TargetEditor.vue | 224+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/components/TargetTable.vue | 342-------------------------------------------------------------------------------
Msrc/components/TimeInput.vue | 23+++++++++++++++++++----
Msrc/router/index.js | 10++++++++++
Asrc/utils/targets.js | 21+++++++++++++++++++++
Msrc/views/Home.vue | 9+++++++--
Msrc/views/PaceCalculator.vue | 8++++----
Msrc/views/RaceCalculator.vue | 8++++----
Asrc/views/SplitCalculator.vue | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/components/SimpleTargetTable.spec.js | 34++++++++++++++++++++++++++++++++++
Atests/unit/components/TargetEditor.spec.js | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dtests/unit/components/TargetTable.spec.js | 34----------------------------------
Mtests/unit/components/TimeInput.spec.js | 32++++++++++++++++++++++++++++++--
Atests/unit/utils/targets.spec.js | 23+++++++++++++++++++++++
16 files changed, 943 insertions(+), 392 deletions(-)

diff --git a/src/assets/global.css b/src/assets/global.css @@ -31,6 +31,20 @@ a:focus, .link:focus { } } +/* styles for tables */ +table { + border-collapse: collapse; + width: 100%; + text-align: left; +} +table th, table td { + padding: 0.2em; +} +table button.icon { + height: 2em; + width: 2em; +} + /* styles for icons */ .icon { border: none; diff --git a/src/components/SimpleTargetTable.vue b/src/components/SimpleTargetTable.vue @@ -0,0 +1,232 @@ +<template> + <div class="simple-target-table"> + <table class="results" v-show="!inEditMode"> + <thead> + <tr> + <th>Distance</th> + + <th>Time</th> + + <th v-if="showPace">Pace</th> + + <th> + <button class="icon" title="Edit Targets" @click="inEditMode=true" v-blur> + <edit-icon/> + </button> + </th> + </tr> + </thead> + + <tbody> + <tr v-for="(item, index) in results" :key="index"> + <td :class="item.result === 'distance' ? 'result' : ''"> + {{ item.distanceValue.toFixed(2) }} + {{ distanceUnits[item.distanceUnit].symbol }} + </td> + + <td :colspan="showPace ? 1 : 2" :class="item.result === 'time' ? 'result' : ''"> + {{ formatDuration(item.time, 0, 2) }} + </td> + + <td v-if="showPace" colspan="2"> + {{ formatDuration(getPace(item), 0, 0) }} + / {{ distanceUnits[getDefaultDistanceUnit()].symbol }} + </td> + </tr> + + <tr v-if="results.length === 0" class="empty-message"> + <td colspan="4"> + There aren't any targets yet,<br> + click + <edit-icon/> + to edit the list of targets + </td> + </tr> + </tbody> + </table> + + <target-editor v-show="inEditMode" v-model="targets" @close="inEditMode=false" + @reset="resetTargets"/> + </div> +</template> + +<script> +import { + EditIcon, +} from 'vue-feather-icons'; + +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, + + EditIcon, + }, + + directives: { + blur, + }, + + props: { + /** + * The method that generates the target table rows + */ + calculateResult: { + type: Function, + required: true, + }, + + /** + * The default targets + */ + defaultTargets: { + type: Array, + default: () => [], + }, + + /** + * The localStorage key for the list of targets + */ + storageKey: { + type: String, + default: null, + }, + + /** + * Whether to show result paces + */ + showPace: { + type: Boolean, + default: false, + }, + }, + + data() { + return { + /** + * The distance units + */ + distanceUnits: unitUtils.DISTANCE_UNITS, + + /** + * The formatDuration method + */ + formatDuration: unitUtils.formatDuration, + + /** + * 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), + }; + }, + + computed: { + /** + * The target table results + */ + results() { + // Calculate results + const result = []; + this.targets.forEach((row) => { + // Add result + result.push(this.calculateResult(row)); + }); + + // Sort results by time + result.sort((a, b) => a.time - b.time); + + // Return results + return result; + }, + }, + + 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 + */ + getPace(result) { + return result.time / unitUtils.convertDistance(result.distanceValue, result.distanceUnit, + 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; +} + +/* empty table message */ +.empty-message td { + text-align: center !important; +} +.empty-message svg { + height: 1em; + width: 1em; + color: var(--foreground); +} +</style> diff --git a/src/components/TargetEditor.vue b/src/components/TargetEditor.vue @@ -0,0 +1,224 @@ +<template> + <table class="target-editor"> + <thead> + <tr> + <th>Edit Targets</th> + + <th> + <button class="icon" title="Reset Targets" @click="reset" v-blur> + <rotate-ccw-icon/> + </button> + <button class="icon" title="Close" @click="close" v-blur> + <x-icon/> + </button> + </th> + </tr> + </thead> + + <tbody> + <tr v-for="(item, index) in internalValue" :key="index"> + <td v-if="item.result === 'time'"> + <decimal-input v-model="item.distanceValue" aria-label="Distance Value" + :min="0" :digits="2"/> + <select v-model="item.distanceUnit" aria-label="Distance Unit"> + <option v-for="(value, key) in distanceUnits" :key="key" :value="key"> + {{ value.name }} + </option> + </select> + </td> + + <td v-else> + <time-input v-model="item.time" aria-label="Time"/> + </td> + + <td> + <button class="icon" title="Remove Target" @click="removeTarget(index)" v-blur> + <trash-2-icon/> + </button> + </td> + </tr> + + <tr v-if="internalValue.length === 0" class="empty-message"> + <td colspan="2"> + There aren't any targets yet + </td> + </tr> + </tbody> + + <tfoot> + <tr> + <td colspan="2"> + <button title="Add Distance Target" @click="addDistanceTarget" v-blur> + Add distance target + </button> + <button v-if="timeTargets" title="Add Time Target" @click="addTimeTarget" v-blur> + Add time target + </button> + </td> + </tr> + </tfoot> + </table> +</template> + +<script> +import { + RotateCcwIcon, + Trash2Icon, + XIcon, +} from 'vue-feather-icons'; + +import unitUtils from '@/utils/units'; + +import DecimalInput from '@/components/DecimalInput.vue'; +import TimeInput from '@/components/TimeInput.vue'; + +import blur from '@/directives/blur'; + +export default { + name: 'TargetEditor', + + components: { + DecimalInput, + TimeInput, + + RotateCcwIcon, + Trash2Icon, + XIcon, + }, + + directives: { + blur, + }, + + props: { + /** + * The targets + */ + value: { + type: Array, + default: () => [], + }, + + /** + * Whether to allow the user to add time based targets + */ + timeTargets: { + type: Boolean, + default: true, + }, + }, + + data() { + return { + /** + * The internal value + */ + internalValue: this.value, + + /** + * The distance units + */ + distanceUnits: unitUtils.DISTANCE_UNITS, + }; + }, + + watch: { + /** + * Update the component value when the value prop changes + * @param {Number} newValue The new prop value + */ + value: { + deep: true, + handler(newValue) { + this.internalValue = newValue; + }, + }, + + /** + * Emit the input event when the component value changes + * @param {Number} newValue The new component value + */ + internalValue: { + deep: true, + handler(newValue) { + this.$emit('input', newValue); + }, + }, + }, + + methods: { + /** + * Restore the default targets + */ + reset() { + // Emit reset event + this.$emit('reset'); + }, + + /** + * Close the target editor + */ + close() { + // Emit close event + this.$emit('close'); + }, + + /** + * Add a new distance based target + */ + addDistanceTarget() { + this.internalValue.push({ + result: 'time', + distanceValue: 1, + distanceUnit: unitUtils.getDefaultDistanceUnit(), + }); + }, + + /** + * Add a new time based target + */ + addTimeTarget() { + this.internalValue.push({ + result: 'distance', + time: 600, + }); + }, + + /** + * Remove a target + * @param {Number} index The index of the target + */ + removeTarget(index) { + this.internalValue.splice(index, 1); + }, + }, +}; +</script> + +<style scoped> +/* edit targets table */ +.target-editor th:last-child, .target-editor td:last-child { + text-align: right; +} +.target-editor td select { + margin-left: 0.2em; + width: 8em; +} +.target-editor tfoot td { + text-align: center !important; + padding: 0.5em 0.2em; +} +.target-editor tfoot button { + margin: 0.5em; +} + +/* empty table message */ +.empty-message td { + text-align: center !important; +} +.empty-message svg { + height: 1em; + width: 1em; + color: var(--foreground); +} +</style> diff --git a/src/components/TargetTable.vue b/src/components/TargetTable.vue @@ -1,342 +0,0 @@ -<template> - <div class="target-table"> - <table class="results" v-show="!inEditMode"> - <thead> - <tr> - <th>Distance</th> - - <th>Time</th> - - <th v-if="showPace">Pace</th> - - <th> - <button class="icon" title="Edit Targets" @click="inEditMode=true" v-blur> - <edit-icon/> - </button> - </th> - </tr> - </thead> - - <tbody> - <tr v-for="(item, index) in results" :key="index"> - <td :class="item.result === 'distance' ? 'result' : ''"> - {{ item.distanceValue.toFixed(2) }} - {{ distanceUnits[item.distanceUnit].symbol }} - </td> - - <td :colspan="showPace ? 1 : 2" :class="item.result === 'time' ? 'result' : ''"> - {{ formatDuration(item.time, 0, 2) }} - </td> - - <td v-if="showPace" colspan="2"> - {{ formatDuration(getPace(item), 0, 0) }} - / {{ distanceUnits[getDefaultDistanceUnit()].symbol }} - </td> - </tr> - - <tr v-if="results.length === 0" class="empty-message"> - <td colspan="4"> - There aren't any targets yet,<br> - click - <edit-icon/> - to edit the list of targets - </td> - </tr> - </tbody> - </table> - - <table class="targets" v-show="inEditMode"> - <thead> - <tr> - <th>Edit Targets</th> - - <th> - <button class="icon" title="Reset Targets" @click="resetTargets" v-blur> - <rotate-ccw-icon/> - </button> - <button class="icon" title="Close" @click="inEditMode=false" v-blur> - <x-icon/> - </button> - </th> - </tr> - </thead> - - <tbody> - <tr v-for="(item, index) in targets" :key="index"> - <td v-if="item.result === 'time'"> - <decimal-input v-model="item.distanceValue" aria-label="Distance Value" - :min="0" :digits="2"/> - <select v-model="item.distanceUnit" aria-label="Distance Unit"> - <option v-for="(value, key) in distanceUnits" :key="key" :value="key"> - {{ value.name }} - </option> - </select> - </td> - - <td v-else> - <time-input v-model="item.time" aria-label="Time"/> - </td> - - <td> - <button class="icon" title="Remove Target" @click="targets.splice(index, 1)" v-blur> - <trash-2-icon/> - </button> - </td> - </tr> - - <tr v-if="targets.length === 0" class="empty-message"> - <td colspan="2"> - There aren't any targets yet - </td> - </tr> - </tbody> - - <tfoot> - <tr> - <td colspan="2"> - <button title="Add Distance Target" @click="targets.push({ result: 'time', - distanceValue: 1, distanceUnit: getDefaultDistanceUnit() })" v-blur> - Add distance target - </button> - <button title="Add Time Target" @click="targets.push({ result: 'distance', - time: 600 })" v-blur> - Add time target - </button> - </td> - </tr> - </tfoot> - </table> - </div> -</template> - -<script> -import { - EditIcon, - RotateCcwIcon, - Trash2Icon, - XIcon, -} from 'vue-feather-icons'; - -import unitUtils from '@/utils/units'; -import storage from '@/utils/localStorage'; - -import DecimalInput from '@/components/DecimalInput.vue'; -import TimeInput from '@/components/TimeInput.vue'; - -import blur from '@/directives/blur'; - -export default { - name: 'TargetTable', - - components: { - DecimalInput, - TimeInput, - - EditIcon, - RotateCcwIcon, - Trash2Icon, - XIcon, - }, - - directives: { - blur, - }, - - props: { - /** - * The method that generates the time table rows - */ - calculateResult: { - type: Function, - required: true, - }, - - /** - * The default time table targets - */ - defaultTargets: { - type: Array, - default: () => [], - }, - - /** - * The localStorage key for the list of targets - */ - storageKey: { - type: String, - default: null, - }, - - /** - * Whether to show result paces - */ - showPace: { - type: Boolean, - default: false, - }, - }, - - data() { - return { - /** - * The distance units - */ - distanceUnits: unitUtils.DISTANCE_UNITS, - - /** - * The formatDuration method - */ - formatDuration: unitUtils.formatDuration, - - /** - * The getDefaultDistanceUnit method - */ - getDefaultDistanceUnit: unitUtils.getDefaultDistanceUnit, - - /** - * Whether the table is in edit mode - */ - inEditMode: false, - - /** - * The time table targets - */ - targets: storage.get(this.storageKey, this.defaultTargets), - }; - }, - - computed: { - /** - * The time table results - */ - results() { - // Calculate results - const result = []; - this.targets.forEach((row) => { - // Add result - result.push(this.calculateResult(row)); - }); - - // Sort results by time - result.sort((a, b) => a.time - b.time); - - // Return results - return result; - }, - }, - - watch: { - /** - * Sort targets - */ - inEditMode() { - this.sortTargets(); - }, - - /** - * 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.sortTargets(); - }, - - /** - * Sort the targets by distance - */ - sortTargets() { - this.targets = [ - ...this.targets.filter((item) => item.result === 'time') - .sort((a, b) => unitUtils.convertDistance(a.distanceValue, a.distanceUnit, 'meters') - - unitUtils.convertDistance(b.distanceValue, b.distanceUnit, 'meters')), - - ...this.targets.filter((item) => item.result === 'distance') - .sort((a, b) => a.time - b.time), - ]; - }, - - /** - * Get the pace of a result - * @param {Object} result The result - */ - getPace(result) { - return result.time / unitUtils.convertDistance(result.distanceValue, result.distanceUnit, - 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; -} - -/* edit targets table */ -.targets th:last-child, .targets td:last-child { - text-align: right; -} -.targets td select { - margin-left: 0.2em; - width: 8em; -} -.targets tfoot td { - text-align: center !important; - padding: 0.5em 0.2em; -} -.targets tfoot button { - margin: 0.5em; -} - -/* general table styles */ -table { - border-collapse: collapse; - width: 100%; - text-align: left; -} -table th, table td { - padding: 0.2em; -} -table button.icon { - height: 2em; - width: 2em; -} - -/* empty table message */ -.empty-message td { - text-align: center !important; -} -.empty-message svg { - height: 1em; - width: 1em; - color: var(--foreground); -} -</style> diff --git a/src/components/TimeInput.vue b/src/components/TimeInput.vue @@ -1,9 +1,9 @@ <template> <div class="time-input"> - <int-input class="hours" aria-label="hours" + <int-input class="hours" aria-label="hours" v-if="showHours" :min="0" :max="99" :padding="1" v-model="hours" :arrow-keys="false" @keydown="onkeydown($event, 3600)"/> - <span>:</span> + <span v-if="showHours">:</span> <int-input class="minutes" aria-label="minutes" :min="0" :max="59" :padding="2" v-model="minutes" :arrow-keys="false" @keydown="onkeydown($event, 60)"/> @@ -37,6 +37,14 @@ export default { return value >= 0 && value <= 359999.99; }, }, + + /** + * Whether to show the hour field + */ + showHours: { + type: Boolean, + default: true, + }, }, data() { @@ -50,6 +58,13 @@ export default { computed: { /** + * The maximum value + */ + max() { + return this.showHours ? 359999.99 : 3599.99; + }, + + /** * The value of the hours field */ hours: { @@ -113,8 +128,8 @@ export default { */ onkeydown(e, step = 1) { if (e.key === 'ArrowUp') { - if (this.internalValue + step > 359999.99) { - this.internalValue = 359999.99; + if (this.internalValue + step > this.max) { + this.internalValue = this.max; } else { this.internalValue += step; } diff --git a/src/router/index.js b/src/router/index.js @@ -3,6 +3,7 @@ import VueRouter from 'vue-router'; import Home from '../views/Home.vue'; import PaceCalculator from '../views/PaceCalculator.vue'; import RaceCalculator from '../views/RaceCalculator.vue'; +import SplitCalculator from '../views/SplitCalculator.vue'; import UnitCalculator from '../views/UnitCalculator.vue'; Vue.use(VueRouter); @@ -44,6 +45,15 @@ const routes = [ }, }, { + path: '/calculate/splits', + name: 'calculate-splits', + component: SplitCalculator, + meta: { + title: 'Split Calculator', + back: 'home', + }, + }, + { path: '/calculate/units', name: 'calculate-units', component: UnitCalculator, diff --git a/src/utils/targets.js b/src/utils/targets.js @@ -0,0 +1,21 @@ +import unitUtils from '@/utils/units'; + +/** + * Sort an array of targets + * @param {Array} targets The array of targets + * @returns {Array} The sorted targets + */ +function sort(targets) { + return [ + ...targets.filter((item) => item.result === 'time') + .sort((a, b) => unitUtils.convertDistance(a.distanceValue, a.distanceUnit, 'meters') + - unitUtils.convertDistance(b.distanceValue, b.distanceUnit, 'meters')), + + ...targets.filter((item) => item.result === 'distance') + .sort((a, b) => a.time - b.time), + ]; +} + +export default { + sort, +}; diff --git a/src/views/Home.vue b/src/views/Home.vue @@ -14,6 +14,11 @@ Race Calculator </button> </router-link> + <router-link :to="{ name: 'calculate-splits' }" v-slot="{ navigate }" custom> + <button @click="navigate"> + Split Calculator + </button> + </router-link> <router-link :to="{ name: 'calculate-units' }" v-slot="{ navigate }" custom> <button @click="navigate"> Unit Calculator @@ -32,7 +37,7 @@ export default { <style scoped> .home { text-align: center; - max-width: 600px; + max-width: 700px; margin: auto; } .description { @@ -49,7 +54,7 @@ export default { padding: 0.5em; margin: 0em 0.3em; } -@media only screen and (max-width: 550px) { +@media only screen and (max-width: 600px) { .calculators { flex-direction: column; } diff --git a/src/views/PaceCalculator.vue b/src/views/PaceCalculator.vue @@ -20,8 +20,8 @@ <h2>Equivalent Paces</h2> - <target-table class="output" :calculate-result="calculatePace" :default-targets="defaultTargets" - storage-key="pace-calculator-targets-v2"/> + <simple-target-table class="output" :calculate-result="calculatePace" + :default-targets="defaultTargets" storage-key="pace-calculator-targets-v2"/> </div> </template> @@ -31,7 +31,7 @@ import unitUtils from '@/utils/units'; import DecimalInput from '@/components/DecimalInput.vue'; import TimeInput from '@/components/TimeInput.vue'; -import TargetTable from '@/components/TargetTable.vue'; +import SimpleTargetTable from '@/components/SimpleTargetTable.vue'; export default { name: 'PaceCalculator', @@ -39,7 +39,7 @@ export default { components: { DecimalInput, TimeInput, - TargetTable, + SimpleTargetTable, }, data() { diff --git a/src/views/RaceCalculator.vue b/src/views/RaceCalculator.vue @@ -54,8 +54,8 @@ <h2>Equivalent Race Results</h2> - <target-table class="output" :calculate-result="predictResult" :default-targets="defaultTargets" - storage-key="race-calculator-targets-v2" show-pace/> + <simple-target-table class="output" :calculate-result="predictResult" + :default-targets="defaultTargets" storage-key="race-calculator-targets-v2" show-pace/> </div> </template> @@ -66,7 +66,7 @@ import unitUtils from '@/utils/units'; import DecimalInput from '@/components/DecimalInput.vue'; import TimeInput from '@/components/TimeInput.vue'; -import TargetTable from '@/components/TargetTable.vue'; +import SimpleTargetTable from '@/components/SimpleTargetTable.vue'; export default { name: 'RaceCalculator', @@ -74,7 +74,7 @@ export default { components: { DecimalInput, TimeInput, - TargetTable, + SimpleTargetTable, }, data() { diff --git a/src/views/SplitCalculator.vue b/src/views/SplitCalculator.vue @@ -0,0 +1,239 @@ +<template> + <div class="split-calculator"> + <div class="output"> + <table class="results" v-show="!inEditMode"> + <thead> + <tr> + <th>Distance</th> + + <th>Time</th> + + <th>Split</th> + + <th>Pace</th> + + <th> + <button class="icon" title="Edit Targets" @click="inEditMode=true" v-blur> + <edit-icon/> + </button> + </th> + </tr> + </thead> + + <tbody> + <tr v-for="(item, index) in results" :key="index"> + <td> + {{ item.distanceValue.toFixed(2) }} + {{ distanceUnits[item.distanceUnit].symbol }} + </td> + + <td> + {{ formatDuration(item.totalTime, 3, 2) }} + </td> + + <td> + <time-input v-model="targets[index].split" :showHours="false"/> + </td> + + <td colspan="2"> + {{ formatDuration(item.pace, 3, 0) }} + / {{ distanceUnits[getDefaultDistanceUnit()].symbol }} + </td> + </tr> + + <tr v-if="targets.length === 0" class="empty-message"> + <td colspan="5"> + There aren't any targets yet,<br> + click + <edit-icon/> + to edit the list of targets + </td> + </tr> + </tbody> + </table> + + <target-editor v-model="targets" :time-targets="false" v-show="inEditMode" + @close="inEditMode=false" @reset="resetTargets"/> + </div> + </div> +</template> + +<script> +import { + EditIcon, +} from 'vue-feather-icons'; + +import storage from '@/utils/localStorage'; +import targetUtils from '@/utils/targets'; +import unitUtils from '@/utils/units'; + +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: { + TimeInput, + TargetEditor, + + EditIcon, + }, + + directives: { + blur, + }, + + data() { + return { + /** + * The distance units + */ + distanceUnits: unitUtils.DISTANCE_UNITS, + + /** + * The formatDuration method + */ + formatDuration: unitUtils.formatDuration, + + /** + * The getDefaultDistanceUnit method + */ + getDefaultDistanceUnit: unitUtils.getDefaultDistanceUnit, + + /** + * Whether the table is in edit mode + */ + inEditMode: false, + + /** + * The target table targets + */ + targets: storage.get(storageKey, defaultTargets), + }; + }, + + computed: { + /** + * The target table results + */ + results() { + // 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, + }); + } + + // Return results array + return results; + }, + }, + + 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 + */ + resetTargets() { + // Clone default targets array + this.targets = JSON.parse(JSON.stringify(defaultTargets)); + + // Sort targets + this.targets = targetUtils.sort(this.targets); + }, + }, + + /** + * Close edit targets table + */ + deactivated() { + this.inEditMode = false; + }, +}; +</script> + +<style scoped> +/* container */ +.split-calculator { + display: flex; + flex-direction: column; + align-items: center; +} + +/* target table */ +.results th:last-child { + text-align: right; +} + +/* calculator output */ +.output { + min-width: 400px; +} +@media only screen and (max-width: 500px) { + .output { + width: 100%; + min-width: 0px; + } +} + +/* empty table message */ +.empty-message td { + text-align: center !important; +} +.empty-message svg { + height: 1em; + width: 1em; + color: var(--foreground); +} +</style> diff --git a/tests/unit/components/SimpleTargetTable.spec.js b/tests/unit/components/SimpleTargetTable.spec.js @@ -0,0 +1,34 @@ +/* eslint-disable no-underscore-dangle */ + +import { expect } from 'chai'; +import { shallowMount } from '@vue/test-utils'; +import SimpleTargetTable from '@/components/SimpleTargetTable.vue'; + +describe('components/SimpleTargetTable.vue', () => { + it('results should be correct and sorted by time', () => { + // Initialize component + const wrapper = shallowMount(SimpleTargetTable, { + propsData: { + calculateResult: (row) => ({ + distanceValue: row.distanceValue, + distanceUnit: row.distanceUnit, + time: row.distanceValue + 1, + }), + defaultTargets: [ + { distanceValue: 20, distanceUnit: 'meters' }, + { distanceValue: 100, distanceUnit: 'meters' }, + { distanceValue: 1, distanceUnit: 'kilometers' }, + { distanceValue: 10, distanceUnit: 'meters' }, + ], + }, + }); + + // Assert results are correct + expect(wrapper.vm._computedWatchers.results.value).to.deep.equal([ + { distanceValue: 1, distanceUnit: 'kilometers', time: 2 }, + { distanceValue: 10, distanceUnit: 'meters', time: 11 }, + { distanceValue: 20, distanceUnit: 'meters', time: 21 }, + { distanceValue: 100, distanceUnit: 'meters', time: 101 }, + ]); + }); +}); diff --git a/tests/unit/components/TargetEditor.spec.js b/tests/unit/components/TargetEditor.spec.js @@ -0,0 +1,82 @@ +/* eslint-disable no-underscore-dangle */ + +import { expect } from 'chai'; +import { shallowMount, mount } from '@vue/test-utils'; +import TargetEditor from '@/components/TargetEditor.vue'; + +describe('components/TargetEditor.vue', () => { + it('addDistanceTarget method should correctly add distance target', async () => { + // Initialize component + const wrapper = shallowMount(TargetEditor); + + // Add distance target + await wrapper.vm.addDistanceTarget(); + + // Assert input event was emitted + expect(wrapper.emitted().input).to.deep.equal([ + [[ + { distanceUnit: 'miles', distanceValue: 1, result: 'time' }, + ]], + ]); + }); + + it('addTimeTarget method should correctly add time target', async () => { + // Initialize component + const wrapper = shallowMount(TargetEditor); + + // Add time target + await wrapper.vm.addTimeTarget(); + + // Assert input event was emitted + expect(wrapper.emitted().input).to.deep.equal([ + [[ + { time: 600, result: 'distance' }, + ]], + ]); + }); + + it('should emit input event when targets are updated', async () => { + // Initialize component + const wrapper = mount(TargetEditor, { + propsData: { + value: [ + { distanceUnit: 'miles', distanceValue: 2, result: 'time' }, + ], + }, + }); + + // Update distance value + await wrapper.find('tbody input').trigger('keydown', { key: 'ArrowUp' }); + + // Assert input event was emitted + expect(wrapper.emitted().input).to.deep.equal([ + [[ + { distanceUnit: 'miles', distanceValue: 3, result: 'time' }, + ]], + ]); + }); + + it('removeTarget method should correctly remove target', async () => { + // Initialize component + const wrapper = shallowMount(TargetEditor, { + propsData: { + value: [ + { distanceUnit: 'miles', distanceValue: 1, result: 'time' }, + { distanceUnit: 'miles', distanceValue: 2, result: 'time' }, + { distanceUnit: 'miles', distanceValue: 3, result: 'time' }, + ], + }, + }); + + // Remove 2nd target + await wrapper.vm.removeTarget(1); + + // Assert input event was emitted + expect(wrapper.emitted().input).to.deep.equal([ + [[ + { distanceUnit: 'miles', distanceValue: 1, result: 'time' }, + { distanceUnit: 'miles', distanceValue: 3, result: 'time' }, + ]], + ]); + }); +}); diff --git a/tests/unit/components/TargetTable.spec.js b/tests/unit/components/TargetTable.spec.js @@ -1,34 +0,0 @@ -/* eslint-disable no-underscore-dangle */ - -import { expect } from 'chai'; -import { shallowMount } from '@vue/test-utils'; -import TargetTable from '@/components/TargetTable.vue'; - -describe('components/TargetTable.vue', () => { - it('results should be correct and sorted by time', () => { - // Initialize component - const wrapper = shallowMount(TargetTable, { - propsData: { - calculateResult: (row) => ({ - distanceValue: row.distanceValue, - distanceUnit: row.distanceUnit, - time: row.distanceValue + 1, - }), - defaultTargets: [ - { distanceValue: 20, distanceUnit: 'meters' }, - { distanceValue: 100, distanceUnit: 'meters' }, - { distanceValue: 1, distanceUnit: 'kilometers' }, - { distanceValue: 10, distanceUnit: 'meters' }, - ], - }, - }); - - // Assert results are correct - expect(wrapper.vm._computedWatchers.results.value).to.deep.equal([ - { distanceValue: 1, distanceUnit: 'kilometers', time: 2 }, - { distanceValue: 10, distanceUnit: 'meters', time: 11 }, - { distanceValue: 20, distanceUnit: 'meters', time: 21 }, - { distanceValue: 100, distanceUnit: 'meters', time: 101 }, - ]); - }); -}); diff --git a/tests/unit/components/TimeInput.spec.js b/tests/unit/components/TimeInput.spec.js @@ -78,10 +78,38 @@ describe('components/TimeInput.vue', () => { expect(wrapper.emitted().input).to.deep.equal([[3659], [3660]]); }); - it('up arrow should not increment value past the maximum', async () => { + it('up arrow should not increment value past the 2 field maximum', async () => { // Initialize component const wrapper = mount(TimeInput, { - propsData: { value: 359998 }, + propsData: { value: 3598, showHours: false }, + }); + + // Press up arrow in seconds field + await wrapper.find('input.seconds').trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is 59:59.00 and input event was emitted + expect(wrapper.vm.internalValue).to.equal(3599); + expect(wrapper.emitted().input).to.deep.equal([[3599]]); + + // Press up arrow in seconds field + await wrapper.find('input.seconds').trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is 59:59.99 and input event was gmitted + expect(wrapper.vm.internalValue).to.equal(3599.99); + expect(wrapper.emitted().input).to.deep.equal([[3599], [3599.99]]); + + // Press up arrow in seconds field + await wrapper.find('input.seconds').trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is still 59:59.99 and input event was not emitted + expect(wrapper.vm.internalValue).to.equal(3599.99); + expect(wrapper.emitted().input).to.deep.equal([[3599], [3599.99]]); + }); + + it('up arrow should not increment value past the 3 field maximum', async () => { + // Initialize component + const wrapper = mount(TimeInput, { + propsData: { value: 359998, showHours: true }, }); // Press up arrow in seconds field diff --git a/tests/unit/utils/targets.spec.js b/tests/unit/utils/targets.spec.js @@ -0,0 +1,23 @@ +import { expect } from 'chai'; +import targets from '@/utils/targets'; + +describe('utils/targets.js', () => { + describe('sort method', () => { + it('should correctly sort targets', () => { + // Initialize unsorted and sorted targets + const input = [ + { time: 60, result: 'distance' }, + { distanceUnit: 'kilometers', distanceValue: 5, result: 'time' }, + { distanceUnit: 'miles', distanceValue: 3, result: 'time' }, + ]; + const expected = [ + { distanceUnit: 'miles', distanceValue: 3, result: 'time' }, + { distanceUnit: 'kilometers', distanceValue: 5, result: 'time' }, + { time: 60, result: 'distance' }, + ]; + + // Assert sort method sorts targets correctly + expect(targets.sort(input)).to.deep.equal(expected); + }); + }); +});