running-tools

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

commit b20593e4b112365acd9426dd663c087b05e61b5e
parent 4306a1e6fa2d6248fc4a4c0b8020d1aff715664c
Author: ashermorgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Thu, 16 Sep 2021 19:57:41 -0700

Merge branch 'dev'

Version 1.1.0

Diffstat:
MCHANGELOG.md | 16++++++++++++++--
MREADME.md | 4++--
Mpackage-lock.json | 32++++++++++++++++++++++++++++++--
Mpackage.json | 3++-
Msrc/App.vue | 18+++++++++++++++---
Dsrc/assets/chevron-left.svg | 1-
Dsrc/assets/edit.svg | 1-
Msrc/assets/global.css | 30+++++++++++++++++++++++++-----
Rpublic/img/icons/icon.svg -> src/assets/icon.svg | 0
Dsrc/assets/plus-circle.svg | 1-
Dsrc/assets/rotate-ccw.svg | 1-
Dsrc/assets/trash-2.svg | 1-
Dsrc/assets/x.svg | 1-
Asrc/components/TargetTable.vue | 342+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/TimeInput.vue | 2+-
Dsrc/components/TimeTable.vue | 292-------------------------------------------------------------------------------
Msrc/utils/races.js | 519+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Msrc/utils/units.js | 281++++++++++++++++++++++++++++++++++++-------------------------------------------
Msrc/views/PaceCalculator.vue | 162+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Msrc/views/RaceCalculator.vue | 302++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Msrc/views/UnitCalculator.vue | 51++++++++++++++++++++++++---------------------------
Atests/unit/components/TargetTable.spec.js | 34++++++++++++++++++++++++++++++++++
Dtests/unit/components/TimeTable.spec.js | 34----------------------------------
Mtests/unit/utils/races.spec.js | 184++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mtests/unit/utils/units.spec.js | 60++++++++++--------------------------------------------------
Mtests/unit/views/PaceCalculator.spec.js | 30+++++++++++++++++++++++++++++-
Mtests/unit/views/RaceCalculator.spec.js | 36+++++++++++++++++++++++++++++++++---
Mtests/unit/views/UnitCalculator.spec.js | 11+++++++----
28 files changed, 1568 insertions(+), 881 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.1.0] - 2021-09-16 + +### Added +- Time based targets in pace and race calculators +- Advanced race calculator options and output + +### Changed +- Pace calculator shows pace of each result +- Improved calculator interfaces +- Default units are chosen automatically + ## [1.0.0] - 2021-08-30 ### Added @@ -23,6 +34,7 @@ ### Added - Basic app structure -[1.0.0]: https://github.com/ashermorgan/running-tools/compare/0.2.0...1.0.0 -[0.2.0]: https://github.com/ashermorgan/running-tools/compare/0.1.0...0.2.0 +[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 +[0.2.0]: https://github.com/ashermorgan/running-tools/releases/tag/0.2.0 [0.1.0]: https://github.com/ashermorgan/running-tools/releases/tag/0.1.0 diff --git a/README.md b/README.md @@ -4,8 +4,8 @@ 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 times for different distances at the same pace -- [Race Calculator](https://ashermorgan.github.io/running-tools/#/calculate/races): Estimate equivalent times for races of different distances +- [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 - [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,15 +1,17 @@ { "name": "running-tools", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "version": "1.0.0", + "name": "running-tools", + "version": "1.1.0", "dependencies": { "core-js": "^3.6.5", "register-service-worker": "^1.7.1", "vue": "^2.6.11", + "vue-feather-icons": "^5.1.0", "vue-router": "^3.2.0" }, "devDependencies": { @@ -3304,6 +3306,11 @@ "node": ">=4" } }, + "node_modules/babel-helper-vue-jsx-merge-props": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-2.0.3.tgz", + "integrity": "sha512-gsLiKK7Qrb7zYJNgiXKpXblxbV5ffSwR0f5whkPAaBAR4fhi6bwRZxX9wBlIc5M/v8CCkXUbXZL4N/nSE97cqg==" + }, "node_modules/babel-loader": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.2.tgz", @@ -15820,6 +15827,14 @@ "node": ">=8.0.0" } }, + "node_modules/vue-feather-icons": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vue-feather-icons/-/vue-feather-icons-5.1.0.tgz", + "integrity": "sha512-ZyM2yFGmL9DYLZYHm63KV1zCQOj8czC2LzDSkxoIp9o6zMAOY4yv1FkxbX+XNUwcH3RRrAuvf25Ij7CnUUsQVA==", + "dependencies": { + "babel-helper-vue-jsx-merge-props": "^2.0.2" + } + }, "node_modules/vue-hot-reload-api": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz", @@ -19932,6 +19947,11 @@ "babylon": "^6.18.0" } }, + "babel-helper-vue-jsx-merge-props": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-2.0.3.tgz", + "integrity": "sha512-gsLiKK7Qrb7zYJNgiXKpXblxbV5ffSwR0f5whkPAaBAR4fhi6bwRZxX9wBlIc5M/v8CCkXUbXZL4N/nSE97cqg==" + }, "babel-loader": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.2.tgz", @@ -30085,6 +30105,14 @@ } } }, + "vue-feather-icons": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vue-feather-icons/-/vue-feather-icons-5.1.0.tgz", + "integrity": "sha512-ZyM2yFGmL9DYLZYHm63KV1zCQOj8czC2LzDSkxoIp9o6zMAOY4yv1FkxbX+XNUwcH3RRrAuvf25Ij7CnUUsQVA==", + "requires": { + "babel-helper-vue-jsx-merge-props": "^2.0.2" + } + }, "vue-hot-reload-api": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz", diff --git a/package.json b/package.json @@ -1,6 +1,6 @@ { "name": "running-tools", - "version": "1.0.0", + "version": "1.1.0", "description": "A collection of tools for runners and their coaches that calculate splits, predict race times, convert units, and more", "private": true, "scripts": { @@ -13,6 +13,7 @@ "core-js": "^3.6.5", "register-service-worker": "^1.7.1", "vue": "^2.6.11", + "vue-feather-icons": "^5.1.0", "vue-router": "^3.2.0" }, "devDependencies": { diff --git a/src/App.vue b/src/App.vue @@ -3,7 +3,7 @@ <header> <router-link :to="{ name: $route.meta.back }" v-if="$route.meta.back" class="icon" title="Back"> - <img alt="" src="@/assets/chevron-left.svg"/> + <chevron-left-icon/> </router-link> <h1 v-if="$route.meta.title"> @@ -22,6 +22,18 @@ </div> </template> +<script> +import { ChevronLeftIcon } from 'vue-feather-icons'; + +export default { + name: 'App', + + components: { + ChevronLeftIcon, + }, +}; +</script> + <style scoped> header { background-color: var(--theme); @@ -36,9 +48,9 @@ header a { height: 2em; width: 2em; } -header a img { +header a svg { padding: 0em; - filter: invert(0%) !important; + color: #000000; } h1 { grid-column: 3; diff --git a/src/assets/chevron-left.svg b/src/assets/chevron-left.svg @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-left"><polyline points="15 18 9 12 15 6"></polyline></svg> diff --git a/src/assets/edit.svg b/src/assets/edit.svg @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-edit"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg> diff --git a/src/assets/global.css b/src/assets/global.css @@ -15,6 +15,21 @@ input, select, button { button { cursor: pointer; } +.link, .link:focus, .link:active, .link:hover { + border: none; + background: none; +} +a, .link { + text-decoration: none; +} +a:focus, .link:focus { + text-decoration: underline; +} +@media (hover: hover) { + a:hover, .link:hover { + text-decoration: underline; + } +} /* styles for icons */ .icon { @@ -24,7 +39,7 @@ button { cursor: pointer; vertical-align: middle; } -.icon img { +.icon svg { width: 100%; height: 100%; padding: 0.3em; @@ -38,7 +53,7 @@ button { } /* element colors */ -body, input, select, button, option { +body, input, select, button, option, .icon svg { color: var(--foreground); } body { @@ -61,6 +76,9 @@ button:active { button, input, select, tr { border: 1px solid var(--background5); } +a, .link { + color: var(--link); +} /* light/default theme */ :root { @@ -84,6 +102,9 @@ button, input, select, tr { /* The foreground color of app elements */ --foreground: #000000; + + /* The color of links */ + --link: hsl(210, 100%, 40%); } /* dark mode */ @@ -95,9 +116,7 @@ button, input, select, tr { --background4: hsl(210, 20%, 25%); --background5: hsl(210, 20%, 30%); --foreground: #e8e8e8; - } - .icon img { - filter: invert(90%); + --link: hsl(210, 100%, 65%); } } @@ -110,5 +129,6 @@ button, input, select, tr { --background4: #ffffff; --background5: #000000; --foreground: #000000; + --link: #0000ff; } } diff --git a/public/img/icons/icon.svg b/src/assets/icon.svg diff --git a/src/assets/plus-circle.svg b/src/assets/plus-circle.svg @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-plus-circle"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line></svg> diff --git a/src/assets/rotate-ccw.svg b/src/assets/rotate-ccw.svg @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-rotate-ccw"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg> diff --git a/src/assets/trash-2.svg b/src/assets/trash-2.svg @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-trash-2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg> diff --git a/src/assets/x.svg b/src/assets/x.svg @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-x"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg> diff --git a/src/components/TargetTable.vue b/src/components/TargetTable.vue @@ -0,0 +1,342 @@ +<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 @@ -31,7 +31,7 @@ export default { type: Number, default: 0, validator(value) { - return value >= 0 && value <= 86399.99; + return value >= 0 && value <= 359999.99; }, }, }, diff --git a/src/components/TimeTable.vue b/src/components/TimeTable.vue @@ -1,292 +0,0 @@ -<template> - <div class="time-table"> - <table class="results" v-show="!inEditMode"> - <thead> - <tr> - <th colspan="2">Distance</th> - - <th>Time</th> - - <th> - <button class="icon" title="Edit Targets" @click="inEditMode=true" v-blur> - <img alt="" src="@/assets/edit.svg"> - </button> - </th> - </tr> - </thead> - - <tbody> - <tr v-for="(item, index) in results" :key="index"> - <td> - {{ item.distanceValue.toFixed(2) }} - {{ distanceSymbols[item.distanceUnit] }} - </td> - - <td>in</td> - - <td colspan="2"> - {{ formatDuration(item.time, 0, 2) }} - </td> - </tr> - - <tr v-if="results.length === 0" class="empty-message"> - <td colspan="4"> - There aren't any targets,<br> - click - <img alt="Edit Targets" src="@/assets/edit.svg"> - to add one - </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> - <img alt="" src="@/assets/rotate-ccw.svg"> - </button> - <button class="icon" title="Close" @click="inEditMode=false" v-blur> - <img alt="" src="@/assets/x.svg"> - </button> - </th> - </tr> - </thead> - - <tbody> - <tr v-for="(item, index) in targets" :key="index"> - <td> - <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 }} - </option> - </select> - </td> - - <td> - <button class="icon" title="Remove Target" @click="targets.splice(index, 1)" v-blur> - <img alt="" src="@/assets/trash-2.svg"> - </button> - </td> - </tr> - - <tr v-if="targets.length === 0" class="empty-message"> - <td colspan="2"> - There aren't any targets,<br> - click - <img alt="Add Target" src="@/assets/plus-circle.svg"> - to add one - </td> - </tr> - </tbody> - - <tfoot> - <tr> - <td colspan="2"> - <button class="icon" title="Add Target" @click="targets.push({distanceValue: 1, - distanceUnit: 'miles'})" v-blur> - <img alt="" src="@/assets/plus-circle.svg"> - </button> - </td> - </tr> - </tfoot> - </table> - </div> -</template> - -<script> -import unitUtils from '@/utils/units'; -import storage from '@/utils/localStorage'; - -import DecimalInput from '@/components/DecimalInput.vue'; - -import blur from '@/directives/blur'; - -export default { - name: 'TimeTable', - - components: { - DecimalInput, - }, - - 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, - }, - }, - - data() { - return { - /** - * The names of the distance units - */ - distanceUnits: unitUtils.DISTANCE_UNIT_NAMES, - - /** - * The symbols of the distance units - */ - distanceSymbols: unitUtils.DISTANCE_UNIT_SYMBOLS, - - /** - * The formatDuration method - */ - formatDuration: unitUtils.formatDuration, - - /** - * 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.sort((a, b) => unitUtils.convertDistance(a.distanceValue, a.distanceUnit, - 'meters') - unitUtils.convertDistance(b.distanceValue, b.distanceUnit, 'meters')); - }, - }, - - /** - * Close edit targets table - */ - deactivated() { - this.inEditMode = false; - }, -}; -</script> - -<style scoped> -/* time table */ -.results th:last-child { - text-align: right; -} - -/* edit targets table */ -.targets th:last-child, .targets td:last-child { - text-align: right; -} -.targets td select { - margin-left: 0.2em; -} -.targets tfoot td { - text-align: center !important; - padding: 0.5em 0.2em; -} - -/* 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 img { - height: 1em; - width: 1em; -} -@media (prefers-color-scheme: dark) { - .empty-message img { - filter: invert(90%); - } -} -</style> diff --git a/src/utils/races.js b/src/utils/races.js @@ -1,159 +1,412 @@ /** - * Predict a race time using the Purdy Points Model - * https://www.cs.uml.edu/~phoffman/xcinfo3.html - * @param {Number} d1 The distance of the input race in meters - * @param {Number} t1 The finish time of the input race in seconds - * @param {Number} d2 The distance of the output race in meters - * @returns {Number} The predicted time for the output race in seconds + * Estimate the point at which a function returns a target value using Newton's Method + * @param {Number} initialEstimate The initial estimate + * @param {Number} target The target function output + * @param {Function} method The function + * @param {Function} derivative The function derivative + * @param {Number} precision The acceptable precision + * @param {Number} iterations The maximum number of iterations + * @returns {Number} The refined estimate */ -function PurdyPointsModel(d1, t1, d2) { - // Declare constants - const c1 = 11.15895; - const c2 = 4.304605; - const c3 = 0.5234627; - const c4 = 4.031560; - const c5 = 2.316157; - const r1 = 3.796158e-2; - const r2 = 1.646772e-3; - const r3 = 4.107670e-4; - const r4 = 7.068099e-6; - const r5 = 5.220990e-9; - - // Calculate world record velocity from running curve - const v1 = (-c1 * Math.exp(-r1 * d1)) - + (c2 * Math.exp(-r2 * d1)) - + (c3 * Math.exp(-r3 * d1)) - + (c4 * Math.exp(-r4 * d1)) - + (c5 * Math.exp(-r5 * d1)); - const v2 = (-c1 * Math.exp(-r1 * d2)) - + (c2 * Math.exp(-r2 * d2)) - + (c3 * Math.exp(-r3 * d2)) - + (c4 * Math.exp(-r4 * d2)) - + (c5 * Math.exp(-r5 * d2)); - - // Calculate world record time - const twsec1 = d1 / v1; - const twsec2 = d2 / v2; - - // Calculate constants - const k1 = 0.0654 - (0.00258 * v1); - const k2 = 0.0654 - (0.00258 * v2); - const a1 = 85 / k1; - const a2 = 85 / k2; - const b1 = 1 - (1035 / a1); - const b2 = 1 - (1035 / a2); - - // Calculate Purdy Points for distance 1 - const points = a1 * ((twsec1 / t1) - b1); - - // Calculate time for distance 2 - const seconds = (a2 * twsec2) / (points + (a2 * b2)); - - // Return predicted time - return seconds; -} +function NewtonsMethod(initialEstimate, target, method, derivative, precision, iterations = 500) { + // Initialize estimate + let estimate = initialEstimate; + let estimateValue; -/** - * Calculate a runner's VO2 max from their performance in a race - * @param {Number} d The race distance in meters - * @param {Number} t The finish time in minutes - * @returns {Number} The runner's VO2 max - */ -function VO2Max(d, t) { - const v = d / t; - const result = (-4.6 + (0.182 * v) + (0.000104 * (v ** 2))) - / (0.8 + (0.189 * Math.exp(-0.0128 * t)) + (0.299 * Math.exp(-0.193 * t))); - return result; + for (let i = 0; i < iterations; i += 1) { + // Evaluate function at estimate + estimateValue = method(estimate); + + // Check if estimate is close enough + if (Math.abs(target - estimateValue) < precision) { + break; + } + + // Refine estimate + estimate -= (estimateValue - target) / derivative(estimate); + } + + // Return refined estimate + return estimate; } -/** - * Calculate the derivative with respect to time of the VO2 max curve at a specific point - * @param {Number} d The race distance in meters - * @param {Number} t The finish time in minutes - * @return {Number} The derivative +/* + * Methods that implement the Purdy Points race prediction model + * https://www.cs.uml.edu/~phoffman/xcinfo3.html */ -function VO2MaxDerivative(d, t) { - const result = ((-575000 * (t ** 2)) + (22750 * d * t) + (13 * (d ** 2))) / (125 - * (t ** 2) * (189 * Math.exp((-8 * t) / 625) + (299 * Math.exp((-193 * t) / 1000) + 800))); - return result; -} +const PurdyPointsModel = { + /** + * Calculate the Purdy Point variables for a distance + * @param {Number} d The distance in meters + * @returns {Object} The Purdy Point variables + */ + getVariables(d) { + // Declare constants + const c1 = 11.15895; + const c2 = 4.304605; + const c3 = 0.5234627; + const c4 = 4.031560; + const c5 = 2.316157; + const r1 = 3.796158e-2; + const r2 = 1.646772e-3; + const r3 = 4.107670e-4; + const r4 = 7.068099e-6; + const r5 = 5.220990e-9; -/** - * Predict a race time using the VO2 Max Model + // Calculate world record velocity from running curve + const v = (-c1 * Math.exp(-r1 * d)) + + (c2 * Math.exp(-r2 * d)) + + (c3 * Math.exp(-r3 * d)) + + (c4 * Math.exp(-r4 * d)) + + (c5 * Math.exp(-r5 * d)); + + // Calculate world record time + const twsec = d / v; + + // Calculate constants + const k = 0.0654 - (0.00258 * v); + const a = 85 / k; + const b = 1 - (1035 / a); + + // Return Purdy Point variables + return { + twsec, + a, + b, + }; + }, + + /** + * Get the Purdy Points for a race + * @param {Number} d The distance of the race in meters + * @param {Number} t The finish time of the race in seconds + * @returns {Number} The Purdy Points for the race + */ + getPurdyPoints(d, t) { + // Get variables + const variables = this.getVariables(d); + + // Calculate Purdy Points + const points = variables.a * ((variables.twsec / t) - variables.b); + + // Return Purdy Points + return points; + }, + + /** + * Predict a race time using the Purdy Points Model + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d2 The distance of the output race in meters + * @returns {Number} The predicted time for the output race in seconds + */ + predictTime(d1, t1, d2) { + // Calculate Purdy Points for distance 1 + const points = this.getPurdyPoints(d1, t1); + + // Calculate time for distance 2 + const variables = this.getVariables(d2); + const seconds = (variables.a * variables.twsec) / (points + (variables.a * variables.b)); + + // Return predicted time + return seconds; + }, + + /** + * Calculate the derivative with respect to distance of the Purdy Points curve at a specific point + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d2 The distance of the output race in meters + * @return {Number} The derivative with respect to distance + */ + derivative(d1, t1, d2) { + const result = (85 * d2) / (((2316157 * Math.exp(-(522099 * d2) / 100000000000000)) / 1000000 + + (100789 * Math.exp(-(7068099 * d2) / 1000000000000)) / 25000 + (5234627 * Math.exp(-(410767 + * d2) / 1000000000)) / 10000000 + (860921 * Math.exp(-(411693 * d2) / 250000000)) / 200000 + - (223179 * Math.exp(-(1898079 * d2) / 50000000)) / 20000) * (327 / 5000 - (129 * ((2316157 + * Math.exp(-(522099 * d2) / 100000000000000)) / 1000000 + (100789 * Math.exp(-(7068099 * d2) + / 1000000000000)) / 25000 + (5234627 * Math.exp(-(410767 * d2) / 1000000000)) / 10000000 + + (860921 * Math.exp(-(411693 * d2) / 250000000)) / 200000 - (223179 * Math.exp(-(1898079 + * d2) / 50000000)) / 20000)) / 50000) * ((85 * (1 - (207 * (327 / 5000 - (129 * ((2316157 + * Math.exp(-(522099 * d2) / 100000000000000)) / 1000000 + (100789 * Math.exp(-(7068099 * d2) + / 1000000000000)) / 25000 + (5234627 * Math.exp(-(410767 * d2) / 1000000000)) / 10000000 + + (860921 * Math.exp(-(411693 * d2) / 250000000)) / 200000 - (223179 * Math.exp(-(1898079 + * d2) / 50000000)) / 20000)) / 50000)) / 17)) / (327 / 5000 - (129 * ((2316157 + * Math.exp(-(522099 * d2) / 100000000000000)) / 1000000 + (100789 * Math.exp(-(7068099 * d2) + / 1000000000000)) / 25000 + (5234627 * Math.exp(-(410767 * d2) / 1000000000)) / 10000000 + + (860921 * Math.exp(-(411693 * d2) / 250000000)) / 200000 - (223179 * Math.exp(-(1898079 + * d2) / 50000000)) / 20000)) / 50000) + (85 * (d1 / (((2316157 * Math.exp(-(522099 * d1) + / 100000000000000)) / 1000000 + (100789 * Math.exp(-(7068099 * d1) / 1000000000000)) / 25000 + + (5234627 * Math.exp(-(410767 * d1) / 1000000000)) / 10000000 + (860921 * Math.exp(-(411693 + * d1) / 250000000)) / 200000 - (223179 * Math.exp(-(1898079 * d1) / 50000000)) / 20000) * t1) + + (207 * (327 / 5000 - (129 * ((2316157 * Math.exp(-(522099 * d1) / 100000000000000)) + / 1000000 + (100789 * Math.exp(-(7068099 * d1) / 1000000000000)) / 25000 + (5234627 + * Math.exp(-(410767 * d1) / 1000000000)) / 10000000 + (860921 * Math.exp(-(411693 * d1) + / 250000000)) / 200000 - (223179 * Math.exp(-(1898079 * d1) / 50000000)) / 20000)) / 50000)) + / 17 - 1)) / (327 / 5000 - (129 * ((2316157 * Math.exp(-(522099 * d1) / 100000000000000)) + / 1000000 + (100789 * Math.exp(-(7068099 * d1) / 1000000000000)) / 25000 + (5234627 + * Math.exp(-(410767 * d1) / 1000000000)) / 10000000 + (860921 * Math.exp(-(411693 * d1) + / 250000000)) / 200000 - (223179 * Math.exp(-(1898079 * d1) / 50000000)) / 20000)) / 50000))); + return result; + }, + + /** + * Predict a race distance using the Purdy Points Model + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t2 The finish time of the output race in seconds + * @returns {Number} The predicted distance for the output race in meters + */ + predictDistance(t1, d1, t2) { + // Initialize estimate + let estimate = (d1 * t2) / t1; + + // Refine estimate + const method = (x) => this.predictTime(d1, t1, x); + const derivative = (x) => this.derivative(d1, t1, x) / 100; // Derivative on its own is too slow + estimate = NewtonsMethod(estimate, t2, method, derivative, 0.01); + + // Return estimate + return estimate; + }, +}; + +/* + * Methods that implement the VO2 Max race prediction model * http://run-down.com/statistics/calcs_explained.php - * @param {Number} d1 The distance of the input race in meters - * @param {Number} t1 The finish time of the input race in seconds - * @param {Number} d2 The distance of the output race in meters - * @param {Number} iterations The maximum number of times to refine the prediction - * @returns {Number} The predicted time for the output race in seconds */ -function VO2MaxModel(d1, t1, d2, iterations = 500) { - // Calculate input VO2 max - const inputVO2 = VO2Max(d1, t1 / 60); +const VO2MaxModel = { + /** + * Calculate the VO2 of a runner during a race + * @param {Number} d The race distance in meters + * @param {Number} t The finish time in seconds + * @returns {Number} The VO2 + */ + getVO2(d, t) { + const minutes = t / 60; + const v = d / minutes; + const result = -4.6 + (0.182 * v) + (0.000104 * (v ** 2)); + return result; + }, - // Initialize estimate - let estimate = (t1 * d2) / (d1 * 60); - let estimateVO2; + /** + * Calculate the percentage of VO2 max a runner is at during a race + * @param {Number} t The race time in seconds + * @returns {Number} The percentage of VO2 max + */ + getVO2Percentage(t) { + const minutes = t / 60; + const result = 0.8 + (0.189 * Math.exp(-0.0128 * minutes)) + (0.299 * Math.exp(-0.193 + * minutes)); + return result; + }, - for (let i = 0; i < iterations; i += 1) { - // Get estimate's VO2 max - estimateVO2 = VO2Max(d2, estimate); + /** + * Calculate a runner's VO2 max from a race result + * @param {Number} d The race distance in meters + * @param {Number} t The finish time in seconds + * @returns {Number} The runner's VO2 max + */ + getVO2Max(d, t) { + const result = this.getVO2(d, t) / this.getVO2Percentage(t); + return result; + }, - // Check if estimate is close enough - if (Math.abs(inputVO2 - estimateVO2) < 0.0001) { - break; - } + /** + * Calculate the derivative with respect to time of the VO2 max curve at a specific point + * @param {Number} d The race distance in meters + * @param {Number} t The finish time in seconds + * @return {Number} The derivative with respect to time + */ + VO2MaxTimeDerivative(d, t) { + const result = (-(273 * d) / (25 * (t ** 2)) - (468 * (d ** 2)) / (625 * (t ** 3))) / ((189 + * Math.exp(-(2 * t) / 9375)) / 1000 + (299 * Math.exp(-(193 * t) / 60000)) / 1000 + 4 / 5) + - (((273 * d) / (25 * t) + (234 * (d ** 2)) / (625 * (t ** 2)) - 23 / 5) * (-(63 + * Math.exp(-(2 * t) / 9375)) / 1562500 - (57707 * Math.exp(-(193 * t) / 60000)) / 60000000)) + / (((189 * Math.exp(-(2 * t) / 9375)) / 1000 + (299 * Math.exp(-(193 * t) / 60000)) / 1000 + + 4 / 5) ** 2); + return result; + }, + + /** + * Predict a race time using the VO2 Max Model + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d2 The distance of the output race in meters + * @returns {Number} The predicted time for the output race in seconds + */ + predictTime(d1, t1, d2) { + // Calculate input VO2 max + const inputVO2 = this.getVO2Max(d1, t1); + + // Initialize estimate + let estimate = (t1 * d2) / d1; // Refine estimate - estimate += (estimateVO2 - inputVO2) / VO2MaxDerivative(d2, estimate); - } + const method = (x) => this.getVO2Max(d2, x); + const derivative = (x) => this.VO2MaxTimeDerivative(d2, x); + estimate = NewtonsMethod(estimate, inputVO2, method, derivative, 0.0001); - // Return estimate - return estimate * 60; -} + // Return estimate + return estimate; + }, -/** - * Predict a race time using Dave Cameron's Model + /** + * Calculate the derivative with respect to distance of the VO2 max curve at a specific point + * @param {Number} d The race distance in meters + * @param {Number} t The finish time in seconds + * @return {Number} The derivative with respect to distance + */ + VO2MaxDistanceDerivative(d, t) { + const result = ((468 * d) / (625 * (t ** 2)) + 273 / (25 * t)) / ((189 * Math.exp(-(2 * t) + / 9375)) / 1000 + (299 * Math.exp(-(193 * t) / 60000)) / 1000 + 4 / 5); + return result; + }, + + /** + * Predict a race distance using the VO2 Max Model + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t2 The finish time of the output race in seconds + * @returns {Number} The predicted distance for the output race in meters + */ + predictDistance(t1, d1, t2) { + // Calculate input VO2 max + const inputVO2 = this.getVO2Max(d1, t1); + + // Initialize estimate + let estimate = (d1 * t2) / t1; + + // Refine estimate + const method = (x) => this.getVO2Max(x, t2); + const derivative = (x) => this.VO2MaxDistanceDerivative(x, t2); + estimate = NewtonsMethod(estimate, inputVO2, method, derivative, 0.0001); + + // Return estimate + return estimate; + }, +}; + +/* + * Methods that implement Dave Cameron's race prediction model * https://www.cs.uml.edu/~phoffman/cammod.html - * @param {Number} d1 The distance of the input race in meters - * @param {Number} t1 The finish time of the input race in seconds - * @param {Number} d2 The distance of the output race in meters - * @returns {Number} The predicted time for the output race in seconds */ -function CameronModel(d1, t1, d2) { - const a = 13.49681 - (0.000030363 * d1) + (835.7114 / (d1 ** 0.7905)); - const b = 13.49681 - (0.000030363 * d2) + (835.7114 / (d2 ** 0.7905)); - return (t1 / d1) * (a / b) * d2; -} +const CameronModel = { + /** + * Predict a race time using Dave Cameron's Model + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d2 The distance of the output race in meters + * @returns {Number} The predicted time for the output race in seconds + */ + predictTime(d1, t1, d2) { + const a = 13.49681 - (0.000030363 * d1) + (835.7114 / (d1 ** 0.7905)); + const b = 13.49681 - (0.000030363 * d2) + (835.7114 / (d2 ** 0.7905)); + return (t1 / d1) * (a / b) * d2; + }, -/** - * Predict a race time using Pete Riegel's Model + /** + * Calculate the derivative with respect to distance of the Cameron curve at a specific point + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d2 The distance of the output race in meters + * @return {Number} The derivative with respect to distance + */ + derivative(d1, t1, d2) { + const result = -(100 * (30363 * (d1 ** (3581 / 2000)) - 13496810000 * (d1 ** (1581 / 2000)) + - 835711400000) * t1 * (134968100 * (d2 ** (3581 / 2000)) + 14963412617 * d2)) / ((d1 ** (3581 + / 2000)) * (d2 ** (419 / 2000)) * ((30363 * (d2 ** (3581 / 2000)) - 13496810000 * (d2 ** (1581 + / 2000)) - 835711400000) ** 2)); + return result; + }, + + /** + * Predict a race distance using Dave Cameron's Model + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t2 The finish time of the output race in seconds + * @returns {Number} The predicted distance for the output race in meters + */ + predictDistance(t1, d1, t2) { + // Initialize estimate + let estimate = (d1 * t2) / t1; + + // Refine estimate + const method = (x) => this.predictTime(d1, t1, x); + const derivative = (x) => this.derivative(d1, t1, x); + estimate = NewtonsMethod(estimate, t2, method, derivative, 0.01); + + // Return estimate + return estimate; + }, +}; + +/* + * Methods that implement Pete Riegel's race prediction model * https://en.wikipedia.org/wiki/Peter_Riegel - * @param {Number} d1 The distance of the input race in meters - * @param {Number} t1 The finish time of the input race in seconds - * @param {Number} d2 The distance of the output race in meters - * @param {Number} c The value of the exponent in the equation - * @returns {Number} The predicted time for the output race in seconds */ -function RiegelModel(d1, t1, d2, c = 1.06) { - return t1 * ((d2 / d1) ** c); -} +const RiegelModel = { + /** + * Predict a race time using Pete Riegel's Model + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d2 The distance of the output race in meters + * @param {Number} c The value of the exponent in the equation + * @returns {Number} The predicted time for the output race in seconds + */ + predictTime(d1, t1, d2, c = 1.06) { + return t1 * ((d2 / d1) ** c); + }, -/** - * Predict a race time by averaging the results of different models - * @param {Number} d1 The distance of the input race in meters - * @param {Number} t1 The finish time of the input race in seconds - * @param {Number} d2 The distance of the output race in meters - * @param {Number} c The value of the exponent in Pete Riegel's Model - * @returns {Number} The predicted time for the output race in seconds + /** + * Predict a race distance using Pete Riegel's Model + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t2 The finish time of the output race in seconds + * @param {Number} c The value of the exponent in the equation + * @returns {Number} The predicted distance for the output race in meters + */ + predictDistance(t1, d1, t2, c = 1.06) { + return d1 * ((t2 / t1) ** (1 / c)); + }, +}; + +/* + * Methods that average the results of different race prediction models */ -function AverageModel(d1, t1, d2, c = 1.06) { - const purdy = PurdyPointsModel(d1, t1, d2); - const vo2max = VO2MaxModel(d1, t1, d2); - const cameron = CameronModel(d1, t1, d2); - const riegel = RiegelModel(d1, t1, d2, c); - return (purdy + vo2max + cameron + riegel) / 4; -} +const AverageModel = { + /** + * Predict a race time by averaging the results of different models + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d2 The distance of the output race in meters + * @param {Number} c The value of the exponent in Pete Riegel's Model + * @returns {Number} The predicted time for the output race in seconds + */ + predictTime(d1, t1, d2, c = 1.06) { + const purdy = PurdyPointsModel.predictTime(d1, t1, d2); + const vo2max = VO2MaxModel.predictTime(d1, t1, d2); + const cameron = CameronModel.predictTime(d1, t1, d2); + const riegel = RiegelModel.predictTime(d1, t1, d2, c); + return (purdy + vo2max + cameron + riegel) / 4; + }, + + /** + * Predict a race distance by averaging the results of different models + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t2 The finish time of the output race in seconds + * @param {Number} c The value of the exponent in Pete Riegel's Model + * @returns {Number} The predicted distance for the output race in meters + */ + predictDistance(t1, d1, t2, c = 1.06) { + const purdy = PurdyPointsModel.predictDistance(t1, d1, t2); + const vo2max = VO2MaxModel.predictDistance(t1, d1, t2); + const cameron = CameronModel.predictDistance(t1, d1, t2); + const riegel = RiegelModel.predictDistance(t1, d1, t2, c); + return (purdy + vo2max + cameron + riegel) / 4; + }, +}; export default { PurdyPointsModel, diff --git a/src/utils/units.js b/src/utils/units.js @@ -2,152 +2,94 @@ * The time units */ const TIME_UNITS = { - seconds: 'seconds', - minutes: 'minutes', - hours: 'hours', -}; - -/** - * The time unit names - */ -const TIME_UNIT_NAMES = { - seconds: 'Seconds', - minutes: 'Minutes', - hours: 'Hours', -}; - -/** - * The time unit symbols - */ -const TIME_UNIT_SYMBOLS = { - seconds: 's', - minutes: 'min', - hours: 'hr', -}; - -/** - * The value of each time unit in seconds - */ -const TIME_UNIT_VALUES = { - seconds: 1, - minutes: 1 * 60, - hours: 1 * 60 * 60, + seconds: { + name: 'Seconds', + symbol: 's', + value: 1, + }, + minutes: { + name: 'Minutes', + symbol: 'min', + value: 60, + }, + hours: { + name: 'Hours', + symbol: 'hr', + value: 3600, + }, }; /** * The distance units */ const DISTANCE_UNITS = { - meters: 'meters', - kilometers: 'kilometers', - yards: 'yards', - miles: 'miles', - marathons: 'marathons', -}; - -/** - * The distance unit names - */ -const DISTANCE_UNIT_NAMES = { - meters: 'Meters', - kilometers: 'Kilometers', - yards: 'Yards', - miles: 'Miles', - marathons: 'Marathons', -}; - -/** - * The distance unit symbols - */ -const DISTANCE_UNIT_SYMBOLS = { - meters: 'm', - kilometers: 'km', - yards: 'yd', - miles: 'mi', - marathons: 'marathons', -}; - -/** - * The value of each distance unit in meters - */ -const DISTANCE_UNIT_VALUES = { - meters: 1, - kilometers: 1000, - yards: 0.9144, - miles: 1609.3499, - marathons: 42195, + meters: { + name: 'Meters', + symbol: 'm', + value: 1, + }, + yards: { + name: 'Yards', + symbol: 'yd', + value: 0.9144, + }, + kilometers: { + name: 'Kilometers', + symbol: 'km', + value: 1000, + }, + miles: { + name: 'Miles', + symbol: 'mi', + value: 1609.3499, + }, + marathons: { + name: 'Marathons', + symbol: 'marathons', + value: 42195, + }, }; /** * The speed units */ const SPEED_UNITS = { - meters_per_second: 'meters_per_second', - kilometers_per_hour: 'kilometers_per_hour', - miles_per_hour: 'miles_per_hour', -}; - -/** - * The speed unit names - */ -const SPEED_UNIT_NAMES = { - meters_per_second: 'Meters per Second', - kilometers_per_hour: 'Kilometers per Hour', - miles_per_hour: 'Miles per Hour', -}; - -/** - * The speed unit symbols - */ -const SPEED_UNIT_SYMBOLS = { - meters_per_second: 'm/s', - kilometers_per_hour: 'kph', - miles_per_hour: 'mph', -}; - -/** - * The value of each speed unit in meters per second - */ -const SPEED_UNIT_VALUES = { - meters_per_second: 1, - kilometers_per_hour: DISTANCE_UNIT_VALUES.kilometers / TIME_UNIT_VALUES.hours, - miles_per_hour: DISTANCE_UNIT_VALUES.miles / TIME_UNIT_VALUES.hours, -}; - -/** - * The pace units - */ -const PACE_UNITS = { - seconds_per_meter: 'seconds_per_meter', - seconds_per_kilometer: 'seconds_per_kilometer', - seconds_per_mile: 'seconds_per_mile', -}; - -/** - * The pace unit names - */ -const PACE_UNIT_NAMES = { - seconds_per_meter: 'Seconds per Meter', - seconds_per_kilometer: 'Time per Kilometer', - seconds_per_mile: 'Time per Mile', -}; - -/** - * The pace unit symbols - */ -const PACE_UNIT_SYMBOLS = { - seconds_per_meter: 's/m', - seconds_er_kilometer: '/km', - seconds_per_mile: '/mi', + meters_per_second: { + name: 'Meters per Second', + symbol: 'm/s', + value: 1, + }, + kilometers_per_hour: { + name: 'Kilometers per Hour', + symbol: 'kph', + value: DISTANCE_UNITS.kilometers.value / TIME_UNITS.hours.value, + }, + miles_per_hour: { + name: 'Miles per Hour', + symbol: 'mph', + value: DISTANCE_UNITS.miles.value / TIME_UNITS.hours.value, + }, }; /** * The value of each pace unit in seconds per meter */ -const PACE_UNIT_VALUES = { - seconds_per_meter: 1, - seconds_per_kilometer: TIME_UNIT_VALUES.seconds / DISTANCE_UNIT_VALUES.kilometers, - seconds_per_mile: TIME_UNIT_VALUES.seconds / DISTANCE_UNIT_VALUES.miles, +const PACE_UNITS = { + seconds_per_meter: { + name: 'Seconds per Meter', + symbol: 's/m', + value: 1, + }, + seconds_per_kilometer: { + name: 'Time per Kilometer', + symbol: '/ km', + value: TIME_UNITS.seconds.value / DISTANCE_UNITS.kilometers.value, + }, + seconds_per_mile: { + name: 'Time per Mile', + symbol: '/ mi', + value: TIME_UNITS.seconds.value / DISTANCE_UNITS.miles.value, + }, }; /** @@ -157,8 +99,8 @@ const PACE_UNIT_VALUES = { * @param {String} outputUnit The unit of the output * @returns {Number} The output */ -function convertTime(inputValue, inputUnits, outputUnits) { - return (inputValue * TIME_UNIT_VALUES[inputUnits]) / TIME_UNIT_VALUES[outputUnits]; +function convertTime(inputValue, inputUnit, outputUnit) { + return (inputValue * TIME_UNITS[inputUnit].value) / TIME_UNITS[outputUnit].value; } /** @@ -168,8 +110,8 @@ function convertTime(inputValue, inputUnits, outputUnits) { * @param {String} outputUnit The unit of the output * @returns {Number} The output */ -function convertDistance(inputValue, inputUnits, outputUnits) { - return (inputValue * DISTANCE_UNIT_VALUES[inputUnits]) / DISTANCE_UNIT_VALUES[outputUnits]; +function convertDistance(inputValue, inputUnit, outputUnit) { + return (inputValue * DISTANCE_UNITS[inputUnit].value) / DISTANCE_UNITS[outputUnit].value; } /** @@ -179,8 +121,8 @@ function convertDistance(inputValue, inputUnits, outputUnits) { * @param {String} outputUnit The unit of the output * @returns {Number} The output */ -function convertSpeed(inputValue, inputUnits, outputUnits) { - return (inputValue * SPEED_UNIT_VALUES[inputUnits]) / SPEED_UNIT_VALUES[outputUnits]; +function convertSpeed(inputValue, inputUnit, outputUnit) { + return (inputValue * SPEED_UNITS[inputUnit].value) / SPEED_UNITS[outputUnit].value; } /** @@ -190,8 +132,8 @@ function convertSpeed(inputValue, inputUnits, outputUnits) { * @param {String} outputUnit The unit of the output * @returns {Number} The output */ -function convertPace(inputValue, inputUnits, outputUnits) { - return (inputValue * PACE_UNIT_VALUES[inputUnits]) / PACE_UNIT_VALUES[outputUnits]; +function convertPace(inputValue, inputUnit, outputUnit) { + return (inputValue * PACE_UNITS[inputUnit].value) / PACE_UNITS[outputUnit].value; } /** @@ -201,20 +143,20 @@ function convertPace(inputValue, inputUnits, outputUnits) { * @param {String} outputUnit The unit of the output * @returns {Number} The output */ -function convertSpeedPace(inputValue, inputUnits, outputUnits) { +function convertSpeedPace(inputValue, inputUnit, outputUnit) { // Calculate input speed let speed; - if (inputUnits in PACE_UNIT_VALUES) { - speed = 1 / (inputValue * PACE_UNIT_VALUES[inputUnits]); + if (inputUnit in PACE_UNITS) { + speed = 1 / (inputValue * PACE_UNITS[inputUnit].value); } else { - speed = inputValue * SPEED_UNIT_VALUES[inputUnits]; + speed = inputValue * SPEED_UNITS[inputUnit].value; } // Calculate output - if (outputUnits in PACE_UNIT_VALUES) { - return (1 / speed) / PACE_UNIT_VALUES[outputUnits]; + if (outputUnit in PACE_UNITS) { + return (1 / speed) / PACE_UNITS[outputUnit].value; } - return speed / SPEED_UNIT_VALUES[outputUnits]; + return speed / SPEED_UNITS[outputUnit].value; } /** @@ -273,22 +215,48 @@ function formatDuration(value, padding = 6, digits = 2) { return result; } +/** + * Get the default unit system + * @returns {String} The default unit system + */ +function getDefaultUnitSystem() { + const language = navigator.language || navigator.userLanguage; + if (language.endsWith('-US') || language.endsWith('-MM')) { + return 'imperial'; + } + return 'metric'; +} + +/** + * Get the default distance unit + * @returns {String} The default distance unit + */ +function getDefaultDistanceUnit() { + return getDefaultUnitSystem() === 'metric' ? 'kilometers' : 'miles'; +} + +/** + * Get the default speed unit + * @returns {String} The default speed unit + */ +function getDefaultSpeedUnit() { + return getDefaultUnitSystem() === 'metric' ? 'kilometers_per_hour' : 'miles_per_hour'; +} + +/** + * Get the default pace unit + * @returns {String} The default pace unit + */ +function getDefaultPaceUnit() { + return getDefaultUnitSystem() === 'metric' ? 'seconds_per_kilometer' : 'seconds_per_mile'; +} + export default { TIME_UNITS, DISTANCE_UNITS, SPEED_UNITS, PACE_UNITS, - TIME_UNIT_NAMES, - DISTANCE_UNIT_NAMES, - SPEED_UNIT_NAMES, - PACE_UNIT_NAMES, - - TIME_UNIT_SYMBOLS, - DISTANCE_UNIT_SYMBOLS, - SPEED_UNIT_SYMBOLS, - PACE_UNIT_SYMBOLS, - convertTime, convertDistance, convertSpeed, @@ -296,4 +264,9 @@ export default { convertSpeedPace, formatDuration, + + getDefaultUnitSystem, + getDefaultDistanceUnit, + getDefaultSpeedUnit, + getDefaultPaceUnit, }; diff --git a/src/views/PaceCalculator.vue b/src/views/PaceCalculator.vue @@ -1,21 +1,26 @@ <template> <div class="pace-calculator"> + <h2>Input Pace</h2> <div class="input"> - Running - <decimal-input v-model="inputDistance" aria-label="distance value" - :min="0" :digits="2"/> - <select v-model="inputUnit" aria-label="distance unit"> - <option v-for="(value, key) in distanceUnits" :key="key" :value="key"> - {{ value }} - </option> - </select> - in - <time-input v-model="inputTime"/> + <div> + Distance: + <decimal-input v-model="inputDistance" aria-label="distance value" + :min="0" :digits="2"/> + <select v-model="inputUnit" aria-label="distance unit"> + <option v-for="(value, key) in distanceUnits" :key="key" :value="key"> + {{ value.name }} + </option> + </select> + </div> + <div> + Time: + <time-input v-model="inputTime"/> + </div> </div> - <p>is the same pace as running</p> + <h2>Equivalent Paces</h2> - <time-table class="output" :calculate-result="calculatePace" :default-targets="defaultTargets" + <target-table class="output" :calculate-result="calculatePace" :default-targets="defaultTargets" storage-key="pace-calculator-targets"/> </div> </template> @@ -26,7 +31,7 @@ import unitUtils from '@/utils/units'; import DecimalInput from '@/components/DecimalInput.vue'; import TimeInput from '@/components/TimeInput.vue'; -import TimeTable from '@/components/TimeTable.vue'; +import TargetTable from '@/components/TargetTable.vue'; export default { name: 'PaceCalculator', @@ -34,7 +39,7 @@ export default { components: { DecimalInput, TimeInput, - TimeTable, + TargetTable, }, data() { @@ -42,58 +47,61 @@ export default { /** * The input distance value */ - inputDistance: 1, + inputDistance: 5, /** * The input distance unit */ - inputUnit: 'miles', + inputUnit: 'kilometers', /** * The input time value */ - inputTime: 10 * 60, + inputTime: 20 * 60, /** * The names of the distance units */ - distanceUnits: unitUtils.DISTANCE_UNIT_NAMES, + distanceUnits: unitUtils.DISTANCE_UNITS, /** * The default output targets */ defaultTargets: [ - { distanceValue: 100, distanceUnit: 'meters' }, - { distanceValue: 200, distanceUnit: 'meters' }, - { distanceValue: 300, distanceUnit: 'meters' }, - { distanceValue: 400, distanceUnit: 'meters' }, - { distanceValue: 600, distanceUnit: 'meters' }, - { distanceValue: 800, distanceUnit: 'meters' }, - { distanceValue: 1000, distanceUnit: 'meters' }, - { distanceValue: 1200, distanceUnit: 'meters' }, - { distanceValue: 1500, distanceUnit: 'meters' }, - { distanceValue: 1600, distanceUnit: 'meters' }, - { distanceValue: 3200, distanceUnit: 'meters' }, - - { distanceValue: 2, distanceUnit: 'kilometers' }, - { distanceValue: 3, distanceUnit: 'kilometers' }, - { distanceValue: 4, distanceUnit: 'kilometers' }, - { distanceValue: 5, distanceUnit: 'kilometers' }, - { distanceValue: 6, distanceUnit: 'kilometers' }, - { distanceValue: 8, distanceUnit: 'kilometers' }, - { distanceValue: 10, distanceUnit: 'kilometers' }, - { distanceValue: 15, distanceUnit: 'kilometers' }, - - { distanceValue: 1, distanceUnit: 'miles' }, - { distanceValue: 2, distanceUnit: 'miles' }, - { distanceValue: 3, distanceUnit: 'miles' }, - { distanceValue: 5, distanceUnit: 'miles' }, - { distanceValue: 6, distanceUnit: 'miles' }, - { distanceValue: 8, distanceUnit: 'miles' }, - { distanceValue: 10, distanceUnit: 'miles' }, - - { distanceValue: 0.5, distanceUnit: 'marathons' }, - { distanceValue: 1, distanceUnit: 'marathons' }, + { result: 'time', distanceValue: 100, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 200, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 300, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 400, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 600, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 800, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1000, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1200, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1500, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1600, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 3200, distanceUnit: 'meters' }, + + { result: 'time', distanceValue: 2, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 3, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 4, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 6, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 8, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 15, distanceUnit: 'kilometers' }, + + { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 5, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 6, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 8, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 10, distanceUnit: 'miles' }, + + { result: 'time', distanceValue: 0.5, distanceUnit: 'marathons' }, + { result: 'time', distanceValue: 1, distanceUnit: 'marathons' }, + + { result: 'distance', time: 600 }, + { result: 'distance', time: 1800 }, ], }; }, @@ -103,8 +111,7 @@ export default { * The input pace (in seconds per meter) */ pace() { - const distance = unitUtils.convertDistance(this.inputDistance, - this.inputUnit, unitUtils.DISTANCE_UNITS.meters); + const distance = unitUtils.convertDistance(this.inputDistance, this.inputUnit, 'meters'); return paceUtils.getPace(distance, this.inputTime); }, }, @@ -116,19 +123,38 @@ export default { * @returns {Object} The result */ calculatePace(target) { - // Convert distance into meters - const distance = unitUtils.convertDistance(target.distanceValue, target.distanceUnit, - unitUtils.DISTANCE_UNITS.meters); - - // Calculate time to travel distance at input pace - const time = paceUtils.getTime(this.pace, distance); - - // Return result - return { + // Initialize result + const result = { distanceValue: target.distanceValue, distanceUnit: target.distanceUnit, - time, + time: target.time, + result: target.result, }; + + // Add missing value to result + if (target.result === 'time') { + // Convert target distance into meters + const d2 = unitUtils.convertDistance(target.distanceValue, target.distanceUnit, 'meters'); + + // Calculate time to travel distance at input pace + const time = paceUtils.getTime(this.pace, d2); + + // Update result + result.time = time; + } else { + // Calculate distance traveled in time at input pace + let distance = paceUtils.getDistance(this.pace, target.time); + + // Convert output distance into default distance unit + distance = unitUtils.convertDistance(distance, 'meters', unitUtils.getDefaultDistanceUnit()); + + // Update result + result.distanceValue = distance; + result.distanceUnit = unitUtils.getDefaultDistanceUnit(); + } + + // Return result + return result; }, }, }; @@ -142,11 +168,16 @@ export default { align-items: center; } -/* calculator input */ -.input { - text-align: center; - margin-bottom: 5px; +/* headings */ +h2 { + font-size: 1.3em; + margin-bottom: 0.2em; } +* + h2 { + margin-top: 0.5em; +} + +/* calculator input */ .input>* { margin-bottom: 5px; /* adds space between wrapped lines */ } @@ -156,7 +187,6 @@ export default { /* calculator output */ .output { - margin-top: 10px; min-width: 300px; } @media only screen and (max-width: 500px) { diff --git a/src/views/RaceCalculator.vue b/src/views/RaceCalculator.vue @@ -1,31 +1,72 @@ <template> <div class="race-calculator"> + <h2>Input Race Result</h2> <div class="input"> - Running - <decimal-input v-model="inputDistance" aria-label="Distance value" :min="0" :digits="2"/> - <select v-model="inputUnit" aria-label="Distance unit"> - <option v-for="(value, key) in distanceUnits" :key="key" :value="key"> - {{ value }} - </option> - </select> - in - <time-input v-model="inputTime"/> + <div> + Distance: + <decimal-input v-model="inputDistance" aria-label="Distance value" :min="0" :digits="2"/> + <select v-model="inputUnit" aria-label="Distance unit"> + <option v-for="(value, key) in distanceUnits" :key="key" :value="key"> + {{ value.name }} + </option> + </select> + </div> + <div> + Time: + <time-input v-model="inputTime"/> + </div> </div> - <p>is approximately equivalent to running</p> + <h2> + Advanced + <button class="link" @click="showAdvancedOptions=!showAdvancedOptions"> + {{ showAdvancedOptions ? '[hide]' : '[show]' }} + </button> + </h2> + <div class="advanced-options" v-show="showAdvancedOptions"> + <div> + Prediction Model: + <select v-model="model" aria-label="Prediction Model"> + <option value="AverageModel">Average</option> + <option value="PurdyPointsModel">Purdy Points Model</option> + <option value="VO2MaxModel">V&#775;O&#8322; Max Model</option> + <option value="CameronModel">Cameron's Model</option> + <option value="RiegelModel">Riegel's Model</option> + </select> + </div> + <div> + Riegel Exponent: + <decimal-input v-model="riegelExponent" aria-label="Riegel Exponent" :min="1" :max="1.3" + :digits="2" :step="0.01"/> + (default: 1.06) + </div> + <div> + Purdy Points: <b>{{ purdyPoints.toFixed(1) }}</b> + </div> + <div> + V&#775;O&#8322;: <b>{{ vo2.toFixed(1) }}</b> ml/kg/min + (<b>{{ vo2Percentage.toFixed(1) }}%</b> of max) + </div> + <div> + V&#775;O&#8322; Max: <b>{{ vo2Max.toFixed(1) }}</b> ml/kg/min + </div> + </div> + + <h2>Equivalent Race Results</h2> - <time-table class="output" :calculate-result="predictTime" :default-targets="defaultTargets" - storage-key="race-calculator-targets"/> + <target-table class="output" :calculate-result="predictResult" :default-targets="defaultTargets" + storage-key="race-calculator-targets" show-pace/> </div> </template> <script> 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 TimeTable from '@/components/TimeTable.vue'; +import TargetTable from '@/components/TargetTable.vue'; export default { name: 'RaceCalculator', @@ -33,7 +74,7 @@ export default { components: { DecimalInput, TimeInput, - TimeTable, + TargetTable, }, data() { @@ -54,62 +95,202 @@ export default { inputTime: 20 * 60, /** + * The race prediction model + */ + model: storage.get('race-calculator-model', 'AverageModel'), + + /** + * The value of the exponent in Riegel's Model + */ + riegelExponent: storage.get('race-calculator-riegel-exponent', 1.06), + + /** + * Whether to show the advanced options + */ + showAdvancedOptions: storage.get('race-calculator-show-advanced-options', false), + + /** * The names of the distance units */ - distanceUnits: unitUtils.DISTANCE_UNIT_NAMES, + distanceUnits: unitUtils.DISTANCE_UNITS, /** * The default output targets */ defaultTargets: [ - { distanceValue: 400, distanceUnit: 'meters' }, - { distanceValue: 800, distanceUnit: 'meters' }, - { distanceValue: 1000, distanceUnit: 'meters' }, - { distanceValue: 1200, distanceUnit: 'meters' }, - { distanceValue: 1500, distanceUnit: 'meters' }, - { distanceValue: 1600, distanceUnit: 'meters' }, - { distanceValue: 3200, distanceUnit: 'meters' }, - - { distanceValue: 3, distanceUnit: 'kilometers' }, - { distanceValue: 5, distanceUnit: 'kilometers' }, - { distanceValue: 8, distanceUnit: 'kilometers' }, - { distanceValue: 10, distanceUnit: 'kilometers' }, - { distanceValue: 15, distanceUnit: 'kilometers' }, - - { distanceValue: 1, distanceUnit: 'miles' }, - { distanceValue: 2, distanceUnit: 'miles' }, - { distanceValue: 3, distanceUnit: 'miles' }, - { distanceValue: 5, distanceUnit: 'miles' }, - { distanceValue: 10, distanceUnit: 'miles' }, - - { distanceValue: 0.5, distanceUnit: 'marathons' }, - { distanceValue: 1, distanceUnit: 'marathons' }, + { result: 'time', distanceValue: 400, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 800, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1000, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1200, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1500, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1600, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 3200, distanceUnit: 'meters' }, + + { result: 'time', distanceValue: 3, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 8, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 15, distanceUnit: 'kilometers' }, + + { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 5, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 10, distanceUnit: 'miles' }, + + { result: 'time', distanceValue: 0.5, distanceUnit: 'marathons' }, + { result: 'time', distanceValue: 1, distanceUnit: 'marathons' }, + + { result: 'distance', time: 600 }, + { result: 'distance', time: 3600 }, ], }; }, methods: { /** - * Predict race times from a target + * Predict race results from a target * @param {Object} target The target * @returns {Object} The result */ - predictTime(target) { - // Convert distances into meters - const d1 = unitUtils.convertDistance(this.inputDistance, this.inputUnit, - unitUtils.DISTANCE_UNITS.meters); - const d2 = unitUtils.convertDistance(target.distanceValue, target.distanceUnit, - unitUtils.DISTANCE_UNITS.meters); - - // Get prediction - const time = raceUtils.AverageModel(d1, this.inputTime, d2); - - // Return result - return { + predictResult(target) { + // Initialize result + const result = { distanceValue: target.distanceValue, distanceUnit: target.distanceUnit, - time, + time: target.time, + result: target.result, }; + + // Add missing value to result + if (target.result === 'time') { + // Convert target distance into meters + const d2 = unitUtils.convertDistance(target.distanceValue, target.distanceUnit, 'meters'); + + // Get prediction + let time; + switch (this.model) { + default: + case 'AverageModel': + time = raceUtils.AverageModel.predictTime(this.d1, this.inputTime, d2, + this.riegelExponent); + break; + case 'PurdyPointsModel': + time = raceUtils.PurdyPointsModel.predictTime(this.d1, this.inputTime, d2); + break; + case 'VO2MaxModel': + time = raceUtils.VO2MaxModel.predictTime(this.d1, this.inputTime, d2); + break; + case 'RiegelModel': + time = raceUtils.RiegelModel.predictTime(this.d1, this.inputTime, d2, + this.riegelExponent); + break; + case 'CameronModel': + time = raceUtils.CameronModel.predictTime(this.d1, this.inputTime, d2); + break; + } + + // Update result + result.time = time; + } else { + // Get prediction + let distance; + switch (this.model) { + default: + case 'AverageModel': + distance = raceUtils.AverageModel.predictDistance(this.inputTime, this.d1, target.time, + this.riegelExponent); + break; + case 'PurdyPointsModel': + distance = raceUtils.PurdyPointsModel.predictDistance(this.inputTime, this.d1, + target.time); + break; + case 'VO2MaxModel': + distance = raceUtils.VO2MaxModel.predictDistance(this.inputTime, this.d1, target.time); + break; + case 'RiegelModel': + distance = raceUtils.RiegelModel.predictDistance(this.inputTime, this.d1, target.time, + this.riegelExponent); + break; + case 'CameronModel': + distance = raceUtils.CameronModel.predictDistance(this.inputTime, this.d1, target.time); + break; + } + + // Convert output distance into default distance unit + distance = unitUtils.convertDistance(distance, 'meters', unitUtils.getDefaultDistanceUnit()); + + // Update result + result.distanceValue = distance; + result.distanceUnit = unitUtils.getDefaultDistanceUnit(); + } + + // Return result + return result; + }, + }, + + computed: { + /** + * The input distance in meters + */ + d1() { + return unitUtils.convertDistance(this.inputDistance, this.inputUnit, 'meters'); + }, + + /** + * The Purdy Points for the input race + */ + purdyPoints() { + const result = raceUtils.PurdyPointsModel.getPurdyPoints(this.d1, this.inputTime); + return result; + }, + + /** + * The VO2 Max calculated from the input race + */ + vo2Max() { + const result = raceUtils.VO2MaxModel.getVO2Max(this.d1, this.inputTime); + return result; + }, + + /** + * The VO2 calculated from the input race + */ + vo2() { + const result = raceUtils.VO2MaxModel.getVO2(this.d1, this.inputTime); + return result; + }, + + /** + * The percentage of VO2 Max calculated from the input race + */ + vo2Percentage() { + const result = raceUtils.VO2MaxModel.getVO2Percentage(this.inputTime) * 100; + return result; + }, + }, + + watch: { + /** + * Save prediction model + */ + model(newValue) { + storage.set('race-calculator-model', newValue); + }, + + /** + * Save Riegel Model exponent + */ + riegelExponent(newValue) { + storage.set('race-calculator-riegel-exponent', newValue); + }, + + /** + * Save advanced options state + */ + showAdvancedOptions(newValue) { + storage.set('race-calculator-show-advanced-options', newValue); }, }, }; @@ -123,11 +304,16 @@ export default { align-items: center; } -/* calculator input */ -.input { - text-align: center; - margin-bottom: 5px; +/* headings */ +h2 { + font-size: 1.3em; + margin-bottom: 0.2em; +} +* + h2 { + margin-top: 0.5em; } + +/* calculator input */ .input>* { margin-bottom: 5px; /* adds space between wrapped lines */ } @@ -135,9 +321,13 @@ export default { margin-left: 5px; } +/* advanced options */ +.advanced-options>* { + margin-bottom: 5px; +} + /* calculator output */ .output { - margin-top: 10px; min-width: 300px; } @media only screen and (max-width: 500px) { diff --git a/src/views/UnitCalculator.vue b/src/views/UnitCalculator.vue @@ -12,23 +12,23 @@ v-model="inputValue" :min="0" :digits="2"/> <select v-model="inputUnit" class="input-units" aria-label="input units"> - <option v-for="(value, key) in unitNames" :key="key" :value="key"> - {{ value }} + <option v-for="(value, key) in units" :key="key" :value="key"> + {{ value.name }} </option> </select> <span class="equals"> = </span> <span v-if="getUnitType(outputUnit) === 'time'" class="output-value"> - {{ formatDuration(outputValue) }} + {{ formatDuration(outputValue, 6, 3) }} </span> <span v-else class="output-value"> - {{ outputValue.toFixed(2) }} + {{ outputValue.toFixed(3) }} </span> <select v-model="outputUnit" class="output-units" aria-label="output units"> - <option v-for="(value, key) in unitNames" :key="key" :value="key"> - {{ value }} + <option v-for="(value, key) in units" :key="key" :value="key"> + {{ value.name }} </option> </select> </div> @@ -63,7 +63,7 @@ export default { /** * The unit of the output */ - outputUnit: 'meters', + outputUnit: 'kilometers', /** * The unit category @@ -81,16 +81,23 @@ export default { /** * The names of the units in the current category */ - unitNames() { + units() { switch (this.category) { case 'distance': { - return unitUtils.DISTANCE_UNIT_NAMES; + return unitUtils.DISTANCE_UNITS; } case 'time': { - return { ...unitUtils.TIME_UNIT_NAMES, 'hh:mm:ss': 'hh:mm:ss' }; + return { + ...unitUtils.TIME_UNITS, + 'hh:mm:ss': { + name: 'hh:mm:ss', + symbol: '', + value: null, + }, + }; } case 'speed_and_pace': { - return { ...unitUtils.PACE_UNIT_NAMES, ...unitUtils.SPEED_UNIT_NAMES }; + return { ...unitUtils.PACE_UNITS, ...unitUtils.SPEED_UNITS }; } default: { return {}; @@ -108,18 +115,8 @@ export default { } case 'time': { // Correct input and output units for 'hh:mm:ss' unit - let realInput; - if (this.inputUnit === 'hh:mm:ss') { - realInput = unitUtils.TIME_UNITS.seconds; - } else { - realInput = this.inputUnit; - } - let realOutput; - if (this.outputUnit === 'hh:mm:ss') { - realOutput = unitUtils.TIME_UNITS.seconds; - } else { - realOutput = this.outputUnit; - } + const realInput = this.inputUnit === 'hh:mm:ss' ? 'seconds' : this.inputUnit; + const realOutput = this.outputUnit === 'hh:mm:ss' ? 'seconds' : this.outputUnit; // Calculate conversion return unitUtils.convertTime(this.inputValue, realInput, realOutput); @@ -143,7 +140,7 @@ export default { case 'distance': { this.inputValue = 1; this.inputUnit = 'miles'; - this.outputUnit = 'meters'; + this.outputUnit = 'kilometers'; break; } case 'time': { @@ -153,9 +150,9 @@ export default { break; } case 'speed_and_pace': { - this.inputValue = 1; - this.inputUnit = 'miles_per_hour'; - this.outputUnit = 'seconds_per_mile'; + this.inputValue = unitUtils.getDefaultPaceUnit() === 'seconds_per_mile' ? 600 : 300; + this.inputUnit = unitUtils.getDefaultPaceUnit(); + this.outputUnit = unitUtils.getDefaultSpeedUnit(); break; } default: { diff --git a/tests/unit/components/TargetTable.spec.js b/tests/unit/components/TargetTable.spec.js @@ -0,0 +1,34 @@ +/* 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/TimeTable.spec.js b/tests/unit/components/TimeTable.spec.js @@ -1,34 +0,0 @@ -/* eslint-disable no-underscore-dangle */ - -import { expect } from 'chai'; -import { shallowMount } from '@vue/test-utils'; -import TimeTable from '@/components/TimeTable.vue'; - -describe('components/TimeTable.vue', () => { - it('results should be correct and sorted by time', () => { - // Initialize component - const wrapper = shallowMount(TimeTable, { - 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/utils/races.spec.js b/tests/unit/utils/races.spec.js @@ -2,67 +2,173 @@ import { expect } from 'chai'; import raceUtils from '@/utils/races'; describe('utils/races.js', () => { - describe('PurdyPointsModel method', () => { - it('Predictions should be approximately correct', () => { - const result = raceUtils.PurdyPointsModel(5000, 1200, 10000); - expect(result).to.be.closeTo(2490, 1); + describe('PurdyPointsModel', () => { + describe('getPurdyPoints method', () => { + it('Result should be approximately correct', () => { + const result = raceUtils.PurdyPointsModel.getPurdyPoints(5000, 1200); + expect(result).to.be.closeTo(454, 1); + }); }); - it('Should predict identical times for itentical distances', () => { - const result = raceUtils.PurdyPointsModel(5000, 1200, 5000); - expect(result).to.be.closeTo(1200, 0.001); + describe('PredictTime method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.PurdyPointsModel.predictTime(5000, 1200, 10000); + expect(result).to.be.closeTo(2490, 1); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.PurdyPointsModel.predictTime(5000, 1200, 5000); + expect(result).to.be.closeTo(1200, 0.001); + }); + }); + + describe('PredictDistance method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.PurdyPointsModel.predictDistance(1200, 5000, 2490); + expect(result).to.be.closeTo(10000, 10); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.PurdyPointsModel.predictDistance(1200, 5000, 1200); + expect(result).to.be.closeTo(5000, 0.001); + }); }); }); - describe('VO2MaxModel method', () => { - it('Predictions should be approximately correct', () => { - const result = raceUtils.VO2MaxModel(5000, 1200, 10000); - expect(result).to.be.closeTo(2488, 1); + describe('VO2MaxModel', () => { + describe('getVO2 method', () => { + it('Result should be approximately correct', () => { + const result = raceUtils.VO2MaxModel.getVO2(5000, 1200); + expect(result).to.be.closeTo(47.4, 0.1); + }); + }); + + describe('getVO2Percentage method', () => { + it('Result should be approximately correct', () => { + const result = raceUtils.VO2MaxModel.getVO2Percentage(660); + expect(result).to.be.closeTo(1, 0.001); + }); + }); + + describe('getVO2Max method', () => { + it('Result should be approximately correct', () => { + const result = raceUtils.VO2MaxModel.getVO2Max(5000, 1200); + expect(result).to.be.closeTo(49.8, 0.1); + }); + }); + + describe('PredictTime method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.VO2MaxModel.predictTime(5000, 1200, 10000); + expect(result).to.be.closeTo(2488, 1); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.VO2MaxModel.predictTime(5000, 1200, 5000); + expect(result).to.be.closeTo(1200, 0.001); + }); }); - it('Should predict identical times for itentical distances', () => { - const result = raceUtils.VO2MaxModel(5000, 1200, 5000); - expect(result).to.be.closeTo(1200, 0.001); + describe('PredictDistance method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.VO2MaxModel.predictDistance(1200, 5000, 2488); + expect(result).to.be.closeTo(10000, 10); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.VO2MaxModel.predictDistance(1200, 5000, 1200); + expect(result).to.be.closeTo(5000, 0.001); + }); }); }); - describe('CameronModel method', () => { - it('Predictions should be approximately correct', () => { - const result = raceUtils.CameronModel(5000, 1200, 10000); - expect(result).to.be.closeTo(2500, 1); + describe('CameronModel', () => { + describe('PredictTime method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.CameronModel.predictTime(5000, 1200, 10000); + expect(result).to.be.closeTo(2500, 1); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.CameronModel.predictTime(5000, 1200, 5000); + expect(result).to.be.closeTo(1200, 0.001); + }); }); - it('Should predict identical times for itentical distances', () => { - const result = raceUtils.CameronModel(5000, 1200, 5000); - expect(result).to.be.closeTo(1200, 0.001); + describe('PredictDistance method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.CameronModel.predictDistance(1200, 5000, 2500); + expect(result).to.be.closeTo(10000, 10); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.CameronModel.predictDistance(1200, 5000, 1200); + expect(result).to.be.closeTo(5000, 0.001); + }); }); }); - describe('RiegelModel method', () => { - it('Predictions should be approximately correct', () => { - const result = raceUtils.RiegelModel(5000, 1200, 10000); - expect(result).to.be.closeTo(2502, 1); + describe('RiegelModel', () => { + describe('PredictTime method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.RiegelModel.predictTime(5000, 1200, 10000); + expect(result).to.be.closeTo(2502, 1); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.RiegelModel.predictTime(5000, 1200, 5000); + expect(result).to.be.closeTo(1200, 0.001); + }); }); - it('Should predict identical times for itentical distances', () => { - const result = raceUtils.RiegelModel(5000, 1200, 5000); - expect(result).to.be.closeTo(1200, 0.001); + describe('PredictDistance method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.RiegelModel.predictDistance(1200, 5000, 2502); + expect(result).to.be.closeTo(10000, 10); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.RiegelModel.predictDistance(1200, 5000, 1200); + expect(result).to.be.closeTo(5000, 0.001); + }); }); }); - describe('AverageModel method', () => { - it('Predictions should be correct', () => { - const result = raceUtils.AverageModel(5000, 1200, 10000); - const riegel = raceUtils.RiegelModel(5000, 1200, 10000); - const cameron = raceUtils.CameronModel(5000, 1200, 10000); - const purdyPoints = raceUtils.PurdyPointsModel(5000, 1200, 10000); - const vo2Max = raceUtils.VO2MaxModel(5000, 1200, 10000); - expect(result).to.equal((riegel + cameron + purdyPoints + vo2Max) / 4); + describe('AverageModel', () => { + describe('PredictTime method', () => { + it('Predictions should be correct', () => { + const riegel = raceUtils.RiegelModel.predictTime(5000, 1200, 10000); + const cameron = raceUtils.CameronModel.predictTime(5000, 1200, 10000); + const purdyPoints = raceUtils.PurdyPointsModel.predictTime(5000, 1200, 10000); + const vo2Max = raceUtils.VO2MaxModel.predictTime(5000, 1200, 10000); + const expected = (riegel + cameron + purdyPoints + vo2Max) / 4; + + const result = raceUtils.AverageModel.predictTime(5000, 1200, 10000); + expect(result).to.equal(expected); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.AverageModel.predictTime(5000, 1200, 5000); + expect(result).to.be.closeTo(1200, 0.001); + }); }); - it('Should predict identical times for itentical distances', () => { - const result = raceUtils.AverageModel(5000, 1200, 5000); - expect(result).to.be.closeTo(1200, 0.001); + describe('PredictDistance method', () => { + it('Predictions should be correct', () => { + const riegel = raceUtils.RiegelModel.predictTime(5000, 1200, 10000); + const cameron = raceUtils.CameronModel.predictTime(5000, 1200, 10000); + const purdyPoints = raceUtils.PurdyPointsModel.predictTime(5000, 1200, 10000); + const vo2Max = raceUtils.VO2MaxModel.predictTime(5000, 1200, 10000); + const expected = (riegel + cameron + purdyPoints + vo2Max) / 4; + + const result = raceUtils.AverageModel.predictDistance(1200, 5000, expected); + expect(result).to.be.closeTo(10000, 10); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.AverageModel.predictDistance(1200, 5000, 1200); + expect(result).to.be.closeTo(5000, 0.001); + }); }); }); }); diff --git a/tests/unit/utils/units.spec.js b/tests/unit/utils/units.spec.js @@ -4,100 +4,60 @@ import units from '@/utils/units'; describe('utils/units.js', () => { describe('convertTime method', () => { it('90 seconds should equal 1.5 minutes', () => { - const result = units.convertTime( - 90, - units.TIME_UNITS.seconds, - units.TIME_UNITS.minutes, - ); + const result = units.convertTime(90, 'seconds', 'minutes'); expect(result).to.equal(1.5); }); it('1.5 minutes should equal 95 seconds', () => { - const result = units.convertTime( - 1.5, - units.TIME_UNITS.minutes, - units.TIME_UNITS.seconds, - ); + const result = units.convertTime(1.5, 'minutes', 'seconds'); expect(result).to.equal(90); }); }); describe('convertDistance method', () => { it('100 meters should equal 0.1 kilometers', () => { - const result = units.convertDistance( - 100, - units.DISTANCE_UNITS.meters, - units.DISTANCE_UNITS.kilometers, - ); + const result = units.convertDistance(100, 'meters', 'kilometers'); expect(result).to.equal(0.1); }); it('0.1 kilometers should equal 100 meters', () => { - const result = units.convertDistance( - 0.1, - units.DISTANCE_UNITS.kilometers, - units.DISTANCE_UNITS.meters, - ); + const result = units.convertDistance(0.1, 'kilometers', 'meters'); expect(result).to.equal(100); }); }); describe('convertSpeed method', () => { it('1000 meters per seconds should equal 3600 kilometers per hour', () => { - const result = units.convertSpeed( - 1000, - units.SPEED_UNITS.meters_per_second, - units.SPEED_UNITS.kilometers_per_hour, - ); + const result = units.convertSpeed(1000, 'meters_per_second', 'kilometers_per_hour'); expect(result).to.equal(3600); }); it('3600 kilometers per hour should equal 1000 meters per second', () => { - const result = units.convertSpeed( - 3600, - units.SPEED_UNITS.kilometers_per_hour, - units.SPEED_UNITS.meters_per_second, - ); + const result = units.convertSpeed(3600, 'kilometers_per_hour', 'meters_per_second'); expect(result).to.equal(1000); }); }); describe('convertPace method', () => { it('1 second per meter should equal 1000 seconds per kilometer', () => { - const result = units.convertPace( - 1, - units.PACE_UNITS.seconds_per_meter, - units.PACE_UNITS.seconds_per_kilometer, - ); + const result = units.convertPace(1, 'seconds_per_meter', 'seconds_per_kilometer'); expect(result).to.equal(1000); }); it('1000 seconds per kilometer should equal 1 second per meter', () => { - const result = units.convertPace( - 1000, - units.PACE_UNITS.seconds_per_kilometer, - units.PACE_UNITS.seconds_per_meter, - ); + const result = units.convertPace(1000, 'seconds_per_kilometer', 'seconds_per_meter'); expect(result).to.equal(1); }); }); describe('convertSpeedPace method', () => { it('3600 kilometers per hour should equal 1 second per kilometer', () => { - const result = units.convertSpeedPace( - 3600, - units.SPEED_UNITS.kilometers_per_hour, - units.PACE_UNITS.seconds_per_kilometer, - ); + const result = units.convertSpeedPace(3600, 'kilometers_per_hour', 'seconds_per_kilometer'); expect(result).to.equal(1); }); it('1 second per kilometer should equal 3600 kilometers per hour', () => { - const result = units.convertSpeedPace( - 3600, - units.PACE_UNITS.seconds_per_kilometer, - units.SPEED_UNITS.kilometers_per_hour, - ); + const result = units.convertSpeedPace(3600, 'seconds_per_kilometer', 'kilometers_per_hour'); expect(result).to.equal(1); }); }); diff --git a/tests/unit/views/PaceCalculator.spec.js b/tests/unit/views/PaceCalculator.spec.js @@ -5,7 +5,7 @@ import { shallowMount } from '@vue/test-utils'; import PaceCalculator from '@/views/PaceCalculator.vue'; describe('views/PaceCalculator.vue', () => { - it('should correctly calculate paces', async () => { + it('should correctly calculate times', async () => { // Initialize component const wrapper = shallowMount(PaceCalculator); @@ -20,6 +20,7 @@ describe('views/PaceCalculator.vue', () => { const result = wrapper.vm.calculatePace({ distanceValue: 20, distanceUnit: 'meters', + result: 'time', }); // Assert result is correct @@ -27,6 +28,33 @@ describe('views/PaceCalculator.vue', () => { distanceValue: 20, distanceUnit: 'meters', time: 2, + result: 'time', + }); + }); + + it('should correctly calculate distances', async () => { + // Initialize component + const wrapper = shallowMount(PaceCalculator); + + // Override input values + await wrapper.setData({ + inputDistance: 1, + inputUnit: 'miles', + inputTime: 100, + }); + + // Calculate paces + const result = wrapper.vm.calculatePace({ + time: 200, + result: 'distance', + }); + + // Assert result is correct + expect(result).to.deep.equal({ + distanceValue: 2, + distanceUnit: 'miles', + time: 200, + result: 'distance', }); }); }); diff --git a/tests/unit/views/RaceCalculator.spec.js b/tests/unit/views/RaceCalculator.spec.js @@ -3,6 +3,7 @@ import { expect } from 'chai'; import { shallowMount } from '@vue/test-utils'; import raceUtils from '@/utils/races'; +import unitUtils from '@/utils/units'; import RaceCalculator from '@/views/RaceCalculator.vue'; describe('views/RaceCalculator.vue', () => { @@ -14,21 +15,50 @@ describe('views/RaceCalculator.vue', () => { await wrapper.setData({ inputDistance: 5, inputUnit: 'kilometers', - inputTime: 20 * 60, + inputTime: 1200, }); // Predict race times - const result = wrapper.vm.predictTime({ + const result = wrapper.vm.predictResult({ distanceValue: 10, distanceUnit: 'kilometers', + result: 'time', }); // Assert result is correct - const prediction = raceUtils.AverageModel(5000, 1200, 10000); + const prediction = raceUtils.AverageModel.predictTime(5000, 1200, 10000); expect(result).to.deep.equal({ distanceValue: 10, distanceUnit: 'kilometers', time: prediction, + result: 'time', + }); + }); + + it('should correctly predict race distances', async () => { + // Initialize component + const wrapper = shallowMount(RaceCalculator); + + // Override input values + await wrapper.setData({ + inputDistance: 5, + inputUnit: 'kilometers', + inputTime: 1200, + }); + + // Predict race distances + const result = wrapper.vm.predictResult({ + time: 2460, + result: 'distance', + }); + + // Assert result is correct + const prediction = raceUtils.AverageModel.predictDistance(1200, 5000, 2460); + expect(result).to.deep.equal({ + distanceValue: unitUtils.convertDistance(prediction, 'meters', 'miles'), + distanceUnit: 'miles', + time: 2460, + result: 'distance', }); }); }); diff --git a/tests/unit/views/UnitCalculator.spec.js b/tests/unit/views/UnitCalculator.spec.js @@ -2,6 +2,7 @@ import { expect } from 'chai'; import { shallowMount } from '@vue/test-utils'; +import unitUtils from '@/utils/units'; import UnitCalculator from '@/views/UnitCalculator.vue'; describe('views/UnitCalculator.vue', () => { @@ -21,9 +22,11 @@ describe('views/UnitCalculator.vue', () => { await wrapper.setData({ category: 'speed_and_pace' }); // Assert controls are correct - expect(wrapper.vm._data.inputValue).to.equal(1); - expect(wrapper.vm._data.inputUnit).to.equal('miles_per_hour'); - expect(wrapper.vm._data.outputUnit).to.equal('seconds_per_mile'); + expect(wrapper.vm._data.inputValue).to.equal( + unitUtils.getDefaultPaceUnit() === 'seconds_per_mile' ? 600 : 300, + ); + expect(wrapper.vm._data.inputUnit).to.equal(unitUtils.getDefaultPaceUnit()); + expect(wrapper.vm._data.outputUnit).to.equal(unitUtils.getDefaultSpeedUnit()); // Change category await wrapper.setData({ category: 'distance' }); @@ -31,7 +34,7 @@ describe('views/UnitCalculator.vue', () => { // Assert controls are correct expect(wrapper.vm._data.inputValue).to.equal(1); expect(wrapper.vm._data.inputUnit).to.equal('miles'); - expect(wrapper.vm._data.outputUnit).to.equal('meters'); + expect(wrapper.vm._data.outputUnit).to.equal('kilometers'); }); it('outputValue should be correct', async () => {