running-tools

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

commit 41039cc813538b9986a5f349961af7dbe40f30c4
parent 63ba00886d2981b6287cf7ed6ef66df3401e9cb4
Author: ashermorgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Sat, 20 Nov 2021 10:47:31 -0800

Merge branch 'dev'

Version 1.2.0

Diffstat:
MCHANGELOG.md | 11+++++++++++
MREADME.md | 3++-
Mpackage-lock.json | 4++--
Mpackage.json | 2+-
Msrc/assets/global.css | 24++++++++++++++++++++++++
Msrc/components/DecimalInput.vue | 27+++++++++++----------------
Dsrc/components/IntInput.vue | 230-------------------------------------------------------------------------------
Asrc/components/IntegerInput.vue | 224+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/SimpleTargetTable.vue | 228+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/TargetEditor.vue | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/components/TargetTable.vue | 342-------------------------------------------------------------------------------
Msrc/components/TimeInput.vue | 115++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Msrc/router/index.js | 21++++++++++++++++-----
Asrc/utils/format.js | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/utils/localStorage.js | 4+++-
Asrc/utils/targets.js | 21+++++++++++++++++++++
Msrc/utils/units.js | 58----------------------------------------------------------
Asrc/views/Error404.vue | 21+++++++++++++++++++++
Msrc/views/Home.vue | 9+++++++--
Msrc/views/PaceCalculator.vue | 40++++++++++++++++++++++++++++++++--------
Msrc/views/RaceCalculator.vue | 61++++++++++++++++++++++++++++++++++++++++++++-----------------
Asrc/views/SplitCalculator.vue | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/views/UnitCalculator.vue | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mtests/unit/components/DecimalInput.spec.js | 81++++++++-----------------------------------------------------------------------
Dtests/unit/components/IntInput.spec.js | 320-------------------------------------------------------------------------------
Atests/unit/components/IntegerInput.spec.js | 255+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 | 143++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Atests/unit/utils/format.spec.js | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/utils/targets.spec.js | 23+++++++++++++++++++++++
Mtests/unit/utils/units.spec.js | 66------------------------------------------------------------------
33 files changed, 2122 insertions(+), 1232 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.2.0] - 2021-11-20 + +### Added +- Split calculator + +### Changed +- Calculator state is saved between sessions +- Improved arrow key behavior in time input fields +- Improved formatting of distances and durations + ## [1.1.1] - 2021-09-19 ### Fixed @@ -40,6 +50,7 @@ ### Added - Basic app structure +[1.2.0]: https://github.com/ashermorgan/running-tools/releases/tag/1.2.0 [1.1.1]: https://github.com/ashermorgan/running-tools/releases/tag/1.1.1 [1.1.0]: https://github.com/ashermorgan/running-tools/releases/tag/1.1.0 [1.0.0]: https://github.com/ashermorgan/running-tools/releases/tag/1.0.0 diff --git a/README.md b/README.md @@ -1,4 +1,4 @@ -# running-tools +# Running Tools A collection of tools for runners and their coaches. Try it out [here](https://ashermorgan.github.io/running-tools/). @@ -6,6 +6,7 @@ A collection of tools for runners and their coaches. Try it out [here](https://a ## Features - [Pace Calculator](https://ashermorgan.github.io/running-tools/#/calculate/paces): Calculate distances and times that are at the same pace - [Race Calculator](https://ashermorgan.github.io/running-tools/#/calculate/races): Estimate equivalent results for races of different distances and/or times +- [Split Calculator](https://ashermorgan.github.io/running-tools/#/calculate/splits): Find splits, paces, and cumulative times for the segments of a race - [Unit Calculator](https://ashermorgan.github.io/running-tools/#/calculate/units): Convert between different distance, time, speed, and pace units diff --git a/package-lock.json b/package-lock.json @@ -1,12 +1,12 @@ { "name": "running-tools", - "version": "1.1.1", + "version": "1.2.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "running-tools", - "version": "1.1.1", + "version": "1.2.0", "dependencies": { "core-js": "^3.6.5", "register-service-worker": "^1.7.1", diff --git a/package.json b/package.json @@ -1,6 +1,6 @@ { "name": "running-tools", - "version": "1.1.1", + "version": "1.2.0", "description": "A collection of tools for runners and their coaches that calculate splits, predict race times, convert units, and more", "private": true, "scripts": { diff --git a/src/assets/global.css b/src/assets/global.css @@ -31,6 +31,30 @@ 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; +} + +/* empty table message */ +table .empty-message td { + text-align: center !important; +} +table .empty-message svg { + height: 1em; + width: 1em; + color: var(--foreground); +} + /* styles for icons */ .icon { border: none; diff --git a/src/components/DecimalInput.vue b/src/components/DecimalInput.vue @@ -8,6 +8,8 @@ </template> <script> +import formatUtils from '@/utils/format'; + export default { name: 'DecimalInput', @@ -45,11 +47,11 @@ export default { }, /** - * Whether to wrap around at the minimum and maximum values + * Whether to allow the user to increment/decrement the value using the arrow keys */ - wrap: { + arrowKeys: { type: Boolean, - default: false, + default: true, }, /** @@ -183,19 +185,13 @@ export default { * @param {Object} e The keydown event args */ onkeydown(e) { - if (e.key === 'ArrowUp') { - if (this.decValue === this.max && this.wrap && this.min !== null) { - this.decValue = this.min; - } else { - this.decValue += this.step; - } + if (!this.arrowKeys) { + this.$emit('keydown', e); + } else if (e.key === 'ArrowUp') { + this.decValue += this.step; e.preventDefault(); } else if (e.key === 'ArrowDown') { - if (this.decValue === this.min && this.wrap && this.max !== null) { - this.decValue = this.max; - } else { - this.decValue -= this.step; - } + this.decValue -= this.step; e.preventDefault(); } }, @@ -222,8 +218,7 @@ export default { * @returns {String} The formated string */ format(value) { - const result = value.toFixed(this.digits); - return result.padStart(this.padding + this.digits + 1, '0'); + return formatUtils.formatNumber(value, this.padding, this.digits, true); }, }, }; diff --git a/src/components/IntInput.vue b/src/components/IntInput.vue @@ -1,230 +0,0 @@ -<template> - <input - ref="input" - @blur="onblur" - @keydown="onkeydown" - @keypress="onkeypress" - v-model="stringValue"> -</template> - -<script> -export default { - name: 'IntInput', - - props: { - /** - * The input value - */ - value: { - type: Number, - default: 0, - }, - - /** - * The minimum value - */ - min: { - type: Number, - default: null, - }, - - /** - * The maximum value - */ - max: { - type: Number, - default: null, - }, - - /** - * The step value - */ - step: { - type: Number, - default: 1, - }, - - /** - * Whether to wrap around at the minimum and maximum values - */ - wrap: { - type: Boolean, - default: false, - }, - - /** - * The number of digits to show before the decimal point - */ - padding: { - type: Number, - default: 0, - validator(value) { - return value >= 0; - }, - }, - }, - - data() { - return { - /** - * The internal value - */ - internalValue: this.format(this.value), - }; - }, - - computed: { - /** - * The value of the input element - */ - stringValue: { - get() { - return this.internalValue; - }, - set(newValue) { - // Parse new value - const parsedValue = this.parse(newValue); - - if (newValue === '' || newValue === '-') { - // Allow input to be '' or '-' - this.internalValue = newValue; - } else if (this.min !== null && parsedValue < this.min) { - // Enforce minimum - this.internalValue = this.format(this.min); - } else if (this.max !== null && parsedValue > this.max) { - // Enforce maximum - this.internalValue = this.format(this.max); - } else if (!Number.isNaN(parsedValue)) { - // Allow valid numbers - this.internalValue = newValue; - } - - // Make sure input element is updated - if (this.$refs.input.value === newValue) { - // Setter was called by the input element - if (this.internalValue !== newValue) { - // The value was corrected, so the input element must be updated - this.$refs.input.value = this.internalValue; - } - } - }, - }, - - /** - * The value of the component - */ - intValue: { - get() { - const parsedValue = parseInt(this.stringValue, 10); - return Number.isNaN(parsedValue) ? this.defaultValue : parsedValue; - }, - set(newValue) { - this.stringValue = this.format(newValue); - }, - }, - - /** - * The default value of the component - */ - defaultValue() { - if (this.min > 0 || this.max < 0) { - return this.min; - } - return 0; - }, - }, - - watch: { - /** - * Update the component value when the value prop changes - * @param {Number} newValue The new prop value - */ - value(newValue) { - if (newValue !== this.intValue) { - this.intValue = newValue; - } - }, - - /** - * Emit the input event when the component value changes - * @param {Number} newValue The new component value - */ - intValue(newValue) { - this.$emit('input', newValue); - }, - }, - - methods: { - /** - * Restrict input to numbers - * @param {Object} e The keypress event args - */ - onkeypress(e) { - const validKeys = ['-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; - if (!validKeys.includes(e.key)) { - /* key was not a number */ - e.preventDefault(); - } - }, - - /** - * Process up and down arrow presses - * @param {Object} e The keydown event args - */ - onkeydown(e) { - if (e.key === 'ArrowUp') { - if (this.intValue === this.max && this.wrap && this.min !== null) { - this.intValue = this.min; - } else { - this.intValue += this.step; - } - e.preventDefault(); - } else if (e.key === 'ArrowDown') { - if (this.intValue === this.min && this.wrap && this.max !== null) { - this.intValue = this.max; - } else { - this.intValue -= this.step; - } - e.preventDefault(); - } - }, - - /** - * Reformat display value - */ - onblur() { - this.stringValue = this.format(this.intValue); - }, - - /** - * Parse an integer from a string - * @param {String} value The string - * @returns {Number} The parsed integer - */ - parse(value) { - if (value.includes('.')) { - // value cannot be parsed as an integer - return NaN; - } - - return Number(value); - }, - - /** - * Format an integer as a string - * @param {Number} value The integer - * @returns {String} The formated string - */ - format(value) { - return value.toString().padStart(this.padding, '0'); - }, - }, -}; -</script> - -<style scoped> -input { - width: 3em; /* can fit 999 comfortably */ - text-align: center; -} -</style> diff --git a/src/components/IntegerInput.vue b/src/components/IntegerInput.vue @@ -0,0 +1,224 @@ +<template> + <input + ref="input" + @blur="onblur" + @keydown="onkeydown" + @keypress="onkeypress" + v-model="stringValue"> +</template> + +<script> +export default { + name: 'IntegerInput', + + props: { + /** + * The input value + */ + value: { + type: Number, + default: 0, + }, + + /** + * The minimum value + */ + min: { + type: Number, + default: null, + }, + + /** + * The maximum value + */ + max: { + type: Number, + default: null, + }, + + /** + * The step value + */ + step: { + type: Number, + default: 1, + }, + + /** + * Whether to allow the user to increment/decrement the value using the arrow keys + */ + arrowKeys: { + type: Boolean, + default: true, + }, + + /** + * The number of digits to show before the decimal point + */ + padding: { + type: Number, + default: 0, + validator(value) { + return value >= 0; + }, + }, + }, + + data() { + return { + /** + * The internal value + */ + internalValue: this.format(this.value), + }; + }, + + computed: { + /** + * The value of the input element + */ + stringValue: { + get() { + return this.internalValue; + }, + set(newValue) { + // Parse new value + const parsedValue = this.parse(newValue); + + if (newValue === '' || newValue === '-') { + // Allow input to be '' or '-' + this.internalValue = newValue; + } else if (this.min !== null && parsedValue < this.min) { + // Enforce minimum + this.internalValue = this.format(this.min); + } else if (this.max !== null && parsedValue > this.max) { + // Enforce maximum + this.internalValue = this.format(this.max); + } else if (!Number.isNaN(parsedValue)) { + // Allow valid numbers + this.internalValue = newValue; + } + + // Make sure input element is updated + if (this.$refs.input.value === newValue) { + // Setter was called by the input element + if (this.internalValue !== newValue) { + // The value was corrected, so the input element must be updated + this.$refs.input.value = this.internalValue; + } + } + }, + }, + + /** + * The value of the component + */ + intValue: { + get() { + const parsedValue = parseInt(this.stringValue, 10); + return Number.isNaN(parsedValue) ? this.defaultValue : parsedValue; + }, + set(newValue) { + this.stringValue = this.format(newValue); + }, + }, + + /** + * The default value of the component + */ + defaultValue() { + if (this.min > 0 || this.max < 0) { + return this.min; + } + return 0; + }, + }, + + watch: { + /** + * Update the component value when the value prop changes + * @param {Number} newValue The new prop value + */ + value(newValue) { + if (newValue !== this.intValue) { + this.intValue = newValue; + } + }, + + /** + * Emit the input event when the component value changes + * @param {Number} newValue The new component value + */ + intValue(newValue) { + this.$emit('input', newValue); + }, + }, + + methods: { + /** + * Restrict input to numbers + * @param {Object} e The keypress event args + */ + onkeypress(e) { + const validKeys = ['-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; + if (!validKeys.includes(e.key)) { + /* key was not a number */ + e.preventDefault(); + } + }, + + /** + * Process up and down arrow presses + * @param {Object} e The keydown event args + */ + onkeydown(e) { + if (!this.arrowKeys) { + this.$emit('keydown', e); + } else if (e.key === 'ArrowUp') { + this.intValue += this.step; + e.preventDefault(); + } else if (e.key === 'ArrowDown') { + this.intValue -= this.step; + e.preventDefault(); + } + }, + + /** + * Reformat display value + */ + onblur() { + this.stringValue = this.format(this.intValue); + }, + + /** + * Parse an integer from a string + * @param {String} value The string + * @returns {Number} The parsed integer + */ + parse(value) { + if (value.includes('.')) { + // value cannot be parsed as an integer + return NaN; + } + + return Number(value); + }, + + /** + * Format an integer as a string + * @param {Number} value The integer + * @returns {String} The formated string + */ + format(value) { + return value.toString().padStart(this.padding, '0'); + }, + }, +}; +</script> + +<style scoped> +input { + width: 3em; /* can fit 999 comfortably */ + text-align: center; +} +</style> diff --git a/src/components/SimpleTargetTable.vue b/src/components/SimpleTargetTable.vue @@ -0,0 +1,228 @@ +<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' : ''"> + {{ formatNumber(item.distanceValue, 0, 2, item.result === 'distance') }} + {{ distanceUnits[item.distanceUnit].symbol }} + </td> + + <td :colspan="showPace ? 1 : 2" :class="item.result === 'time' ? 'result' : ''"> + {{ formatDuration(item.time, 3, 2, item.result === 'time') }} + </td> + + <td v-if="showPace" colspan="2"> + {{ formatDuration(getPace(item), 3, 0, true) }} + / {{ 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 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, + + 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: formatUtils.formatDuration, + + /** + * The formatNumber method + */ + formatNumber: formatUtils.formatNumber, + + /** + * 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; +} +</style> diff --git a/src/components/TargetEditor.vue b/src/components/TargetEditor.vue @@ -0,0 +1,214 @@ +<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; +} +</style> diff --git a/src/components/TargetTable.vue b/src/components/TargetTable.vue @@ -1,342 +0,0 @@ -<template> - <div class="time-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: 'TimeTable', - - 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> -/* time 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,25 +1,28 @@ <template> <div class="time-input"> - <int-input class="hours" aria-label="hours" - :min="0" :max="99" :padding="1" v-model="hours"/> - <span>:</span> - <int-input class="minutes" aria-label="minutes" - :min="0" :max="59" wrap :padding="2" v-model="minutes"/> + <integer-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 v-if="showHours">:</span> + <integer-input class="minutes" aria-label="minutes" + :min="0" :max="59" :padding="2" v-model="minutes" + :arrow-keys="false" @keydown="onkeydown($event, 60)"/> <span>:</span> <decimal-input class="seconds" aria-label="seconds" - :min="0" :max="59.99" wrap :padding="2" :digits="2" v-model="seconds"/> + :min="0" :max="59.99" :padding="2" :digits="2" v-model="seconds" + :arrow-keys="false" @keydown="onkeydown($event, 1)"/> </div> </template> <script> -import IntInput from '@/components/IntInput.vue'; +import IntegerInput from '@/components/IntegerInput.vue'; import DecimalInput from '@/components/DecimalInput.vue'; export default { name: 'TimeInput', components: { - IntInput, + IntegerInput, DecimalInput, }, @@ -34,33 +37,67 @@ export default { return value >= 0 && value <= 359999.99; }, }, + + /** + * Whether to show the hour field + */ + showHours: { + type: Boolean, + default: true, + }, }, data() { return { /** - * The number of hours in the component value - */ - hours: Math.floor(this.value / 3600), - - /** - * The number of minutes in the component value - */ - minutes: Math.floor((this.value % 3600) / 60), - - /** - * The number of seconds in the component value + * The internal value */ - seconds: this.value % 60, + internalValue: this.value, }; }, computed: { /** - * The value of the component + * The maximum value */ - intValue() { - return (this.hours * 3600) + (this.minutes * 60) + this.seconds; + max() { + return this.showHours ? 359999.99 : 3599.99; + }, + + /** + * The value of the hours field + */ + hours: { + get() { + return Math.floor(this.value / 3600); + }, + set(newValue) { + this.internalValue = (newValue * 3600) + (this.minutes * 60) + this.seconds; + }, + }, + + /** + * The value of the minutes field + */ + minutes: { + get() { + return Math.floor((this.value % 3600) / 60); + }, + set(newValue) { + this.internalValue = (this.hours * 3600) + (newValue * 60) + this.seconds; + }, + }, + + /** + * The value of the seconds field + */ + seconds: { + get() { + return this.value % 60; + }, + set(newValue) { + this.internalValue = (this.hours * 3600) + (this.minutes * 60) + newValue; + }, }, }, @@ -70,10 +107,8 @@ export default { * @param {Number} newValue The new prop value */ value(newValue) { - if (newValue !== this.intValue) { - this.hours = Math.floor(newValue / 3600); - this.minutes = Math.floor((newValue % 3600) / 60); - this.seconds = newValue % 60; + if (newValue !== this.internalValue) { + this.internalValue = newValue; } }, @@ -81,10 +116,34 @@ export default { * Emit the input event when the component value changes * @param {Number} newValue The new component value */ - intValue(newValue) { + internalValue(newValue) { this.$emit('input', newValue); }, }, + + methods: { + /** + * Process up and down arrow presses + * @param {Object} e The keydown event args + */ + onkeydown(e, step = 1) { + if (e.key === 'ArrowUp') { + if (this.internalValue + step > this.max) { + this.internalValue = this.max; + } else { + this.internalValue += step; + } + e.preventDefault(); + } else if (e.key === 'ArrowDown') { + if (this.internalValue - step < 0) { + this.internalValue = 0; + } else { + this.internalValue -= step; + } + e.preventDefault(); + } + }, + }, }; </script> diff --git a/src/router/index.js b/src/router/index.js @@ -1,9 +1,11 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; -import Home from '../views/Home.vue'; -import PaceCalculator from '../views/PaceCalculator.vue'; -import RaceCalculator from '../views/RaceCalculator.vue'; -import UnitCalculator from '../views/UnitCalculator.vue'; +import Error404 from '@/views/Error404.vue'; +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 +46,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, @@ -54,7 +65,7 @@ const routes = [ }, { path: '*', - redirect: '/home', + component: Error404, }, ]; diff --git a/src/utils/format.js b/src/utils/format.js @@ -0,0 +1,104 @@ +/** + * Format a number as a string + * @param {Number} value The number + * @param {Number} minPadding The minimum number of digits to show before the decimal point + * @param {Number} maxDigits The maximum number of digits to show after the decimal point + * @param {Boolean} extraDigits Whether to show extra zeros after the decimal point + * @returns {String} The formatted value + */ +function formatNumber(value, minPadding = 0, maxDigits = 2, extraDigits = true) { + // Initialize result + let result = ''; + + // Remove sign + const negative = value < 0; + const fixedValue = Math.abs(value); + + // Address edge cases + if (Number.isNaN(fixedValue)) { + return 'NaN'; + } + if (fixedValue === Infinity) { + return negative ? '-Infinity' : 'Infinity'; + } + + // Convert number to string + if (extraDigits) { + result = fixedValue.toFixed(maxDigits); + } else { + const power = 10 ** maxDigits; + result = (Math.round((fixedValue + Number.EPSILON) * power) / power).toString(); + } + + // Add padding + const currentPadding = result.split('.')[0].length; + result = result.padStart(result.length - currentPadding + minPadding, '0'); + + // Add negative sign + if (negative) { + result = `-${result}`; + } + + // Return result + return result; +} + +/** + * Format a duration as a string + * @param {Number} value The duration (in seconds) + * @param {Number} minPadding The minimum number of digits to show before the decimal point + * @param {Number} maxDigits The maximum number of digits to show after the decimal point + * @param {Boolean} extraDigits Whether to show extra zeros after the decimal point + * @returns {String} The formatted value + */ +function formatDuration(value, minPadding = 6, maxDigits = 2, extraDigits = true) { + // Check if value is NaN + if (Number.isNaN(value)) { + return 'NaN'; + } + + // Initialize result + let result = ''; + + // Check value sign + if (value < 0) { + result += '-'; + } + + // Check if value is valid + if (Math.abs(value) === Infinity) { + return `${result}Infinity`; + } + + // Validate padding + let fixedPadding = Math.min(minPadding, 6); + + // Prevent rounding errors + const fixedValue = parseFloat(Math.abs(value).toFixed(maxDigits)); + + // Calculate parts + const hours = Math.floor(fixedValue / 3600); + const minutes = Math.floor((fixedValue % 3600) / 60); + const seconds = fixedValue % 60; + + // Format parts + if (hours !== 0 || fixedPadding >= 5) { + result += hours.toString().padStart(fixedPadding - 4, '0'); + result += ':'; + fixedPadding = 4; + } + if (minutes !== 0 || fixedPadding >= 3) { + result += minutes.toString().padStart(fixedPadding - 2, '0'); + result += ':'; + fixedPadding = 2; + } + result += formatNumber(seconds, fixedPadding, maxDigits, extraDigits); + + // Return result + return result; +} + +export default { + formatNumber, + formatDuration, +}; diff --git a/src/utils/localStorage.js b/src/utils/localStorage.js @@ -29,7 +29,9 @@ function get(key, defaultValue) { * @param {Object} value The value * */ function set(key, value) { - localStorage.setItem(`${prefix}.${key}`, JSON.stringify(value)); + if (typeof localStorage !== 'undefined') { + localStorage.setItem(`${prefix}.${key}`, JSON.stringify(value)); + } } export default { 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/utils/units.js b/src/utils/units.js @@ -160,62 +160,6 @@ function convertSpeedPace(inputValue, inputUnit, outputUnit) { } /** - * Format a duration as a string - * @param {Number} value The duration (in seconds) - * @param {Number} padding The number of digits to show before the decimal point - * @param {Number} digits The number of digits to show after the decimal point - * @returns {String} The formatted value - */ -function formatDuration(value, padding = 6, digits = 2) { - // Check if value is NaN - if (Number.isNaN(value)) { - return 'NaN'; - } - - // Initialize result - let result = ''; - - // Check value sign - if (value < 0) { - result += '-'; - } - - // Check if value is valid - if (Math.abs(value) === Infinity) { - return `${result}Infinity`; - } - - // Validate padding - let fixedPadding = Math.min(padding, 6); - - // Prevent rounding errors - const fixedValue = parseFloat(Math.abs(value).toFixed(digits)); - - // Calculate parts - const hours = Math.floor(fixedValue / 3600); - const minutes = Math.floor((fixedValue % 3600) / 60); - const seconds = fixedValue % 60; - - // Format parts - if (hours !== 0 || fixedPadding >= 5) { - result += hours.toString().padStart(fixedPadding - 4, '0'); - result += ':'; - fixedPadding = 4; - } - if (minutes !== 0 || fixedPadding >= 3) { - result += minutes.toString().padStart(fixedPadding - 2, '0'); - result += ':'; - fixedPadding = 2; - } - if (digits === 0) { - result += seconds.toFixed(digits).padStart(fixedPadding, '0'); - } else { - result += seconds.toFixed(digits).padStart(fixedPadding + digits + 1, '0'); - } - return result; -} - -/** * Get the default unit system * @returns {String} The default unit system */ @@ -263,8 +207,6 @@ export default { convertPace, convertSpeedPace, - formatDuration, - getDefaultUnitSystem, getDefaultDistanceUnit, getDefaultSpeedUnit, diff --git a/src/views/Error404.vue b/src/views/Error404.vue @@ -0,0 +1,21 @@ +<template> + <div class="error404"> + <h1>404 Not Found</h1> + <p><router-link to="/home">homepage</router-link></p> + </div> +</template> + +<script> +export default { + name: 'Error404', +}; +</script> + +<style scoped> +h1 { + font-size: 1.5em; +} +.error404 { + text-align: center; +} +</style> 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,18 +20,19 @@ <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> <script> import paceUtils from '@/utils/paces'; +import storage from '@/utils/localStorage'; 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 +40,7 @@ export default { components: { DecimalInput, TimeInput, - TargetTable, + SimpleTargetTable, }, data() { @@ -47,17 +48,17 @@ export default { /** * The input distance value */ - inputDistance: 5, + inputDistance: storage.get('pace-calculator-input-distance', 5), /** * The input distance unit */ - inputUnit: 'kilometers', + inputUnit: storage.get('pace-calculator-input-unit', 'kilometers'), /** * The input time value */ - inputTime: 20 * 60, + inputTime: storage.get('pace-calculator-input-time', 20 * 60), /** * The names of the distance units @@ -87,7 +88,6 @@ export default { { 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: 1, distanceUnit: 'miles' }, { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, @@ -102,10 +102,34 @@ export default { { result: 'distance', time: 600 }, { result: 'distance', time: 1800 }, + { result: 'distance', time: 3600 }, ], }; }, + watch: { + /** + * Save input distance value + */ + inputDistance(newValue) { + storage.set('pace-calculator-input-distance', newValue); + }, + + /** + * Save input distance unit + */ + inputUnit(newValue) { + storage.set('pace-calculator-input-unit', newValue); + }, + + /** + * Save input time value + */ + inputTime(newValue) { + storage.set('pace-calculator-input-time', newValue); + }, + }, + computed: { /** * The input pace (in seconds per meter) diff --git a/src/views/RaceCalculator.vue b/src/views/RaceCalculator.vue @@ -41,32 +41,33 @@ (default: 1.06) </div> <div> - Purdy Points: <b>{{ purdyPoints.toFixed(1) }}</b> + Purdy Points: <b>{{ formatNumber(purdyPoints, 0, 1, true) }}</b> </div> <div> - V&#775;O&#8322;: <b>{{ vo2.toFixed(1) }}</b> ml/kg/min - (<b>{{ vo2Percentage.toFixed(1) }}%</b> of max) + V&#775;O&#8322;: <b>{{ formatNumber(vo2, 0, 1, true) }}</b> ml/kg/min + (<b>{{ formatNumber(vo2Percentage, 0, 1, true) }}%</b> of max) </div> <div> - V&#775;O&#8322; Max: <b>{{ vo2Max.toFixed(1) }}</b> ml/kg/min + V&#775;O&#8322; Max: <b>{{ formatNumber(vo2Max, 0, 1, true) }}</b> ml/kg/min </div> </div> <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> <script> +import formatUtils from '@/utils/format'; import raceUtils from '@/utils/races'; import storage from '@/utils/localStorage'; 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 +75,7 @@ export default { components: { DecimalInput, TimeInput, - TargetTable, + SimpleTargetTable, }, data() { @@ -82,17 +83,17 @@ export default { /** * The input distance value */ - inputDistance: 5, + inputDistance: storage.get('race-calculator-input-distance', 5), /** * The input distance unit */ - inputUnit: 'kilometers', + inputUnit: storage.get('race-calculator-input-unit', 'kilometers'), /** * The input time value */ - inputTime: 20 * 60, + inputTime: storage.get('race-calculator-input-time', 20 * 60), /** * The race prediction model @@ -115,6 +116,11 @@ export default { distanceUnits: unitUtils.DISTANCE_UNITS, /** + * The formatNumber method + */ + formatNumber: formatUtils.formatNumber, + + /** * The default output targets */ defaultTargets: [ @@ -273,22 +279,43 @@ export default { watch: { /** - * Save prediction model - */ + * Save input distance value + */ + inputDistance(newValue) { + storage.set('race-calculator-input-distance', newValue); + }, + + /** + * Save input distance unit + */ + inputUnit(newValue) { + storage.set('race-calculator-input-unit', newValue); + }, + + /** + * Save input time value + */ + inputTime(newValue) { + storage.set('race-calculator-input-time', newValue); + }, + + /** + * Save prediction model + */ model(newValue) { storage.set('race-calculator-model', newValue); }, /** - * Save Riegel Model exponent - */ + * Save Riegel Model exponent + */ riegelExponent(newValue) { storage.set('race-calculator-riegel-exponent', newValue); }, /** - * Save advanced options state - */ + * Save advanced options state + */ showAdvancedOptions(newValue) { storage.set('race-calculator-show-advanced-options', newValue); }, diff --git a/src/views/SplitCalculator.vue b/src/views/SplitCalculator.vue @@ -0,0 +1,247 @@ +<template> + <div class="split-calculator"> + <div class="output"> + <table class="results" v-show="!inEditMode"> + <thead> + <tr> + <th> + <span>Distance</span> + <span class="mobile-abbreviation">Dist</span> + </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> + {{ formatNumber(item.distanceValue, 0, 2, false) }} + {{ distanceUnits[item.distanceUnit].symbol }} + </td> + + <td> + {{ formatDuration(item.totalTime, 3, 2, true) }} + </td> + + <td> + <time-input v-model="targets[index].split" :showHours="false"/> + </td> + + <td colspan="2"> + {{ formatDuration(item.pace, 3, 0, true) }} + / {{ 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 formatUtils from '@/utils/format'; +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: formatUtils.formatDuration, + + /** + * The formatNumber method + */ + formatNumber: formatUtils.formatNumber, + + /** + * 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; +} +.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; + } + .results th:first-child span.mobile-abbreviation { + display: inherit; + } +} +</style> diff --git a/src/views/UnitCalculator.vue b/src/views/UnitCalculator.vue @@ -3,7 +3,7 @@ <select class="category" v-model="category"> <option value="distance">Distance</option> <option value="time">Time</option> - <option value="speed_and_pace">Speed & Pace</option> + <option value="speed_and_pace">Speed &amp; Pace</option> </select> <time-input v-if="getUnitType(inputUnit) === 'time'" class="input-value" @@ -20,10 +20,10 @@ <span class="equals"> = </span> <span v-if="getUnitType(outputUnit) === 'time'" class="output-value"> - {{ formatDuration(outputValue, 6, 3) }} + {{ formatDuration(outputValue, 6, 3, true) }} </span> <span v-else class="output-value"> - {{ outputValue.toFixed(3) }} + {{ formatNumber(outputValue, 0, 3, true) }} </span> <select v-model="outputUnit" class="output-units" aria-label="output units"> @@ -35,6 +35,8 @@ </template> <script> +import formatUtils from '@/utils/format'; +import storage from '@/utils/localStorage'; import unitUtils from '@/utils/units'; import DecimalInput from '@/components/DecimalInput.vue'; @@ -53,17 +55,17 @@ export default { /** * The input value */ - inputValue: 1.0, + inputValue: storage.get('unit-calculator-distance-input-value', 1.0), /** * The unit of the input */ - inputUnit: 'miles', + inputUnit: storage.get('unit-calculator-distance-input-unit', 'miles'), /** * The unit of the output */ - outputUnit: 'kilometers', + outputUnit: storage.get('unit-calculator-distance-output-unit', 'kilometers'), /** * The unit category @@ -73,7 +75,12 @@ export default { /** * The formatDuration method */ - formatDuration: unitUtils.formatDuration, + formatDuration: formatUtils.formatDuration, + + /** + * The formatNumber method + */ + formatNumber: formatUtils.formatNumber, }; }, @@ -138,21 +145,93 @@ export default { category(newValue) { switch (newValue) { case 'distance': { - this.inputValue = 1; - this.inputUnit = 'miles'; - this.outputUnit = 'kilometers'; + this.inputValue = storage.get('unit-calculator-distance-input-value', 1); + this.inputUnit = storage.get('unit-calculator-distance-input-unit', 'miles'); + this.outputUnit = storage.get('unit-calculator-distance-output-unit', 'kilometers'); + break; + } + case 'time': { + this.inputValue = storage.get('unit-calculator-time-input-value', 1); + this.inputUnit = storage.get('unit-calculator-time-input-unit', 'seconds'); + this.outputUnit = storage.get('unit-calculator-time-ouput-unit', 'hh:mm:ss'); + break; + } + case 'speed_and_pace': { + this.inputValue = storage.get('unit-calculator-speed-input-value', + unitUtils.getDefaultPaceUnit() === 'seconds_per_mile' ? 600 : 300); + this.inputUnit = storage.get('unit-calculator-speed-input-unit', + unitUtils.getDefaultPaceUnit()); + this.outputUnit = storage.get('unit-calculator-speed-output-unit', + unitUtils.getDefaultSpeedUnit()); + break; + } + default: { + break; + } + } + }, + + /** + * Save input value + */ + inputValue(newValue) { + switch (this.category) { + case 'distance': { + storage.set('unit-calculator-distance-input-value', newValue); + break; + } + case 'time': { + storage.set('unit-calculator-time-input-value', newValue); + break; + } + case 'speed_and_pace': { + storage.set('unit-calculator-speed-input-value', newValue); + break; + } + default: { + break; + } + } + }, + + /** + * Save input unit + */ + inputUnit(newValue) { + switch (this.category) { + case 'distance': { + storage.set('unit-calculator-distance-input-unit', newValue); + break; + } + case 'time': { + storage.set('unit-calculator-time-input-unit', newValue); + break; + } + case 'speed_and_pace': { + storage.set('unit-calculator-speed-input-unit', newValue); + break; + } + default: { + break; + } + } + }, + + /** + * Save output unit + */ + outputUnit(newValue) { + switch (this.category) { + case 'distance': { + storage.set('unit-calculator-distance-output-unit', newValue); break; } case 'time': { - this.inputValue = 1; - this.inputUnit = 'seconds'; - this.outputUnit = 'hh:mm:ss'; + storage.set('unit-calculator-time-output-unit', newValue); break; } case 'speed_and_pace': { - this.inputValue = unitUtils.getDefaultPaceUnit() === 'seconds_per_mile' ? 600 : 300; - this.inputUnit = unitUtils.getDefaultPaceUnit(); - this.outputUnit = unitUtils.getDefaultSpeedUnit(); + storage.set('unit-calculator-speed-output-unit', newValue); break; } default: { diff --git a/tests/unit/components/DecimalInput.spec.js b/tests/unit/components/DecimalInput.spec.js @@ -252,91 +252,26 @@ describe('components/DecimalInput.vue', () => { expect(wrapper.emitted().input).to.deep.equal([[10.0]]); }); - it('should not wrap to the maximum if it is null', async () => { + it('should format value according to padding and digits props', async () => { // Initialize component const wrapper = mount(DecimalInput, { - propsData: { - min: -1.0, max: null, value: -1.0, step: 0.2, wrap: true, - }, + propsData: { padding: 2, digits: 3 }, }); - // Try to decrement value - await wrapper.trigger('keydown', { key: 'ArrowDown' }); - - // Assert value is still -1.0 and no events were emitted - expect(wrapper.find('input').element.value).to.equal('-1.0'); - expect(wrapper.emitted().input).to.equal(undefined); + // Assert value is correctly formatted + expect(wrapper.find('input').element.value).to.equal('00.000'); }); - it('should not wrap to the minimum if it is null', async () => { + it('should emit keydown event if arrow-keys is false', async () => { // Initialize component const wrapper = mount(DecimalInput, { - propsData: { - min: null, max: 1.0, value: 1.0, step: 0.2, wrap: true, - }, + propsData: { arrowKeys: false }, }); // Try to increment value await wrapper.trigger('keydown', { key: 'ArrowUp' }); - // Assert value is still 1.0 and no events were emitted - expect(wrapper.find('input').element.value).to.equal('1.0'); - expect(wrapper.emitted().input).to.equal(undefined); - }); - - it('should correctly wrap from the minimum to maximum', async () => { - // Initialize component - const wrapper = mount(DecimalInput, { - propsData: { - min: -1.0, max: 1.0, value: -0.9, step: 0.2, wrap: true, - }, - }); - - // Decrement value - await wrapper.trigger('keydown', { key: 'ArrowDown' }); - - // Assert value is -1.0 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('-1.0'); - expect(wrapper.emitted().input).to.deep.equal([[-1.0]]); - - // Decrement value - await wrapper.trigger('keydown', { key: 'ArrowDown' }); - - // Assert value is 1.0 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('1.0'); - expect(wrapper.emitted().input).to.deep.equal([[-1.0], [1.0]]); - }); - - it('should correctly wrap from the maximum to minimum', async () => { - // Initialize component - const wrapper = mount(DecimalInput, { - propsData: { - min: -1.0, max: 1.0, value: 0.9, step: 0.2, wrap: true, - }, - }); - - // Increment value - await wrapper.trigger('keydown', { key: 'ArrowUp' }); - - // Assert value is 1.0 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('1.0'); - expect(wrapper.emitted().input).to.deep.equal([[1.0]]); - - // Increment value - await wrapper.trigger('keydown', { key: 'ArrowUp' }); - - // Assert value is -1.0 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('-1.0'); - expect(wrapper.emitted().input).to.deep.equal([[1.0], [-1.0]]); - }); - - it('should format value according to padding and digits props', async () => { - // Initialize component - const wrapper = mount(DecimalInput, { - propsData: { padding: 2, digits: 3 }, - }); - - // Assert value is correctly formatted - expect(wrapper.find('input').element.value).to.equal('00.000'); + // Assert keydown event emitted + expect(wrapper.emitted().keydown.length).to.equal(1); }); }); diff --git a/tests/unit/components/IntInput.spec.js b/tests/unit/components/IntInput.spec.js @@ -1,320 +0,0 @@ -import { expect } from 'chai'; -import { mount } from '@vue/test-utils'; -import IntInput from '@/components/IntInput.vue'; - -describe('components/IntInput.vue', () => { - it('value should be 0 by default', () => { - // Initialize component - const wrapper = mount(IntInput); - - // Assert value is 0 - expect(wrapper.find('input').element.value).to.equal('0'); - }); - - it('should read value prop', () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { value: 1 }, - }); - - // Assert value is 1 - expect(wrapper.find('input').element.value).to.equal('1'); - }); - - it('up arrow should increment value by step', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { step: 2 }, - }); - - // Press up arrow - await wrapper.trigger('keydown', { key: 'ArrowUp' }); - - // Assert value is 1 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('2'); - expect(wrapper.emitted().input).to.deep.equal([[2]]); - }); - - it('down arrow should increment value by step', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { step: 2 }, - }); - - // Press down arrow - await wrapper.trigger('keydown', { key: 'ArrowDown' }); - - // Assert value is -1 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('-2'); - expect(wrapper.emitted().input).to.deep.equal([[-2]]); - }); - - it('should fire input event when value changes', async () => { - // Initialize component - const wrapper = mount(IntInput); - - // Set value to 1 - wrapper.find('input').element.value = '1'; - await wrapper.find('input').trigger('input'); - - // Assert input event was emitted - expect(wrapper.emitted().input).to.deep.equal([[1]]); - }); - - it('should accept numerical values', async () => { - // Initialize component - const wrapper = mount(IntInput); - - // Try to set value to 1 - wrapper.find('input').element.value = '1'; - await wrapper.find('input').trigger('input'); - - // Assert value was accepted and input event was emitted - expect(wrapper.find('input').element.value).to.equal('1'); - expect(wrapper.emitted().input).to.deep.equal([[1]]); - }); - - it('should not accept decimal values', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { value: 1 }, - }); - - // Try to set value to 1.5 - wrapper.find('input').element.value = '1.5'; - await wrapper.find('input').trigger('input'); - - // Assert value was not accepted and no events were emitted - expect(wrapper.find('input').element.value).to.equal('1'); - expect(wrapper.emitted().input).to.equal(undefined); - }); - - it('should not accept non numerical values', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { value: 1 }, - }); - - // Try to set value to a - wrapper.find('input').element.value = 'a'; - await wrapper.find('input').trigger('input'); - - // Assert value was not accepted and no events were emitted - expect(wrapper.find('input').element.value).to.equal('1'); - expect(wrapper.emitted().input).to.equal(undefined); - }); - - it('should format input value on blur', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { value: 1, padding: 3 }, - }); - - // Set value to '01' - wrapper.find('input').element.value = '01'; - await wrapper.find('input').trigger('input'); - - // Assert value was not updated and no events were emitted - expect(wrapper.find('input').element.value).to.equal('01'); - expect(wrapper.emitted().input).to.equal(undefined); - - // Trigger blur event - await wrapper.find('input').trigger('blur'); - - // Assert value was formatted but no events were emitted - expect(wrapper.find('input').element.value).to.equal('001'); - expect(wrapper.emitted().input).to.equal(undefined); - }); - - it('should allow input to be empty until blur', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { value: 5 }, - }); - - // Set value to '' - wrapper.find('input').element.value = ''; - await wrapper.find('input').trigger('input'); - - // Assert value is '' and input event was emitted with default value - expect(wrapper.find('input').element.value).to.equal(''); - expect(wrapper.emitted().input).to.deep.equal([[0]]); - - // Trigger blur event - await wrapper.find('input').trigger('blur'); - - // Assert value is the default value but no new events were emitted - expect(wrapper.find('input').element.value).to.equal('0'); - expect(wrapper.emitted().input).to.deep.equal([[0]]); - }); - - it('should allow input to be "-" until blur', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { value: 5 }, - }); - - // Set value to '-' - wrapper.find('input').element.value = '-'; - await wrapper.find('input').trigger('input'); - - // Assert value is '-' and input event was emitted with default value - expect(wrapper.find('input').element.value).to.equal('-'); - expect(wrapper.emitted().input).to.deep.equal([[0]]); - - // Trigger blur event - await wrapper.find('input').trigger('blur'); - - // Assert value is the default value but no new events were emitted - expect(wrapper.find('input').element.value).to.equal('0'); - expect(wrapper.emitted().input).to.deep.equal([[0]]); - }); - - it('default value should be the minimum if 0 is not valid', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { value: 3, max: 4, min: 2 }, - }); - - // Set value to '' and trigger blur event so value must be updated - wrapper.find('input').element.value = ''; - await wrapper.find('input').trigger('input'); - await wrapper.find('input').trigger('blur'); - - // Assert value is 2 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('2'); - expect(wrapper.emitted().input).to.deep.equal([[2]]); - }); - - it('should not allow input to be below the minimum', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { min: 10, value: 20 }, - }); - - // Try to set value to 9, which is below the minimum - wrapper.find('input').element.value = '9'; - await wrapper.find('input').trigger('input'); - - // Assert value is 10 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('10'); - expect(wrapper.emitted().input).to.deep.equal([[10]]); - - // Try to decrement value - await wrapper.trigger('keydown', { key: 'ArrowDown' }); - - // Assert value is still 10 and no new event were emitted - expect(wrapper.find('input').element.value).to.equal('10'); - expect(wrapper.emitted().input).to.deep.equal([[10]]); - }); - - it('should not allow input to be above the maximum', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { max: 10 }, - }); - - // Try to set value to 11, which is above the maximum - wrapper.find('input').element.value = '11'; - await wrapper.find('input').trigger('input'); - - // Assert value is 10 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('10'); - expect(wrapper.emitted().input).to.deep.equal([[10]]); - - // Try to increment value - await wrapper.trigger('keydown', { key: 'ArrowUp' }); - - // Assert value is still 10 and no new events were emitted - expect(wrapper.find('input').element.value).to.equal('10'); - expect(wrapper.emitted().input).to.deep.equal([[10]]); - }); - - it('should not wrap to the maximum if it is null', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { - min: -10, max: null, value: -10, step: 2, wrap: true, - }, - }); - - // Try to decrement value - await wrapper.trigger('keydown', { key: 'ArrowDown' }); - - // Assert value is still -10 and no events were emitted - expect(wrapper.find('input').element.value).to.equal('-10'); - expect(wrapper.emitted().input).to.equal(undefined); - }); - - it('should not wrap to the minimum if it is null', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { - min: null, max: 10, value: 10, step: 2, wrap: true, - }, - }); - - // Try to increment value - await wrapper.trigger('keydown', { key: 'ArrowUp' }); - - // Assert value is still 10 and no events were emitted - expect(wrapper.find('input').element.value).to.equal('10'); - expect(wrapper.emitted().input).to.equal(undefined); - }); - - it('should correctly wrap from the minimum to maximum', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { - min: -10, max: 10, value: -9, step: 2, wrap: true, - }, - }); - - // Decrement value - await wrapper.trigger('keydown', { key: 'ArrowDown' }); - - // Assert value is -10 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('-10'); - expect(wrapper.emitted().input).to.deep.equal([[-10]]); - - // Decrement value - await wrapper.trigger('keydown', { key: 'ArrowDown' }); - - // Assert value is 10 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('10'); - expect(wrapper.emitted().input).to.deep.equal([[-10], [10]]); - }); - - it('should correctly wrap from the maximum to minimum', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { - min: -10, max: 10, value: 9, step: 2, wrap: true, - }, - }); - - // Increment value - await wrapper.trigger('keydown', { key: 'ArrowUp' }); - - // Assert value is 10 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('10'); - expect(wrapper.emitted().input).to.deep.equal([[10]]); - - // Increment value - await wrapper.trigger('keydown', { key: 'ArrowUp' }); - - // Assert value is -10 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('-10'); - expect(wrapper.emitted().input).to.deep.equal([[10], [-10]]); - }); - - it('should format value according to padding prop', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { padding: 2 }, - }); - - // Assert value is correctly formatted - expect(wrapper.find('input').element.value).to.equal('00'); - }); -}); diff --git a/tests/unit/components/IntegerInput.spec.js b/tests/unit/components/IntegerInput.spec.js @@ -0,0 +1,255 @@ +import { expect } from 'chai'; +import { mount } from '@vue/test-utils'; +import IntegerInput from '@/components/IntegerInput.vue'; + +describe('components/IntegerInput.vue', () => { + it('value should be 0 by default', () => { + // Initialize component + const wrapper = mount(IntegerInput); + + // Assert value is 0 + expect(wrapper.find('input').element.value).to.equal('0'); + }); + + it('should read value prop', () => { + // Initialize component + const wrapper = mount(IntegerInput, { + propsData: { value: 1 }, + }); + + // Assert value is 1 + expect(wrapper.find('input').element.value).to.equal('1'); + }); + + it('up arrow should increment value by step', async () => { + // Initialize component + const wrapper = mount(IntegerInput, { + propsData: { step: 2 }, + }); + + // Press up arrow + await wrapper.trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is 1 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('2'); + expect(wrapper.emitted().input).to.deep.equal([[2]]); + }); + + it('down arrow should increment value by step', async () => { + // Initialize component + const wrapper = mount(IntegerInput, { + propsData: { step: 2 }, + }); + + // Press down arrow + await wrapper.trigger('keydown', { key: 'ArrowDown' }); + + // Assert value is -1 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('-2'); + expect(wrapper.emitted().input).to.deep.equal([[-2]]); + }); + + it('should fire input event when value changes', async () => { + // Initialize component + const wrapper = mount(IntegerInput); + + // Set value to 1 + wrapper.find('input').element.value = '1'; + await wrapper.find('input').trigger('input'); + + // Assert input event was emitted + expect(wrapper.emitted().input).to.deep.equal([[1]]); + }); + + it('should accept numerical values', async () => { + // Initialize component + const wrapper = mount(IntegerInput); + + // Try to set value to 1 + wrapper.find('input').element.value = '1'; + await wrapper.find('input').trigger('input'); + + // Assert value was accepted and input event was emitted + expect(wrapper.find('input').element.value).to.equal('1'); + expect(wrapper.emitted().input).to.deep.equal([[1]]); + }); + + it('should not accept decimal values', async () => { + // Initialize component + const wrapper = mount(IntegerInput, { + propsData: { value: 1 }, + }); + + // Try to set value to 1.5 + wrapper.find('input').element.value = '1.5'; + await wrapper.find('input').trigger('input'); + + // Assert value was not accepted and no events were emitted + expect(wrapper.find('input').element.value).to.equal('1'); + expect(wrapper.emitted().input).to.equal(undefined); + }); + + it('should not accept non numerical values', async () => { + // Initialize component + const wrapper = mount(IntegerInput, { + propsData: { value: 1 }, + }); + + // Try to set value to a + wrapper.find('input').element.value = 'a'; + await wrapper.find('input').trigger('input'); + + // Assert value was not accepted and no events were emitted + expect(wrapper.find('input').element.value).to.equal('1'); + expect(wrapper.emitted().input).to.equal(undefined); + }); + + it('should format input value on blur', async () => { + // Initialize component + const wrapper = mount(IntegerInput, { + propsData: { value: 1, padding: 3 }, + }); + + // Set value to '01' + wrapper.find('input').element.value = '01'; + await wrapper.find('input').trigger('input'); + + // Assert value was not updated and no events were emitted + expect(wrapper.find('input').element.value).to.equal('01'); + expect(wrapper.emitted().input).to.equal(undefined); + + // Trigger blur event + await wrapper.find('input').trigger('blur'); + + // Assert value was formatted but no events were emitted + expect(wrapper.find('input').element.value).to.equal('001'); + expect(wrapper.emitted().input).to.equal(undefined); + }); + + it('should allow input to be empty until blur', async () => { + // Initialize component + const wrapper = mount(IntegerInput, { + propsData: { value: 5 }, + }); + + // Set value to '' + wrapper.find('input').element.value = ''; + await wrapper.find('input').trigger('input'); + + // Assert value is '' and input event was emitted with default value + expect(wrapper.find('input').element.value).to.equal(''); + expect(wrapper.emitted().input).to.deep.equal([[0]]); + + // Trigger blur event + await wrapper.find('input').trigger('blur'); + + // Assert value is the default value but no new events were emitted + expect(wrapper.find('input').element.value).to.equal('0'); + expect(wrapper.emitted().input).to.deep.equal([[0]]); + }); + + it('should allow input to be "-" until blur', async () => { + // Initialize component + const wrapper = mount(IntegerInput, { + propsData: { value: 5 }, + }); + + // Set value to '-' + wrapper.find('input').element.value = '-'; + await wrapper.find('input').trigger('input'); + + // Assert value is '-' and input event was emitted with default value + expect(wrapper.find('input').element.value).to.equal('-'); + expect(wrapper.emitted().input).to.deep.equal([[0]]); + + // Trigger blur event + await wrapper.find('input').trigger('blur'); + + // Assert value is the default value but no new events were emitted + expect(wrapper.find('input').element.value).to.equal('0'); + expect(wrapper.emitted().input).to.deep.equal([[0]]); + }); + + it('default value should be the minimum if 0 is not valid', async () => { + // Initialize component + const wrapper = mount(IntegerInput, { + propsData: { value: 3, max: 4, min: 2 }, + }); + + // Set value to '' and trigger blur event so value must be updated + wrapper.find('input').element.value = ''; + await wrapper.find('input').trigger('input'); + await wrapper.find('input').trigger('blur'); + + // Assert value is 2 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('2'); + expect(wrapper.emitted().input).to.deep.equal([[2]]); + }); + + it('should not allow input to be below the minimum', async () => { + // Initialize component + const wrapper = mount(IntegerInput, { + propsData: { min: 10, value: 20 }, + }); + + // Try to set value to 9, which is below the minimum + wrapper.find('input').element.value = '9'; + await wrapper.find('input').trigger('input'); + + // Assert value is 10 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('10'); + expect(wrapper.emitted().input).to.deep.equal([[10]]); + + // Try to decrement value + await wrapper.trigger('keydown', { key: 'ArrowDown' }); + + // Assert value is still 10 and no new event were emitted + expect(wrapper.find('input').element.value).to.equal('10'); + expect(wrapper.emitted().input).to.deep.equal([[10]]); + }); + + it('should not allow input to be above the maximum', async () => { + // Initialize component + const wrapper = mount(IntegerInput, { + propsData: { max: 10 }, + }); + + // Try to set value to 11, which is above the maximum + wrapper.find('input').element.value = '11'; + await wrapper.find('input').trigger('input'); + + // Assert value is 10 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('10'); + expect(wrapper.emitted().input).to.deep.equal([[10]]); + + // Try to increment value + await wrapper.trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is still 10 and no new events were emitted + expect(wrapper.find('input').element.value).to.equal('10'); + expect(wrapper.emitted().input).to.deep.equal([[10]]); + }); + + it('should format value according to padding prop', async () => { + // Initialize component + const wrapper = mount(IntegerInput, { + propsData: { padding: 2 }, + }); + + // Assert value is correctly formatted + expect(wrapper.find('input').element.value).to.equal('00'); + }); + + it('should emit keydown event if arrow-keys is false', async () => { + // Initialize component + const wrapper = mount(IntegerInput, { + propsData: { arrowKeys: false }, + }); + + // Try to increment value + await wrapper.trigger('keydown', { key: 'ArrowUp' }); + + // Assert keydown event emitted + expect(wrapper.emitted().keydown.length).to.equal(1); + }); +}); 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 @@ -1,7 +1,7 @@ /* eslint-disable no-underscore-dangle */ import { expect } from 'chai'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, mount } from '@vue/test-utils'; import TimeInput from '@/components/TimeInput.vue'; describe('components/TimeInput.vue', () => { @@ -10,9 +10,9 @@ describe('components/TimeInput.vue', () => { const wrapper = shallowMount(TimeInput); // Assert value is 0:00:00.00 - expect(wrapper.vm._data.hours).to.equal(0); - expect(wrapper.vm._data.minutes).to.equal(0); - expect(wrapper.vm._data.seconds).to.equal(0.00); + expect(wrapper.vm.hours).to.equal(0); + expect(wrapper.vm.minutes).to.equal(0); + expect(wrapper.vm.seconds).to.equal(0.00); }); it('should read value prop', () => { @@ -22,9 +22,9 @@ describe('components/TimeInput.vue', () => { }); // Assert value is 1:01:01.50 - expect(wrapper.vm._data.hours).to.equal(1); - expect(wrapper.vm._data.minutes).to.equal(1); - expect(wrapper.vm._data.seconds).to.equal(1.50); + expect(wrapper.vm.hours).to.equal(1); + expect(wrapper.vm.minutes).to.equal(1); + expect(wrapper.vm.seconds).to.equal(1.50); }); it('should update when value prop changes', async () => { @@ -35,9 +35,9 @@ describe('components/TimeInput.vue', () => { await wrapper.setProps({ value: 60 }); // Assert value is 0:01:00.00 - expect(wrapper.vm._data.hours).to.equal(0); - expect(wrapper.vm._data.minutes).to.equal(1); - expect(wrapper.vm._data.seconds).to.equal(0.00); + expect(wrapper.vm.hours).to.equal(0); + expect(wrapper.vm.minutes).to.equal(1); + expect(wrapper.vm.seconds).to.equal(0.00); }); it('should emit input event when value changes', async () => { @@ -45,15 +45,134 @@ describe('components/TimeInput.vue', () => { const wrapper = shallowMount(TimeInput); // Change value to 1:00:00.00 - await wrapper.setData({ hours: 1 }); + await wrapper.setData({ internalValue: 3600 }); // Assert input event was emitted expect(wrapper.emitted().input).to.deep.equal([[3600.00]]); // Change value to 1:00:01.50 - await wrapper.setData({ seconds: 1.5 }); + await wrapper.setData({ internalValue: 3601.5 }); // Assert another input event was emitted expect(wrapper.emitted().input).to.deep.equal([[3600.00], [3601.50]]); }); + + it('up arrow should increment value', async () => { + // Initialize component + const wrapper = mount(TimeInput, { + propsData: { value: 59 }, + }); + + // Press up arrow in hours field + await wrapper.find('input.hours').trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is 01:00:59.00 and input event was emitted + expect(wrapper.vm.internalValue).to.equal(3659); + expect(wrapper.emitted().input).to.deep.equal([[3659]]); + + // Press up arrow in seconds field + await wrapper.find('input.seconds').trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is 01:01:00.00 and input event was emitted + expect(wrapper.vm.internalValue).to.equal(3660); + expect(wrapper.emitted().input).to.deep.equal([[3659], [3660]]); + }); + + it('up arrow should not increment value past the 2 field maximum', async () => { + // Initialize component + const wrapper = mount(TimeInput, { + 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 + await wrapper.find('input.seconds').trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is 99:59:59.00 and input event was emitted + expect(wrapper.vm.internalValue).to.equal(359999); + expect(wrapper.emitted().input).to.deep.equal([[359999]]); + + // Press up arrow in seconds field + await wrapper.find('input.seconds').trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is 99:59:59.99 and input event was emitted + expect(wrapper.vm.internalValue).to.equal(359999.99); + expect(wrapper.emitted().input).to.deep.equal([[359999], [359999.99]]); + + // Press up arrow in seconds field + await wrapper.find('input.seconds').trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is still 99:59:59.99 and input event was not emitted + expect(wrapper.vm.internalValue).to.equal(359999.99); + expect(wrapper.emitted().input).to.deep.equal([[359999], [359999.99]]); + }); + + it('down arrow should decrement value', async () => { + // Initialize component + const wrapper = mount(TimeInput, { + propsData: { value: 3660 }, + }); + + // Press down arrow in hours field + await wrapper.find('input.hours').trigger('keydown', { key: 'ArrowDown' }); + + // Assert value is 00:01:00.00 and input event was emitted + expect(wrapper.vm.internalValue).to.equal(60); + expect(wrapper.emitted().input).to.deep.equal([[60]]); + + // Press down arrow in seconds field + await wrapper.find('input.seconds').trigger('keydown', { key: 'ArrowDown' }); + + // Assert value is 00:00:59.00 and input event was emitted + expect(wrapper.vm.internalValue).to.equal(59); + expect(wrapper.emitted().input).to.deep.equal([[60], [59]]); + }); + + it('down arrow should not decrement value past the minimum', async () => { + // Initialize component + const wrapper = mount(TimeInput, { + propsData: { value: 1 }, + }); + + // Press down arrow in seconds field + await wrapper.find('input.seconds').trigger('keydown', { key: 'ArrowDown' }); + + // Assert value is 00:00:00.00 and input event was emitted + expect(wrapper.vm.internalValue).to.equal(0); + expect(wrapper.emitted().input).to.deep.equal([[0]]); + + // Press down arrow in seconds field + await wrapper.find('input.seconds').trigger('keydown', { key: 'ArrowDown' }); + + // Assert value is still 00:00:00.00 and input event was not emitted + expect(wrapper.vm.internalValue).to.equal(0); + expect(wrapper.emitted().input).to.deep.equal([[0]]); + }); }); diff --git a/tests/unit/utils/format.spec.js b/tests/unit/utils/format.spec.js @@ -0,0 +1,195 @@ +import { expect } from 'chai'; +import formatUtils from '@/utils/format'; + +describe('utils/format.js', () => { + describe('formatNumber method', () => { + it('should correctly format number when padding is not 0', () => { + let result = formatUtils.formatNumber(12.3, 3, 0); + expect(result).to.equal('012'); + + result = formatUtils.formatNumber(12.3, 3, 2); + expect(result).to.equal('012.30'); + + result = formatUtils.formatNumber(123, 2, 0); + expect(result).to.equal('123'); + + result = formatUtils.formatNumber(-12, 3, 0); + expect(result).to.equal('-012'); + }); + + it('should correctly format number when extraDigits is true', () => { + let result = formatUtils.formatNumber(1234, 0, 2); + expect(result).to.equal('1234.00'); + + result = formatUtils.formatNumber(1234.5, 0, 2); + expect(result).to.equal('1234.50'); + + result = formatUtils.formatNumber(1234.56, 0, 2); + expect(result).to.equal('1234.56'); + + result = formatUtils.formatNumber(1234.567, 0, 2); + expect(result).to.equal('1234.57'); + + result = formatUtils.formatNumber(1234.56, 0, 0); + expect(result).to.equal('1235'); + }); + + it('should correctly format number when extraDigits is false', () => { + let result = formatUtils.formatNumber(1234, 0, 2, false); + expect(result).to.equal('1234'); + + result = formatUtils.formatNumber(1234.5, 0, 2, false); + expect(result).to.equal('1234.5'); + + result = formatUtils.formatNumber(1234.56, 0, 2, false); + expect(result).to.equal('1234.56'); + + result = formatUtils.formatNumber(1234.567, 0, 2, false); + expect(result).to.equal('1234.57'); + + result = formatUtils.formatNumber(1234.56, 0, 0, false); + expect(result).to.equal('1235'); + }); + + it('should correctly format undefined', () => { + let result = formatUtils.formatNumber(undefined, 0, 2); + expect(result).to.equal('NaN'); + + result = formatUtils.formatNumber(undefined, 0, 2, false); + expect(result).to.equal('NaN'); + + result = formatUtils.formatNumber(undefined, 5, 2); + expect(result).to.equal('NaN'); + }); + + it('should correctly format NaN', () => { + let result = formatUtils.formatNumber(NaN, 0, 0); + expect(result).to.equal('NaN'); + + result = formatUtils.formatNumber(NaN, 0, 2, false); + expect(result).to.equal('NaN'); + + result = formatUtils.formatNumber(NaN, 5, 2); + expect(result).to.equal('NaN'); + }); + + it('should correctly format +/- Infinity', () => { + let result = formatUtils.formatNumber(Infinity); + expect(result).to.equal('Infinity'); + + result = formatUtils.formatNumber(Infinity, 10, 2); + expect(result).to.equal('Infinity'); + + result = formatUtils.formatNumber(-Infinity); + expect(result).to.equal('-Infinity'); + }); + + it('should correctly format numbers less than 1', () => { + let result = formatUtils.formatNumber(0.123, 0, 0); + expect(result).to.equal('0'); + + result = formatUtils.formatNumber(0.123, 0, 2); + expect(result).to.equal('0.12'); + }); + + it('should correctly format negative numbers', () => { + let result = formatUtils.formatNumber(-12, 0, 2, false); + expect(result).to.equal('-12'); + + result = formatUtils.formatNumber(-12, 0, 2); + expect(result).to.equal('-12.00'); + + result = formatUtils.formatNumber(-12.34, 0, 2); + expect(result).to.equal('-12.34'); + + result = formatUtils.formatNumber(-12.34, 3, 2); + expect(result).to.equal('-012.34'); + + result = formatUtils.formatNumber(-0.12, 0, 2); + expect(result).to.equal('-0.12'); + }); + }); + + describe('formatDuration method', () => { + it('should correctly divide durations into parts', () => { + const result = formatUtils.formatDuration(3600 + 120 + 3 + 0.4); + expect(result).to.equal('01:02:03.40'); + }); + + it('should correctly format duration when padding is 7', () => { + const result = formatUtils.formatDuration(3600 + 120 + 3 + 0.4, 7); + expect(result).to.equal('01:02:03.40'); + }); + + it('should correctly format duration when padding is 3', () => { + let result = formatUtils.formatDuration(3600 + 120 + 3 + 0.4, 3); + expect(result).to.equal('1:02:03.40'); + + result = formatUtils.formatDuration(120 + 3 + 0.4, 3); + expect(result).to.equal('2:03.40'); + + result = formatUtils.formatDuration(3 + 0.4, 3); + expect(result).to.equal('0:03.40'); + }); + + it('should correctly format duration when padding is 0', () => { + const result = formatUtils.formatDuration(0.4, 0); + expect(result).to.equal('0.40'); + }); + + it('should correctly format duration when digits is 3', () => { + const result = formatUtils.formatDuration(3600 + 120 + 3 + 0.4567, 0, 3); + expect(result).to.equal('1:02:03.457'); + }); + + it('should correctly format duration when digits is 0', () => { + const result = formatUtils.formatDuration(3600 + 120 + 3 + 0.456, 0, 0); + expect(result).to.equal('1:02:03'); + }); + + it('should correctly format NaN', () => { + const result = formatUtils.formatDuration(NaN); + expect(result).to.equal('NaN'); + }); + + it('should correctly format +/- Infinity', () => { + let result = formatUtils.formatDuration(Infinity); + expect(result).to.equal('Infinity'); + + result = formatUtils.formatDuration(-Infinity); + expect(result).to.equal('-Infinity'); + }); + + it('should correctly format 0 when padding is 0', () => { + const result = formatUtils.formatDuration(0, 0); + expect(result).to.equal('0.00'); + }); + + it('should correctly format negative durations', () => { + const result = formatUtils.formatDuration(-3600 - 120 - 3 - 0.4); + expect(result).to.equal('-01:02:03.40'); + }); + + it('should correctly format 59.9999', () => { + const result = formatUtils.formatDuration(59.9999); + expect(result).to.equal('00:01:00.00'); + }); + + it('should correctly format duration when extraDigits is false', () => { + let result = formatUtils.formatDuration(83, 0, 2, false); + expect(result).to.equal('1:23'); + + result = formatUtils.formatDuration(83.4, 0, 2, false); + expect(result).to.equal('1:23.4'); + + result = formatUtils.formatDuration(83.45, 0, 2, false); + expect(result).to.equal('1:23.45'); + + result = formatUtils.formatDuration(83.456, 0, 2, false); + expect(result).to.equal('1:23.46'); + + result = formatUtils.formatDuration(83.45, 0, 0, false); + expect(result).to.equal('1:23'); + }); + }); +}); 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); + }); + }); +}); diff --git a/tests/unit/utils/units.spec.js b/tests/unit/utils/units.spec.js @@ -61,70 +61,4 @@ describe('utils/units.js', () => { expect(result).to.equal(1); }); }); - - describe('formatDuration method', () => { - it('should correctly divide durations into parts', () => { - const result = units.formatDuration(3600 + 120 + 3 + 0.4); - expect(result).to.equal('01:02:03.40'); - }); - - it('should correctly format duration when padding is 7', () => { - const result = units.formatDuration(3600 + 120 + 3 + 0.4, 7); - expect(result).to.equal('01:02:03.40'); - }); - - it('should correctly format duration when padding is 3', () => { - let result = units.formatDuration(3600 + 120 + 3 + 0.4, 3); - expect(result).to.equal('1:02:03.40'); - - result = units.formatDuration(120 + 3 + 0.4, 3); - expect(result).to.equal('2:03.40'); - - result = units.formatDuration(3 + 0.4, 3); - expect(result).to.equal('0:03.40'); - }); - - it('should correctly format duration when padding is 0', () => { - const result = units.formatDuration(0.4, 0); - expect(result).to.equal('0.40'); - }); - - it('should correctly format duration when digits is 3', () => { - const result = units.formatDuration(3600 + 120 + 3 + 0.4567, 0, 3); - expect(result).to.equal('1:02:03.457'); - }); - - it('should correctly format duration when digits is 0', () => { - const result = units.formatDuration(3600 + 120 + 3 + 0.456, 0, 0); - expect(result).to.equal('1:02:03'); - }); - - it('should correctly format NaN', () => { - const result = units.formatDuration(NaN); - expect(result).to.equal('NaN'); - }); - - it('should correctly format +/- Infinity', () => { - let result = units.formatDuration(Infinity); - expect(result).to.equal('Infinity'); - - result = units.formatDuration(-Infinity); - expect(result).to.equal('-Infinity'); - }); - - it('should correctly format 0 when padding is 0', () => { - const result = units.formatDuration(0, 0); - expect(result).to.equal('0.00'); - }); - - it('should correctly format negative durations', () => { - const result = units.formatDuration(-3600 - 120 - 3 - 0.4); - expect(result).to.equal('-01:02:03.40'); - }); - - it('should correctly format 59.9999', () => { - const result = units.formatDuration(59.9999); - expect(result).to.equal('00:01:00.00'); - }); - }); });