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:
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̇O₂: <b>{{ vo2.toFixed(1) }}</b> ml/kg/min
- (<b>{{ vo2Percentage.toFixed(1) }}%</b> of max)
+ V̇O₂: <b>{{ formatNumber(vo2, 0, 1, true) }}</b> ml/kg/min
+ (<b>{{ formatNumber(vo2Percentage, 0, 1, true) }}%</b> of max)
</div>
<div>
- V̇O₂ Max: <b>{{ vo2Max.toFixed(1) }}</b> ml/kg/min
+ V̇O₂ 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 & 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');
- });
- });
});