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