running-tools

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

commit 314b19d89ed8dd2baaa2758a1f782deb20a0f5cd
parent 147f47f096469669d4925a172f91ff35feba94f2
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Sat, 28 Jun 2025 13:19:36 -0700

Convert all views to TypeScript

Diffstat:
Mpackage-lock.json | 8++++----
Mpackage.json | 2+-
Rsrc/components/RaceOptions.vue -> src/components/RaceOptionsInput.vue | 0
Msrc/components/TargetEditor.vue | 18+++++++++---------
Msrc/components/TargetSetSelector.vue | 6+++---
Msrc/composables/useStorage.ts | 14+++++++-------
Msrc/utils/calculators.ts | 10+++++-----
Msrc/utils/storage.ts | 8++++----
Msrc/utils/targets.ts | 120++++++++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/utils/units.ts | 32+++++++++++++++++++++++---------
Msrc/views/AboutPage.vue | 6+++---
Msrc/views/BatchCalculator.vue | 233++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/views/PaceCalculator.vue | 16+++++++++-------
Msrc/views/RaceCalculator.vue | 26+++++++++++++++-----------
Msrc/views/SplitCalculator.vue | 19+++++++++++--------
Msrc/views/UnitCalculator.vue | 165++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/views/WorkoutCalculator.vue | 38+++++++++++++++++++++-----------------
Dtests/unit/components/RaceOptions.spec.js | 53-----------------------------------------------------
Atests/unit/components/RaceOptionsInput.spec.js | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/unit/views/BatchCalculator.spec.js | 4++--
Mtests/unit/views/RaceCalculator.spec.js | 8++++----
Mtests/unit/views/WorkoutCalculator.spec.js | 6+++---
Mtsconfig.app.json | 9++++++---
23 files changed, 504 insertions(+), 350 deletions(-)

diff --git a/package-lock.json b/package-lock.json @@ -17,7 +17,7 @@ "@eslint/js": "^9.22.0", "@playwright/test": "^1.53.1", "@tsconfig/node22": "^22.0.2", - "@types/node": "^24.0.3", + "@types/node": "^24.0.7", "@vitejs/plugin-vue": "^5.2.4", "@vue/eslint-config-typescript": "^14.5.1", "@vue/test-utils": "^2.4.6", @@ -3051,9 +3051,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.0.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.3.tgz", - "integrity": "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==", + "version": "24.0.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.7.tgz", + "integrity": "sha512-YIEUUr4yf8q8oQoXPpSlnvKNVKDQlPMWrmOcgzoduo7kvA2UF0/BwJ/eMKFTiTtkNL17I0M6Xe2tvwFU7be6iw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json @@ -28,7 +28,7 @@ "@eslint/js": "^9.22.0", "@playwright/test": "^1.53.1", "@tsconfig/node22": "^22.0.2", - "@types/node": "^24.0.3", + "@types/node": "^24.0.7", "@vitejs/plugin-vue": "^5.2.4", "@vue/eslint-config-typescript": "^14.5.1", "@vue/test-utils": "^2.4.6", diff --git a/src/components/RaceOptions.vue b/src/components/RaceOptionsInput.vue diff --git a/src/components/TargetEditor.vue b/src/components/TargetEditor.vue @@ -89,7 +89,7 @@ <script setup lang="ts"> import VueFeather from 'vue-feather'; -import { TargetType, TargetSetType, workoutTargetToString } from '@/utils/targets'; +import { TargetTypes, TargetSetTypes, workoutTargetToString } from '@/utils/targets'; import type { StandardTargetSet, TargetSet, WorkoutTarget, WorkoutTargetSet } from '@/utils/targets'; import { DistanceUnitData, UnitSystems, getDefaultDistanceUnit } from '@/utils/units'; @@ -120,14 +120,14 @@ interface Props { /** * The target set type (Standard, Split, or Workout, defaults to Standard) */ - setType?: TargetSetType, + setType?: TargetSetTypes, } const props = withDefaults(defineProps<Props>(), { customWorkoutNames: false, defaultUnitSystem: UnitSystems.Metric, isCustomSet: false, - setType: TargetSetType.Standard, + setType: TargetSetTypes.Standard, }); // Declare emitted events @@ -140,9 +140,9 @@ const model = useObjectModel<TargetSet>(() => props.modelValue, (x) => emit('upd * Add a new distance based target */ function addDistanceTarget() { - if (props.setType === TargetSetType.Workout) { + if (props.setType === TargetSetTypes.Workout) { (model.value as WorkoutTargetSet).targets.push({ - type: TargetType.Distance, + type: TargetTypes.Distance, distanceValue: 1, distanceUnit: getDefaultDistanceUnit(props.defaultUnitSystem), splitValue: 1, @@ -150,7 +150,7 @@ function addDistanceTarget() { }); } else { (model.value as StandardTargetSet).targets.push({ - type: TargetType.Distance, + type: TargetTypes.Distance, distanceValue: 1, distanceUnit: getDefaultDistanceUnit(props.defaultUnitSystem), }); @@ -161,16 +161,16 @@ function addDistanceTarget() { * Add a new time based target */ function addTimeTarget() { - if (props.setType === TargetSetType.Workout) { + if (props.setType === TargetSetTypes.Workout) { (model.value as WorkoutTargetSet).targets.push({ - type: TargetType.Time, + type: TargetTypes.Time, time: 600, splitValue: 1, splitUnit: getDefaultDistanceUnit(props.defaultUnitSystem), }); } else { (model.value as StandardTargetSet).targets.push({ - type: TargetType.Time, + type: TargetTypes.Time, time: 600, }); } diff --git a/src/components/TargetSetSelector.vue b/src/components/TargetSetSelector.vue @@ -26,7 +26,7 @@ import { computed, nextTick, ref } from 'vue'; import VueFeather from 'vue-feather'; import { deepCopy } from '@/utils/misc'; -import { TargetSetType, sort, defaultTargetSets } from '@/utils/targets'; +import { TargetSetTypes, sort, defaultTargetSets } from '@/utils/targets'; import type { TargetSet, TargetSets } from '@/utils/targets'; import { UnitSystems } from '@/utils/units'; @@ -55,7 +55,7 @@ interface Props { /** * The target set type (Standard, Split, or Workout, defaults to Standard) */ - setType?: TargetSetType, + setType?: TargetSetTypes, /** * The target sets @@ -67,7 +67,7 @@ interface Props { const props = withDefaults(defineProps<Props>(), { customWorkoutNames: false, defaultUnitSystem: UnitSystems.Metric, - setType: TargetSetType.Standard, + setType: TargetSetTypes.Standard, }); // Generate internal ref tied to modelValue prop diff --git a/src/composables/useStorage.ts b/src/composables/useStorage.ts @@ -6,16 +6,16 @@ import * as storage from '@/utils/storage'; /* * Create a reactive value that is synced with a localStorage item * @param {string} key The localStorage item's key - * @param {object} defaultValue The default value - * @returns {Ref<object>} The synchronized ref + * @param {Type} defaultValue The default value + * @returns {Ref<Type>} The synchronized ref */ -export default function useStorage(key: string, defaultValue: object): Ref<object> { - const clonedDefault = JSON.parse(JSON.stringify(defaultValue)); - const value = ref(clonedDefault); +export default function useStorage<Type>(key: string, defaultValue: Type): Ref<Type> { + const clonedDefault: Type = JSON.parse(JSON.stringify(defaultValue)); + const value: Ref<Type> = ref<Type>(clonedDefault) as Ref<Type>; // (Re)load value from localStorage function updateValue() { - const parsedValue = storage.get(key); + const parsedValue = storage.get<Type>(key); if (parsedValue !== null) value.value = parsedValue; } updateValue(); @@ -24,7 +24,7 @@ export default function useStorage(key: string, defaultValue: object): Ref<objec // Save value to localStorage when modified watchEffect(() => { if (typeof localStorage !== 'undefined') { - storage.set(key, value.value); + storage.set<Type>(key, value.value); } }) diff --git a/src/utils/calculators.ts b/src/utils/calculators.ts @@ -1,7 +1,7 @@ import { formatDuration, formatNumber } from '@/utils/format'; import * as paceUtils from '@/utils/paces'; import * as raceUtils from '@/utils/races'; -import { TargetType, workoutTargetToString } from '@/utils/targets'; +import { TargetTypes, workoutTargetToString } from '@/utils/targets'; import type { StandardTarget, WorkoutTarget } from '@/utils/targets'; import { DistanceUnits, DistanceUnitData, UnitSystems, convertDistance, getDefaultDistanceUnit } from '@/utils/units'; @@ -15,7 +15,7 @@ export enum ResultType { interface PreResult { distanceValue: number, distanceUnit: DistanceUnits, - result: TargetType, + result: TargetTypes, time: number, }; @@ -69,7 +69,7 @@ export function formatTargetResult(result: PreResult, defaultUnitSystem: UnitSys + DistanceUnitData[getDefaultDistanceUnit(defaultUnitSystem)].symbol, // Convert dist/time result to key/value - result: result.result === TargetType.Time ? ResultType.Value : ResultType.Key, + result: result.result === TargetTypes.Time ? ResultType.Value : ResultType.Key, // Use time (in seconds) as sort key sort: result.time, @@ -91,7 +91,7 @@ export function calculatePaceResults(input: DistanceTime, target: StandardTarget distanceValue: 0, distanceUnit: DistanceUnits.Meters, time: 0, - result: target.type === TargetType.Distance ? TargetType.Time : TargetType.Distance, + result: target.type === TargetTypes.Distance ? TargetTypes.Time : TargetTypes.Distance, }; const d1 = convertDistance(input.distanceValue, input.distanceUnit, DistanceUnits.Meters); @@ -141,7 +141,7 @@ export function calculateRaceResults(input: DistanceTime, target: StandardTarget distanceValue: 0, distanceUnit: DistanceUnits.Meters, time: 0, - result: target.type === TargetType.Distance ? TargetType.Time : TargetType.Distance, + result: target.type === TargetTypes.Distance ? TargetTypes.Time : TargetTypes.Distance, }; const d1 = convertDistance(input.distanceValue, input.distanceUnit, DistanceUnits.Meters); diff --git a/src/utils/storage.ts b/src/utils/storage.ts @@ -4,9 +4,9 @@ const prefix = 'running-tools'; /** * Read an object from a localStorage item * @param {string} key The localStorage item's key - * @returns {object} The object + * @returns {Type} The object */ -export function get(key: string): object | null { +export function get<Type>(key: string): Type | null { try { return JSON.parse(localStorage.getItem(`${prefix}.${key}`) || ''); } catch { @@ -17,9 +17,9 @@ export function get(key: string): object | null { /** * Write an object to a localStorage item * @param {string} key The localStorage item's key - * @param {object} value The object to write + * @param {Type} value The object to write */ -export function set(key: string, value: object) { +export function set<Type>(key: string, value: Type) { localStorage.setItem(`${prefix}.${key}`, JSON.stringify(value)); } diff --git a/src/utils/targets.ts b/src/utils/targets.ts @@ -4,7 +4,7 @@ import { DistanceUnits, DistanceUnitData, convertDistance } from '@/utils/units' /* * Enumeration for the two basic types of targets: those defined by distance vs time */ -export enum TargetType { +export enum TargetTypes { Distance = 'distance', Time = 'time', }; @@ -13,7 +13,7 @@ export enum TargetType { * Type for basic distance-defined targets */ interface DistanceTarget { - type: TargetType.Distance, + type: TargetTypes.Distance, distanceValue: number, distanceUnit: DistanceUnits, }; @@ -22,7 +22,7 @@ interface DistanceTarget { * Type for basic time-defined targets */ interface TimeTarget { - type: TargetType.Time, + type: TargetTypes.Time, time: number, }; @@ -95,7 +95,7 @@ export interface WorkoutTargetSets { /* * Enumeration for the three types of targets sets: standard (pace & race), split, and workout */ -export enum TargetSetType { +export enum TargetSetTypes { Standard = 'standard', Split = 'split', Workout = 'workout', @@ -123,11 +123,11 @@ export type TargetSets = StandardTargetSets | SplitTargetSets | WorkoutTargetSet */ export function sort(targets: Array<Target>): Array<Target> { return [ - ...targets.filter((item) => item.type === TargetType.Distance) + ...targets.filter((item) => item.type === TargetTypes.Distance) .sort((a, b) => convertDistance(a.distanceValue, a.distanceUnit, DistanceUnits.Meters) - convertDistance(b.distanceValue, b.distanceUnit, DistanceUnits.Meters)), - ...targets.filter((item) => item.type === TargetType.Time) + ...targets.filter((item) => item.type === TargetTypes.Time) .sort((a, b) => a.time - b.time), ]; } @@ -140,7 +140,7 @@ export function sort(targets: Array<Target>): Array<Target> { export function workoutTargetToString(target: WorkoutTarget): string { let result = formatNumber(target.splitValue, 0, 2, false) + ' ' + DistanceUnitData[target.splitUnit].symbol; - if (target.type === TargetType.Time) { + if (target.type === TargetTypes.Time) { result += ' @ ' + formatDuration(target.time, 3, 2, false); } else if (target.distanceValue != target.splitValue || target.distanceUnit != target.splitUnit) { result += ' @ ' + formatNumber(target.distanceValue, 0, 2, false) + ' ' + @@ -155,40 +155,40 @@ export function workoutTargetToString(target: WorkoutTarget): string { const common_pace_targets: StandardTargetSet = { name: 'Common Pace Targets', targets: sort([ - { type: TargetType.Distance, distanceValue: 100, distanceUnit: DistanceUnits.Meters }, - { type: TargetType.Distance, distanceValue: 200, distanceUnit: DistanceUnits.Meters }, - { type: TargetType.Distance, distanceValue: 300, distanceUnit: DistanceUnits.Meters }, - { type: TargetType.Distance, distanceValue: 400, distanceUnit: DistanceUnits.Meters }, - { type: TargetType.Distance, distanceValue: 600, distanceUnit: DistanceUnits.Meters }, - { type: TargetType.Distance, distanceValue: 800, distanceUnit: DistanceUnits.Meters }, - { type: TargetType.Distance, distanceValue: 1000, distanceUnit: DistanceUnits.Meters }, - { type: TargetType.Distance, distanceValue: 1200, distanceUnit: DistanceUnits.Meters }, - { type: TargetType.Distance, distanceValue: 1500, distanceUnit: DistanceUnits.Meters }, - { type: TargetType.Distance, distanceValue: 1600, distanceUnit: DistanceUnits.Meters }, - { type: TargetType.Distance, distanceValue: 3200, distanceUnit: DistanceUnits.Meters }, + { type: TargetTypes.Distance, distanceValue: 100, distanceUnit: DistanceUnits.Meters }, + { type: TargetTypes.Distance, distanceValue: 200, distanceUnit: DistanceUnits.Meters }, + { type: TargetTypes.Distance, distanceValue: 300, distanceUnit: DistanceUnits.Meters }, + { type: TargetTypes.Distance, distanceValue: 400, distanceUnit: DistanceUnits.Meters }, + { type: TargetTypes.Distance, distanceValue: 600, distanceUnit: DistanceUnits.Meters }, + { type: TargetTypes.Distance, distanceValue: 800, distanceUnit: DistanceUnits.Meters }, + { type: TargetTypes.Distance, distanceValue: 1000, distanceUnit: DistanceUnits.Meters }, + { type: TargetTypes.Distance, distanceValue: 1200, distanceUnit: DistanceUnits.Meters }, + { type: TargetTypes.Distance, distanceValue: 1500, distanceUnit: DistanceUnits.Meters }, + { type: TargetTypes.Distance, distanceValue: 1600, distanceUnit: DistanceUnits.Meters }, + { type: TargetTypes.Distance, distanceValue: 3200, distanceUnit: DistanceUnits.Meters }, - { type: TargetType.Distance, distanceValue: 2, distanceUnit: DistanceUnits.Kilometers }, - { type: TargetType.Distance, distanceValue: 3, distanceUnit: DistanceUnits.Kilometers }, - { type: TargetType.Distance, distanceValue: 4, distanceUnit: DistanceUnits.Kilometers }, - { type: TargetType.Distance, distanceValue: 5, distanceUnit: DistanceUnits.Kilometers }, - { type: TargetType.Distance, distanceValue: 6, distanceUnit: DistanceUnits.Kilometers }, - { type: TargetType.Distance, distanceValue: 8, distanceUnit: DistanceUnits.Kilometers }, - { type: TargetType.Distance, distanceValue: 10, distanceUnit: DistanceUnits.Kilometers }, + { type: TargetTypes.Distance, distanceValue: 2, distanceUnit: DistanceUnits.Kilometers }, + { type: TargetTypes.Distance, distanceValue: 3, distanceUnit: DistanceUnits.Kilometers }, + { type: TargetTypes.Distance, distanceValue: 4, distanceUnit: DistanceUnits.Kilometers }, + { type: TargetTypes.Distance, distanceValue: 5, distanceUnit: DistanceUnits.Kilometers }, + { type: TargetTypes.Distance, distanceValue: 6, distanceUnit: DistanceUnits.Kilometers }, + { type: TargetTypes.Distance, distanceValue: 8, distanceUnit: DistanceUnits.Kilometers }, + { type: TargetTypes.Distance, distanceValue: 10, distanceUnit: DistanceUnits.Kilometers }, - { type: TargetType.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Miles }, - { type: TargetType.Distance, distanceValue: 2, distanceUnit: DistanceUnits.Miles }, - { type: TargetType.Distance, distanceValue: 3, distanceUnit: DistanceUnits.Miles }, - { type: TargetType.Distance, distanceValue: 5, distanceUnit: DistanceUnits.Miles }, - { type: TargetType.Distance, distanceValue: 6, distanceUnit: DistanceUnits.Miles }, - { type: TargetType.Distance, distanceValue: 8, distanceUnit: DistanceUnits.Miles }, - { type: TargetType.Distance, distanceValue: 10, distanceUnit: DistanceUnits.Miles }, + { type: TargetTypes.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Miles }, + { type: TargetTypes.Distance, distanceValue: 2, distanceUnit: DistanceUnits.Miles }, + { type: TargetTypes.Distance, distanceValue: 3, distanceUnit: DistanceUnits.Miles }, + { type: TargetTypes.Distance, distanceValue: 5, distanceUnit: DistanceUnits.Miles }, + { type: TargetTypes.Distance, distanceValue: 6, distanceUnit: DistanceUnits.Miles }, + { type: TargetTypes.Distance, distanceValue: 8, distanceUnit: DistanceUnits.Miles }, + { type: TargetTypes.Distance, distanceValue: 10, distanceUnit: DistanceUnits.Miles }, - { type: TargetType.Distance, distanceValue: 0.5, distanceUnit: DistanceUnits.Marathons }, - { type: TargetType.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Marathons }, + { type: TargetTypes.Distance, distanceValue: 0.5, distanceUnit: DistanceUnits.Marathons }, + { type: TargetTypes.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Marathons }, - { type: TargetType.Time, time: 600 }, - { type: TargetType.Time, time: 1800 }, - { type: TargetType.Time, time: 3600 }, + { type: TargetTypes.Time, time: 600 }, + { type: TargetTypes.Time, time: 1800 }, + { type: TargetTypes.Time, time: 3600 }, ]), }; @@ -198,24 +198,24 @@ const common_pace_targets: StandardTargetSet = { const common_race_targets: StandardTargetSet = { name: 'Common Race Targets', targets: sort([ - { type: TargetType.Distance, distanceValue: 400, distanceUnit: DistanceUnits.Meters }, - { type: TargetType.Distance, distanceValue: 800, distanceUnit: DistanceUnits.Meters }, - { type: TargetType.Distance, distanceValue: 1500, distanceUnit: DistanceUnits.Meters }, - { type: TargetType.Distance, distanceValue: 1600, distanceUnit: DistanceUnits.Meters }, - { type: TargetType.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Miles }, - { type: TargetType.Distance, distanceValue: 3000, distanceUnit: DistanceUnits.Meters }, - { type: TargetType.Distance, distanceValue: 3200, distanceUnit: DistanceUnits.Meters }, - { type: TargetType.Distance, distanceValue: 2, distanceUnit: DistanceUnits.Miles }, + { type: TargetTypes.Distance, distanceValue: 400, distanceUnit: DistanceUnits.Meters }, + { type: TargetTypes.Distance, distanceValue: 800, distanceUnit: DistanceUnits.Meters }, + { type: TargetTypes.Distance, distanceValue: 1500, distanceUnit: DistanceUnits.Meters }, + { type: TargetTypes.Distance, distanceValue: 1600, distanceUnit: DistanceUnits.Meters }, + { type: TargetTypes.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Miles }, + { type: TargetTypes.Distance, distanceValue: 3000, distanceUnit: DistanceUnits.Meters }, + { type: TargetTypes.Distance, distanceValue: 3200, distanceUnit: DistanceUnits.Meters }, + { type: TargetTypes.Distance, distanceValue: 2, distanceUnit: DistanceUnits.Miles }, - { type: TargetType.Distance, distanceValue: 3, distanceUnit: DistanceUnits.Miles }, - { type: TargetType.Distance, distanceValue: 5, distanceUnit: DistanceUnits.Kilometers }, - { type: TargetType.Distance, distanceValue: 6, distanceUnit: DistanceUnits.Kilometers }, - { type: TargetType.Distance, distanceValue: 8, distanceUnit: DistanceUnits.Kilometers }, - { type: TargetType.Distance, distanceValue: 10, distanceUnit: DistanceUnits.Kilometers }, - { type: TargetType.Distance, distanceValue: 15, distanceUnit: DistanceUnits.Kilometers }, + { type: TargetTypes.Distance, distanceValue: 3, distanceUnit: DistanceUnits.Miles }, + { type: TargetTypes.Distance, distanceValue: 5, distanceUnit: DistanceUnits.Kilometers }, + { type: TargetTypes.Distance, distanceValue: 6, distanceUnit: DistanceUnits.Kilometers }, + { type: TargetTypes.Distance, distanceValue: 8, distanceUnit: DistanceUnits.Kilometers }, + { type: TargetTypes.Distance, distanceValue: 10, distanceUnit: DistanceUnits.Kilometers }, + { type: TargetTypes.Distance, distanceValue: 15, distanceUnit: DistanceUnits.Kilometers }, - { type: TargetType.Distance, distanceValue: 0.5, distanceUnit: DistanceUnits.Marathons }, - { type: TargetType.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Marathons }, + { type: TargetTypes.Distance, distanceValue: 0.5, distanceUnit: DistanceUnits.Marathons }, + { type: TargetTypes.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Marathons }, ]), }; @@ -226,9 +226,9 @@ const common_race_targets: StandardTargetSet = { const five_k_mile_splits: SplitTargetSet = { name: '5K Mile Splits', targets: [ - { type: TargetType.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Miles }, - { type: TargetType.Distance, distanceValue: 2, distanceUnit: DistanceUnits.Miles }, - { type: TargetType.Distance, distanceValue: 5, distanceUnit: DistanceUnits.Kilometers }, + { type: TargetTypes.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Miles }, + { type: TargetTypes.Distance, distanceValue: 2, distanceUnit: DistanceUnits.Miles }, + { type: TargetTypes.Distance, distanceValue: 5, distanceUnit: DistanceUnits.Kilometers }, ], }; @@ -240,19 +240,19 @@ const common_workout_targets: WorkoutTargetSet = { targets: [ { splitValue: 400, splitUnit: DistanceUnits.Meters, - type: TargetType.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Miles, + type: TargetTypes.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Miles, }, { splitValue: 800, splitUnit: DistanceUnits.Meters, - type: TargetType.Distance, distanceValue: 5, distanceUnit: DistanceUnits.Kilometers, + type: TargetTypes.Distance, distanceValue: 5, distanceUnit: DistanceUnits.Kilometers, }, { splitValue: 1600, splitUnit: DistanceUnits.Meters, - type: TargetType.Time, time: 3600, + type: TargetTypes.Time, time: 3600, }, { splitValue: 1, splitUnit: DistanceUnits.Miles, - type: TargetType.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Marathons, + type: TargetTypes.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Marathons, }, ], }; diff --git a/src/utils/units.ts b/src/utils/units.ts @@ -1,4 +1,13 @@ /** + * The data included for each unit + */ +export interface UnitData { + name: string, + symbol: string, + value: number, +}; + +/** * The supported time units */ export enum TimeUnits { @@ -6,7 +15,7 @@ export enum TimeUnits { Minutes = 'minutes', Hours = 'hours', } -export const TimeUnitData = { +export const TimeUnitData: { [key in TimeUnits]: UnitData } = { [TimeUnits.Seconds]: { name: 'Seconds', symbol: 's', @@ -34,7 +43,7 @@ export enum DistanceUnits { Miles = 'miles', Marathons = 'marathons', } -export const DistanceUnitData = { +export const DistanceUnitData: { [key in DistanceUnits]: UnitData } = { [DistanceUnits.Meters]: { name: 'Meters', symbol: 'm', @@ -70,18 +79,18 @@ export enum SpeedUnits { KilometersPerHour = 'kilometers_per_hour', MilesPerHour = 'miles_per_hour', } -export const SpeedUnitData = { - meters_per_second: { +export const SpeedUnitData: { [key in SpeedUnits]: UnitData } = { + [SpeedUnits.MetersPerSecond]: { name: 'Meters per Second', symbol: 'm/s', value: 1, }, - kilometers_per_hour: { + [SpeedUnits.KilometersPerHour]: { name: 'Kilometers per Hour', symbol: 'kph', value: DistanceUnitData[DistanceUnits.Kilometers].value / TimeUnitData[TimeUnits.Hours].value, }, - miles_per_hour: { + [SpeedUnits.MilesPerHour]: { name: 'Miles per Hour', symbol: 'mph', value: DistanceUnitData[DistanceUnits.Miles].value / TimeUnitData[TimeUnits.Hours].value, @@ -96,7 +105,7 @@ export enum PaceUnits { TimePerKilometer = 'seconds_per_kilometer', TimePerMile = 'seconds_per_mile', } -export const PaceUnitData = { +export const PaceUnitData: { [key in PaceUnits]: UnitData } = { [PaceUnits.SecondsPerMeter]: { name: 'Seconds per Meter', symbol: 's/m', @@ -114,6 +123,11 @@ export const PaceUnitData = { }, }; +/** + * The supported speed and pace units + */ +export type SpeedPaceUnits = SpeedUnits | PaceUnits; + export enum UnitSystems { Metric = 'metric', Imperial = 'imperial', @@ -183,8 +197,8 @@ export function convertPace(inputValue: number, inputUnit: PaceUnits, * @param {string} outputUnit The unit of the output * @returns {number} The output */ -export function convertSpeedPace(inputValue: number, inputUnit: SpeedUnits | PaceUnits, - outputUnit: SpeedUnits | PaceUnits): number { +export function convertSpeedPace(inputValue: number, inputUnit: SpeedPaceUnits, + outputUnit: SpeedPaceUnits): number { // Calculate input speed let speed; if (inputUnit in PaceUnitData) { diff --git a/src/views/AboutPage.vue b/src/views/AboutPage.vue @@ -161,13 +161,13 @@ </div> </template> -<script setup> +<script setup lang="ts"> import { repository, version } from '/package.json'; import VueFeather from 'vue-feather'; -const development = process.env.NODE_ENV === 'development'; -const git_url = repository.url.slice(4); +const development: boolean = process.env.NODE_ENV === 'development'; +const git_url: string = repository.url.slice(4); </script> <style scoped> diff --git a/src/views/BatchCalculator.vue b/src/views/BatchCalculator.vue @@ -37,18 +37,22 @@ <div> Target Set: <target-set-selector v-model:selectedTargetSet="selectedTargetSet" - :setType="options.calculator === 'workout' ? 'workout' : 'standard'" - :customWorkoutNames="advancedOptions.customTargetNames" v-model:targetSets="targetSets" + :set-type="options.calculator === BatchCompatableCalculators.Workout ? + TargetSetTypes.Workout : TargetSetTypes.Standard" v-model:targetSets="targetSets" + :customWorkoutNames="options.calculator === BatchCompatableCalculators.Workout ? + (advancedOptions as WorkoutOptions).customTargetNames : false" :default-unit-system="defaultUnitSystem"/> </div> <div v-if="options.calculator === 'workout'"> Target Name Customization: - <select v-model="advancedOptions.customTargetNames" aria-label="Target name customization"> + <select v-model="(advancedOptions as WorkoutOptions).customTargetNames" + aria-label="Target name customization"> <option :value="false">Disabled</option> <option :value="true">Enabled</option> </select> </div> - <race-options v-if="options.calculator !== 'pace'" v-model="advancedOptions"/> + <race-options-input v-if="options.calculator !== BatchCompatableCalculators.Pace" + v-model="advancedOptions as RaceOptions"/> </details> <h2>Batch Results</h2> @@ -58,36 +62,59 @@ </div> </template> -<script setup> +<script setup lang="ts"> import { computed } from 'vue'; import * as calcUtils from '@/utils/calculators'; -import { defaultTargetSets } from '@/utils/targets'; -import { detectDefaultUnitSystem } from '@/utils/units'; +import type { TargetResult, RaceOptions, WorkoutOptions } from '@/utils/calculators'; +import { RacePredictionModel } from '@/utils/races'; +import { TargetSetTypes, defaultTargetSets } from '@/utils/targets'; +import type { Target, TargetSets, StandardTargetSet, StandardTargetSets, WorkoutTarget, + WorkoutTargetSet, WorkoutTargetSets } from '@/utils/targets'; +import { DistanceUnits, UnitSystems, detectDefaultUnitSystem } from '@/utils/units'; +import type { Distance, DistanceTime } from '@/utils/units'; import DoubleOutputTable from '@/components/DoubleOutputTable.vue'; import IntegerInput from '@/components/IntegerInput.vue'; import PaceInput from '@/components/PaceInput.vue'; -import RaceOptions from '@/components/RaceOptions.vue'; +import RaceOptionsInput from '@/components/RaceOptionsInput.vue'; import TargetSetSelector from '@/components/TargetSetSelector.vue'; import TimeInput from '@/components/TimeInput.vue'; import useStorage from '@/composables/useStorage'; /** + * The calculators that may be used from within the batch calculator + */ +enum BatchCompatableCalculators { + Pace = 'pace', + Race = 'race', + Workout = 'workout', +} + +/** + * The batch calculator settings type + */ +interface BatchCalculatorOptions { + calculator: BatchCompatableCalculators, + increment: number, + rows: number, +} + +/** * The input pace */ -const input = useStorage('batch-calculator-input', { +const input = useStorage<DistanceTime>('batch-calculator-input', { distanceValue: 5, - distanceUnit: 'kilometers', + distanceUnit: DistanceUnits.Kilometers, time: 1200, }); /** * The batch input options */ -const options = useStorage('batch-calculator-options', { - calculator: 'workout', +const options = useStorage<BatchCalculatorOptions>('batch-calculator-options', { + calculator: BatchCompatableCalculators.Workout, increment: 15, rows: 20, }); @@ -95,45 +122,46 @@ const options = useStorage('batch-calculator-options', { /** * The default unit system */ -const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSystem()); +const defaultUnitSystem = useStorage<UnitSystems>('default-unit-system', detectDefaultUnitSystem()); /** * The current selected target sets for each calculator */ -const selectedPaceTargetSet = useStorage('pace-calculator-target-set', '_pace_targets'); -const selectedRaceTargetSet = useStorage('race-calculator-target-set', '_race_targets'); -const selectedWorkoutTargetSet = useStorage('workout-calculator-target-set', '_workout_targets'); +const selectedPaceTargetSet = useStorage<string>('pace-calculator-target-set', '_pace_targets'); +const selectedRaceTargetSet = useStorage<string>('race-calculator-target-set', '_race_targets'); +const selectedWorkoutTargetSet = useStorage<string>('workout-calculator-target-set', + '_workout_targets'); /** * The target sets for each calculator */ -const paceTargetSets = useStorage('pace-calculator-target-sets', { - _pace_targets: defaultTargetSets._pace_targets +const paceTargetSets = useStorage<StandardTargetSets>('pace-calculator-target-sets', { + _pace_targets: defaultTargetSets._pace_targets as StandardTargetSet }); -const raceTargetSets = useStorage('race-calculator-target-sets', { - _race_targets: defaultTargetSets._race_targets +const raceTargetSets = useStorage<StandardTargetSets>('race-calculator-target-sets', { + _race_targets: defaultTargetSets._race_targets as StandardTargetSet }); -const workoutTargetSets = useStorage('workout-calculator-target-sets', { - _workout_targets: defaultTargetSets._workout_targets +const workoutTargetSets = useStorage<WorkoutTargetSets>('workout-calculator-target-sets', { + _workout_targets: defaultTargetSets._workout_targets as WorkoutTargetSet }); /** * The advanced options for each calculator */ -const raceOptions = useStorage('race-calculator-options', { - model: 'AverageModel', +const raceOptions = useStorage<RaceOptions>('race-calculator-options', { + model: RacePredictionModel.AverageModel, riegelExponent: 1.06, }); -const workoutOptions = useStorage('workout-calculator-options', { +const workoutOptions = useStorage<WorkoutOptions>('workout-calculator-options', { customTargetNames: false, - model: 'AverageModel', + model: RacePredictionModel.AverageModel, riegelExponent: 1.06, }); /** * The input distance */ -const inputDistance = computed(() => ({ +const inputDistance = computed<Distance>(() => ({ distanceValue: input.value.distanceValue, distanceUnit: input.value.distanceUnit, })); @@ -141,8 +169,8 @@ const inputDistance = computed(() => ({ /** * The set of input times */ -const inputTimes = computed(() => { - let results = []; +const inputTimes = computed<Array<number>>(() => { + const results = []; for (let i = 0; i < options.value.rows; i++) { results.push(input.value.time + options.value.increment * i); } @@ -152,23 +180,36 @@ const inputTimes = computed(() => { /** * The selected target set for the current calculator */ -const selectedTargetSet = computed({ - get: () => { - if (options.value.calculator === 'pace') { - return selectedPaceTargetSet.value; - } else if (options.value.calculator === 'race') { - return selectedRaceTargetSet.value; - } else { - return selectedWorkoutTargetSet.value; +const selectedTargetSet = computed<string>({ + get: (): string => { + switch (options.value.calculator) { + case (BatchCompatableCalculators.Pace): { + return selectedPaceTargetSet.value; + } + case (BatchCompatableCalculators.Race): { + return selectedRaceTargetSet.value; + } + default: + case (BatchCompatableCalculators.Workout): { + return selectedWorkoutTargetSet.value; + } } }, - set: (newValue) => { - if (options.value.calculator === 'pace') { - selectedPaceTargetSet.value = newValue; - } else if (options.value.calculator === 'race') { - selectedRaceTargetSet.value = newValue; - } else { - selectedWorkoutTargetSet.value = newValue; + set: (newValue: string) => { + switch (options.value.calculator) { + case (BatchCompatableCalculators.Pace): { + selectedPaceTargetSet.value = newValue; + break; + } + case (BatchCompatableCalculators.Race): { + selectedRaceTargetSet.value = newValue; + break; + } + default: + case (BatchCompatableCalculators.Workout): { + selectedWorkoutTargetSet.value = newValue; + break; + } } }, }); @@ -176,23 +217,36 @@ const selectedTargetSet = computed({ /** * The target sets for the current calculator */ -const targetSets = computed({ +const targetSets = computed<TargetSets>({ get: () => { - if (options.value.calculator === 'pace') { - return paceTargetSets.value; - } else if (options.value.calculator === 'race') { - return raceTargetSets.value; - } else { - return workoutTargetSets.value; + switch (options.value.calculator) { + case (BatchCompatableCalculators.Pace): { + return paceTargetSets.value; + } + case (BatchCompatableCalculators.Race): { + return raceTargetSets.value; + } + default: + case (BatchCompatableCalculators.Workout): { + return workoutTargetSets.value; + } } }, - set: (newValue) => { - if (options.value.calculator === 'pace') { - paceTargetSets.value = newValue; - } else if (options.value.calculator === 'race') { - raceTargetSets.value = newValue; - } else { - workoutTargetSets.value = newValue; + set: (newValue: TargetSets) => { + switch (options.value.calculator) { + case (BatchCompatableCalculators.Pace): { + paceTargetSets.value = newValue as StandardTargetSets; + break; + } + case (BatchCompatableCalculators.Race): { + raceTargetSets.value = newValue as StandardTargetSets; + break; + } + default: + case (BatchCompatableCalculators.Workout): { + workoutTargetSets.value = newValue as WorkoutTargetSets; + break; + } } }, }); @@ -200,23 +254,36 @@ const targetSets = computed({ /** * The advanced options for the current calculator */ -const advancedOptions = computed({ +const advancedOptions = computed<null | RaceOptions | WorkoutOptions>({ get: () => { - if (options.value.calculator === 'pace') { - return {}; - } else if (options.value.calculator === 'race') { - return raceOptions.value; - } else { - return workoutOptions.value; + switch (options.value.calculator) { + case (BatchCompatableCalculators.Pace): { + return null; + } + case (BatchCompatableCalculators.Race): { + return raceOptions.value; + } + default: + case (BatchCompatableCalculators.Workout): { + return workoutOptions.value; + } } }, - set: (newValue) => { - if (options.value.calculator === 'pace') { - // do nothing - } else if (options.value.calculator === 'race') { - raceOptions.value = newValue; - } else { - workoutOptions.value = newValue; + set: (newValue: null | RaceOptions | WorkoutOptions) => { + switch(options.value.calculator) { + case (BatchCompatableCalculators.Pace): { + // do nothing + break; + } + case (BatchCompatableCalculators.Race): { + raceOptions.value = newValue as RaceOptions; + break; + } + default: + case (BatchCompatableCalculators.Workout): { + workoutOptions.value = newValue as WorkoutOptions; + break; + } } }, }); @@ -224,14 +291,20 @@ const advancedOptions = computed({ /** * The appropriate calculate_results function for the current calculator */ -const calculateResult = computed(() => { - if (options.value.calculator === 'pace') { - return (x,y) => calcUtils.calculatePaceResults(x, y, defaultUnitSystem.value, false); - } else if (options.value.calculator === 'race') { - return (x,y) => calcUtils.calculateRaceResults(x, y, raceOptions.value, defaultUnitSystem.value, - false); - } else { - return (x,y) => calcUtils.calculateWorkoutResults(x, y, workoutOptions.value, false); +const calculateResult = computed<(x: DistanceTime, y: Target) => TargetResult>(() => { + switch(options.value.calculator) { + case (BatchCompatableCalculators.Pace): { + return (x,y) => calcUtils.calculatePaceResults(x, y, defaultUnitSystem.value, false); + } + case (BatchCompatableCalculators.Race): { + return (x,y) => calcUtils.calculateRaceResults(x, y, raceOptions.value, + defaultUnitSystem.value, false); + } + default: + case (BatchCompatableCalculators.Workout): { + return (x,y) => calcUtils.calculateWorkoutResults(x, y as WorkoutTarget, workoutOptions.value, + false); + } } }); </script> diff --git a/src/views/PaceCalculator.vue b/src/views/PaceCalculator.vue @@ -30,10 +30,12 @@ </div> </template> -<script setup> +<script setup lang="ts"> import { calculatePaceResults } from '@/utils/calculators'; import { defaultTargetSets } from '@/utils/targets'; -import { detectDefaultUnitSystem } from '@/utils/units'; +import type { StandardTargetSets } from '@/utils/targets'; +import { DistanceUnits, UnitSystems, detectDefaultUnitSystem } from '@/utils/units'; +import type { DistanceTime } from '@/utils/units'; import PaceInput from '@/components/PaceInput.vue'; import SingleOutputTable from '@/components/SingleOutputTable.vue'; @@ -44,26 +46,26 @@ import useStorage from '@/composables/useStorage'; /** * The input pace */ -const input = useStorage('pace-calculator-input', { +const input = useStorage<DistanceTime>('pace-calculator-input', { distanceValue: 5, - distanceUnit: 'kilometers', + distanceUnit: DistanceUnits.Kilometers, time: 1200, }); /** * The default unit system */ -const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSystem()); +const defaultUnitSystem = useStorage<UnitSystems>('default-unit-system', detectDefaultUnitSystem()); /** * The current selected target set */ -const selectedTargetSet = useStorage('pace-calculator-target-set', '_pace_targets'); +const selectedTargetSet = useStorage<string>('pace-calculator-target-set', '_pace_targets'); /** * The target sets */ -const targetSets = useStorage('pace-calculator-target-sets', { +const targetSets = useStorage<StandardTargetSets>('pace-calculator-target-sets', { _pace_targets: defaultTargetSets._pace_targets }); </script> diff --git a/src/views/RaceCalculator.vue b/src/views/RaceCalculator.vue @@ -38,7 +38,7 @@ <target-set-selector v-model:selectedTargetSet="selectedTargetSet" v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/> </div> - <race-options v-model="options"/> + <race-options-input v-model="options"/> </details> <h2>Equivalent Race Results</h2> @@ -48,16 +48,20 @@ </div> </template> -<script setup> +<script setup lang="ts"> import { computed } from 'vue'; import { calculateRaceResults, calculateRaceStats } from '@/utils/calculators'; +import type { RaceOptions } from '@/utils/calculators'; import { formatNumber } from '@/utils/format'; +import { RacePredictionModel } from '@/utils/races'; import { defaultTargetSets } from '@/utils/targets'; -import { detectDefaultUnitSystem } from '@/utils/units'; +import type { StandardTargetSets } from '@/utils/targets'; +import { DistanceUnits, UnitSystems, detectDefaultUnitSystem } from '@/utils/units'; +import type { DistanceTime } from '@/utils/units'; import PaceInput from '@/components/PaceInput.vue'; -import RaceOptions from '@/components/RaceOptions.vue'; +import RaceOptionsInput from '@/components/RaceOptionsInput.vue'; import SingleOutputTable from '@/components/SingleOutputTable.vue'; import TargetSetSelector from '@/components/TargetSetSelector.vue'; @@ -66,34 +70,34 @@ import useStorage from '@/composables/useStorage'; /** * The input race */ -const input = useStorage('race-calculator-input', { +const input = useStorage<DistanceTime>('race-calculator-input', { distanceValue: 5, - distanceUnit: 'kilometers', + distanceUnit: DistanceUnits.Kilometers, time: 1200, }); /** * The default unit system */ -const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSystem()); +const defaultUnitSystem = useStorage<UnitSystems>('default-unit-system', detectDefaultUnitSystem()); /** * The race prediction options */ -const options = useStorage('race-calculator-options', { - model: 'AverageModel', +const options = useStorage<RaceOptions>('race-calculator-options', { + model: RacePredictionModel.AverageModel, riegelExponent: 1.06, }); /** * The current selected target set */ -const selectedTargetSet = useStorage('race-calculator-target-set', '_race_targets'); +const selectedTargetSet = useStorage<string>('race-calculator-target-set', '_race_targets'); /** * The target sets */ -let targetSets = useStorage('race-calculator-target-sets', { +const targetSets = useStorage<StandardTargetSets>('race-calculator-target-sets', { _race_targets: defaultTargetSets._race_targets }); diff --git a/src/views/SplitCalculator.vue b/src/views/SplitCalculator.vue @@ -11,8 +11,9 @@ <div class="target-set"> Target Set: - <target-set-selector v-model:selectedTargetSet="selectedTargetSet" setType="split" - v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/> + <target-set-selector v-model:selectedTargetSet="selectedTargetSet" + :set-type="TargetSetTypes.Split" v-model:targetSets="targetSets" + :default-unit-system="defaultUnitSystem"/> </div> </div> @@ -22,11 +23,13 @@ </div> </template> -<script setup> +<script setup lang="ts"> import { computed } from 'vue'; import { defaultTargetSets } from '@/utils/targets'; -import { detectDefaultUnitSystem } from '@/utils/units'; +import { TargetSetTypes } from '@/utils/targets'; +import type { SplitTargetSet, SplitTargetSets } from '@/utils/targets'; +import { UnitSystems, detectDefaultUnitSystem } from '@/utils/units'; import SplitOutputTable from '@/components/SplitOutputTable.vue'; import TargetSetSelector from '@/components/TargetSetSelector.vue'; @@ -36,18 +39,18 @@ import useStorage from '@/composables/useStorage'; /** * The default unit system */ -const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSystem()); +const defaultUnitSystem = useStorage<UnitSystems>('default-unit-system', detectDefaultUnitSystem()); /** * The current selected target set */ -const selectedTargetSet = useStorage('split-calculator-target-set', '_split_targets'); +const selectedTargetSet = useStorage<string>('split-calculator-target-set', '_split_targets'); /** * The default output targets */ -const targetSets = useStorage('split-calculator-target-sets', { - _split_targets: defaultTargetSets._split_targets +const targetSets = useStorage<SplitTargetSets>('split-calculator-target-sets', { + _split_targets: defaultTargetSets._split_targets as SplitTargetSet }); /** diff --git a/src/views/UnitCalculator.vue b/src/views/UnitCalculator.vue @@ -6,20 +6,20 @@ <option value="speed_and_pace">Speed &amp; Pace</option> </select> - <time-input v-if="getUnitType(input.inputUnit) === 'time'" class="input-value" + <time-input v-if="isTimeUnit(input.inputUnit)" class="input-value" label="Input time" v-model="input.inputValue"/> <decimal-input v-else class="input-value" aria-label="Input value" v-model="input.inputValue" :min="0" :digits="2"/> <select v-model="input.inputUnit" class="input-units" aria-label="Input units"> <option v-for="(value, key) in units" :key="key" :value="key"> - {{ value.name }} + {{ value?.name }} </option> </select> <span class="equals"> = </span> - <span v-if="getUnitType(input.outputUnit) === 'time'" class="output-value" aria-label="Output value"> + <span v-if="isTimeUnit(input.outputUnit)" class="output-value" aria-label="Output value"> {{ formatDuration(outputValue, 6, 3, true) }} </span> <span v-else class="output-value" aria-label="Output value"> @@ -28,18 +28,19 @@ <select v-model="input.outputUnit" class="output-units" aria-label="Output units"> <option v-for="(value, key) in units" :key="key" :value="key"> - {{ value.name }} + {{ value?.name }} </option> </select> </div> </template> -<script setup> +<script setup lang="ts"> import { computed, ref } from 'vue'; import { formatDuration, formatNumber } from '@/utils/format'; -import { DistanceUnitData, TimeUnitData, SpeedUnitData, PaceUnitData, convertDistance, convertTime, -convertSpeedPace } from '@/utils/units'; +import { DistanceUnits, DistanceUnitData, TimeUnits, TimeUnitData, PaceUnits, SpeedUnits, SpeedUnitData, + PaceUnitData, convertDistance, convertTime, convertSpeedPace } from '@/utils/units'; +import type { SpeedPaceUnits, UnitData } from '@/utils/units'; import DecimalInput from '@/components/DecimalInput.vue'; import TimeInput from '@/components/TimeInput.vue'; @@ -47,116 +48,166 @@ import TimeInput from '@/components/TimeInput.vue'; import useStorage from '@/composables/useStorage'; /** + * The supported time units: Hours, Minutes, Seconds, and 'hh:mm:ss' + */ +type ExtendedTimeUnits = TimeUnits | 'hh:mm:ss'; + +/** + * All supported distance, time, speed, and pace units + */ +type AllUnits = DistanceUnits | ExtendedTimeUnits | SpeedPaceUnits; + +/** + * The three categories of units + */ +enum UnitTypes { + Distance = 'distance', + Time = 'time', + SpeedPace = 'speed_and_pace', +} + +/** + * The type of the calculator inputs + */ +interface UnitCalculatorInputs { + [UnitTypes.Distance]: { + inputValue: number, + inputUnit: DistanceUnits, + outputUnit: DistanceUnits, + }, + [UnitTypes.Time]: { + inputValue: number, + inputUnit: ExtendedTimeUnits, + outputUnit: ExtendedTimeUnits, + }, + [UnitTypes.SpeedPace]: { + inputValue: number, + inputUnit: SpeedPaceUnits, + outputUnit: SpeedPaceUnits, + }, +}; + +/** * The calculator inputs */ -const inputs = useStorage('unit-calculator-inputs', { - distance: { +const inputs = useStorage<UnitCalculatorInputs>('unit-calculator-inputs', { + [UnitTypes.Distance]: { inputValue: 1, - inputUnit: 'miles', - outputUnit: 'kilometers', + inputUnit: DistanceUnits.Miles, + outputUnit: DistanceUnits.Kilometers, }, - time: { + [UnitTypes.Time]: { inputValue: 1, - inputUnit: 'seconds', + inputUnit: TimeUnits.Seconds, outputUnit: 'hh:mm:ss', }, - speed_and_pace: { + [UnitTypes.SpeedPace]: { inputValue: 600, - inputUnit: 'seconds_per_mile', - outputUnit: 'miles_per_hour', + inputUnit: PaceUnits.TimePerMile, + outputUnit: SpeedUnits.MilesPerHour, }, }); /** * The unit category */ -const category = ref('distance'); +const category = ref<UnitTypes>(UnitTypes.Distance); /** * The inputs for the current category */ -const input = computed({ +const input = computed<{ inputValue: number, inputUnit: AllUnits, outputUnit: AllUnits }>({ get() { return inputs.value[category.value]; }, set(newValue) { - inputs.value[category.value] = newValue; - }, + switch (category.value) { + default: + case UnitTypes.Distance: { + inputs.value[category.value] = { + inputValue: newValue.inputValue, + inputUnit: newValue.inputUnit as DistanceUnits, + outputUnit: newValue.outputUnit as DistanceUnits, + }; + break; + } + case UnitTypes.Time: { + inputs.value[category.value] = { + inputValue: newValue.inputValue, + inputUnit: newValue.inputUnit as ExtendedTimeUnits, + outputUnit: newValue.outputUnit as ExtendedTimeUnits, + }; + break; + } + case UnitTypes.SpeedPace: { + inputs.value[category.value] = { + inputValue: newValue.inputValue, + inputUnit: newValue.inputUnit as SpeedPaceUnits, + outputUnit: newValue.outputUnit as SpeedPaceUnits, + }; + break; + } + } + } }); /** * The names of the units in the current category */ -const units = computed(() => { +const units = computed<{ [key in AllUnits]?: UnitData }>(() => { switch (category.value) { - case 'distance': { + default: + case UnitTypes.Distance: { return DistanceUnitData; } - case 'time': { + case UnitTypes.Time: { return { ...TimeUnitData, 'hh:mm:ss': { name: 'hh:mm:ss', symbol: '', - value: null, + value: 1, }, }; } - case 'speed_and_pace': { + case UnitTypes.SpeedPace: { return { ...PaceUnitData, ...SpeedUnitData }; } - default: { - return {}; - } } }); /** * The output value */ -const outputValue = computed(() => { +const outputValue = computed<number>(() => { switch (category.value) { - case 'distance': { - return convertDistance(input.value.inputValue, input.value.inputUnit, - input.value.outputUnit); + default: + case UnitTypes.Distance: { + return convertDistance(input.value.inputValue, input.value.inputUnit as DistanceUnits, + input.value.outputUnit as DistanceUnits); } - case 'time': { + case UnitTypes.Time: { // Correct input and output units for 'hh:mm:ss' unit const realInput = input.value.inputUnit === 'hh:mm:ss' ? 'seconds' : input.value.inputUnit; const realOutput = input.value.outputUnit === 'hh:mm:ss' ? 'seconds' : input.value.outputUnit; // Calculate conversion - return convertTime(input.value.inputValue, realInput, realOutput); - } - case 'speed_and_pace': { - return convertSpeedPace(input.value.inputValue, input.value.inputUnit, - input.value.outputUnit); + return convertTime(input.value.inputValue, realInput as TimeUnits, realOutput as TimeUnits); } - default: { - return null; + case UnitTypes.SpeedPace: { + return convertSpeedPace(input.value.inputValue, input.value.inputUnit as SpeedPaceUnits, + input.value.outputUnit as SpeedPaceUnits); } } }); /** - * Get the type of a unit - * @param {String} unit The unit - * @returns {String} The type ('decimal' or 'time') + * Determine whether a unit should be represented as a time + * @param {AllUnits} unit The unit + * @returns {boolean} Whether the unit should be represented as a time */ -function getUnitType(unit) { - if (unit in DistanceUnitData) { - return 'decimal'; - } - if (unit in TimeUnitData) { - return 'decimal'; - } - if (unit === 'hh:mm:ss') { - return 'time'; - } - if (['seconds_per_kilometer', 'seconds_per_mile'].includes(unit)) { - return 'time'; - } - return 'decimal'; +function isTimeUnit(unit: AllUnits): boolean { + return unit in PaceUnitData || unit === 'hh:mm:ss'; } </script> diff --git a/src/views/WorkoutCalculator.vue b/src/views/WorkoutCalculator.vue @@ -18,9 +18,9 @@ </div> <div> Target Set: - <target-set-selector v-model:selectedTargetSet="selectedTargetSet" setType="workout" - :customWorkoutNames="options.customTargetNames" v-model:targetSets="targetSets" - :default-unit-system="defaultUnitSystem"/> + <target-set-selector v-model:selectedTargetSet="selectedTargetSet" + :set-type="TargetSetTypes.Workout" :customWorkoutNames="options.customTargetNames" + v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/> </div> <div> Target Name Customization: @@ -29,23 +29,27 @@ <option :value="true">Enabled</option> </select> </div> - <race-options v-model="options"/> + <race-options-input v-model="options"/> </details> <h2>Workout Splits</h2> <single-output-table class="output" - :calculate-result="x => calculateWorkoutResults(input, x, options, true)" + :calculate-result="x => calculateWorkoutResults(input, x as WorkoutTarget, options, true)" :targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []"/> </div> </template> -<script setup> +<script setup lang="ts"> import { calculateWorkoutResults } from '@/utils/calculators'; -import { defaultTargetSets } from '@/utils/targets'; -import { detectDefaultUnitSystem } from '@/utils/units'; +import type { WorkoutOptions } from '@/utils/calculators'; +import { RacePredictionModel } from '@/utils/races'; +import { TargetSetTypes, defaultTargetSets } from '@/utils/targets'; +import type { WorkoutTarget, WorkoutTargetSet, WorkoutTargetSets } from '@/utils/targets'; +import { DistanceUnits, UnitSystems, detectDefaultUnitSystem } from '@/utils/units'; +import type { DistanceTime } from '@/utils/units'; import PaceInput from '@/components/PaceInput.vue'; -import RaceOptions from '@/components/RaceOptions.vue'; +import RaceOptionsInput from '@/components/RaceOptionsInput.vue'; import SingleOutputTable from '@/components/SingleOutputTable.vue'; import TargetSetSelector from '@/components/TargetSetSelector.vue'; @@ -54,36 +58,36 @@ import useStorage from '@/composables/useStorage'; /** * The input race */ -const input = useStorage('workout-calculator-input', { +const input = useStorage<DistanceTime>('workout-calculator-input', { distanceValue: 5, - distanceUnit: 'kilometers', + distanceUnit: DistanceUnits.Kilometers, time: 1200, }); /** * The default unit system */ -const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSystem()); +const defaultUnitSystem = useStorage<UnitSystems>('default-unit-system', detectDefaultUnitSystem()); /** * The race prediction options */ -const options = useStorage('workout-calculator-options', { +const options = useStorage<WorkoutOptions>('workout-calculator-options', { customTargetNames: false, - model: 'AverageModel', + model: RacePredictionModel.AverageModel, riegelExponent: 1.06, }); /** * The current selected target set */ -const selectedTargetSet = useStorage('workout-calculator-target-set', '_workout_targets'); +const selectedTargetSet = useStorage<string>('workout-calculator-target-set', '_workout_targets'); /** * The target sets */ -let targetSets = useStorage('workout-calculator-target-sets', { - _workout_targets: defaultTargetSets._workout_targets +const targetSets = useStorage<WorkoutTargetSets>('workout-calculator-target-sets', { + _workout_targets: defaultTargetSets._workout_targets as WorkoutTargetSet }); </script> diff --git a/tests/unit/components/RaceOptions.spec.js b/tests/unit/components/RaceOptions.spec.js @@ -1,53 +0,0 @@ -import { test, expect } from 'vitest'; -import { shallowMount } from '@vue/test-utils'; -import RaceOptions from '@/components/RaceOptions.vue'; - -test('should be initialized to modelValue', () => { - // Initialize component - const wrapper = shallowMount(RaceOptions, { - propsData: { - modelValue: { - model: 'PurdyPointsModel', - riegelExponent: 1.2, - } - }, - }); - - // Assert input fields are correct - expect(wrapper.find('select').element.value).to.equal('PurdyPointsModel'); - expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1.2); -}); - -test('should emit event when inputs are modified', async () => { - // Initialize component - const wrapper = shallowMount(RaceOptions, { - propsData: { - modelValue: { - model: 'AverageModel', - riegelExponent: 1.06, - }, - }, - }); - - // Update model - await wrapper.find('select').setValue('CameronModel'); - expect(wrapper.emitted()['update:modelValue']).to.deep.equal([ - [{ - model: 'CameronModel', - riegelExponent: 1.06, - }], - ]); - - // Update Riegel exponent - await wrapper.findComponent({ name: 'decimal-input' }).setValue(1.3); - expect(wrapper.emitted()['update:modelValue']).to.deep.equal([ - [{ - model: 'CameronModel', - riegelExponent: 1.06, - }], - [{ - model: 'CameronModel', - riegelExponent: 1.3, - }], - ]); -}); diff --git a/tests/unit/components/RaceOptionsInput.spec.js b/tests/unit/components/RaceOptionsInput.spec.js @@ -0,0 +1,53 @@ +import { test, expect } from 'vitest'; +import { shallowMount } from '@vue/test-utils'; +import RaceOptionsInput from '@/components/RaceOptionsInput.vue'; + +test('should be initialized to modelValue', () => { + // Initialize component + const wrapper = shallowMount(RaceOptionsInput, { + propsData: { + modelValue: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + } + }, + }); + + // Assert input fields are correct + expect(wrapper.find('select').element.value).to.equal('PurdyPointsModel'); + expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1.2); +}); + +test('should emit event when inputs are modified', async () => { + // Initialize component + const wrapper = shallowMount(RaceOptionsInput, { + propsData: { + modelValue: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + }, + }); + + // Update model + await wrapper.find('select').setValue('CameronModel'); + expect(wrapper.emitted()['update:modelValue']).to.deep.equal([ + [{ + model: 'CameronModel', + riegelExponent: 1.06, + }], + ]); + + // Update Riegel exponent + await wrapper.findComponent({ name: 'decimal-input' }).setValue(1.3); + expect(wrapper.emitted()['update:modelValue']).to.deep.equal([ + [{ + model: 'CameronModel', + riegelExponent: 1.06, + }], + [{ + model: 'CameronModel', + riegelExponent: 1.3, + }], + ]); +}); diff --git a/tests/unit/views/BatchCalculator.spec.js b/tests/unit/views/BatchCalculator.spec.js @@ -298,14 +298,14 @@ test('should load advanced model options from localStorage', async () => { // Assert race prediction options are loaded await wrapper.find('select[aria-label="Calculator"]').setValue('race'); - expect(wrapper.findComponent({ name: 'RaceOptions' }).vm.modelValue).to.deep.equal({ + expect(wrapper.findComponent({ name: 'RaceOptionsInput' }).vm.modelValue).to.deep.equal({ model: 'PurdyPointsModel', riegelExponent: 1.2, }); // Assert workout prediction options are loaded await wrapper.find('select[aria-label="Calculator"]').setValue('workout'); - expect(wrapper.findComponent({ name: 'RaceOptions' }).vm.modelValue).to.deep.equal({ + expect(wrapper.findComponent({ name: 'RaceOptionsInput' }).vm.modelValue).to.deep.equal({ model: 'RiegelModel', riegelExponent: 1.1, }); diff --git a/tests/unit/views/RaceCalculator.spec.js b/tests/unit/views/RaceCalculator.spec.js @@ -135,7 +135,7 @@ test('should correctly calculate results according to advanced model options', a }); // Switch model - await wrapper.findComponent({ name: 'RaceOptions' }).setValue({ + await wrapper.findComponent({ name: 'RaceOptionsInput' }).setValue({ model: 'RiegelModel', riegelExponent: 1.06, // default value }); @@ -152,7 +152,7 @@ test('should correctly calculate results according to advanced model options', a expect(result.value).to.equal('41:41.92'); // Update Riegel Exponent - await wrapper.findComponent({ name: 'RaceOptions' }).setValue({ + await wrapper.findComponent({ name: 'RaceOptionsInput' }).setValue({ model: 'RiegelModel', // existing value riegelExponent: 1, }); @@ -275,7 +275,7 @@ test('should load advanced model options from localStorage', async () => { const wrapper = shallowMount(RaceCalculator); // Assert data loaded - expect(wrapper.findComponent({ name: 'RaceOptions' }).vm.modelValue).to.deep.equal({ + expect(wrapper.findComponent({ name: 'RaceOptionsInput' }).vm.modelValue).to.deep.equal({ model: 'PurdyPointsModel', riegelExponent: 1.2, }); @@ -286,7 +286,7 @@ test('should save advanced model options to localStorage when modified', async ( const wrapper = shallowMount(RaceCalculator); // Update advanced model options - await wrapper.findComponent({ name: 'RaceOptions' }).setValue({ + await wrapper.findComponent({ name: 'RaceOptionsInput' }).setValue({ model: 'CameronModel', riegelExponent: 1.30, }); diff --git a/tests/unit/views/WorkoutCalculator.spec.js b/tests/unit/views/WorkoutCalculator.spec.js @@ -65,7 +65,7 @@ test('should correctly calculate results according to advanced model options', a }); // Update model and Riegel Exponent - await wrapper.findComponent({ name: 'RaceOptions' }).setValue({ + await wrapper.findComponent({ name: 'RaceOptionsInput' }).setValue({ model: 'RiegelModel', riegelExponent: 1.10, }); @@ -215,7 +215,7 @@ test('should load advanced model options from localStorage', async () => { const wrapper = shallowMount(WorkoutCalculator); // Assert data loaded - expect(wrapper.findComponent({ name: 'RaceOptions' }).vm.modelValue).to.deep.equal({ + expect(wrapper.findComponent({ name: 'RaceOptionsInput' }).vm.modelValue).to.deep.equal({ model: 'PurdyPointsModel', riegelExponent: 1.2, }); @@ -226,7 +226,7 @@ test('should save advanced model options to localStorage when modified', async ( const wrapper = shallowMount(WorkoutCalculator); // Update advanced model options - await wrapper.findComponent({ name: 'RaceOptions' }).setValue({ + await wrapper.findComponent({ name: 'RaceOptionsInput' }).setValue({ model: 'CameronModel', riegelExponent: 1.30, }); diff --git a/tsconfig.app.json b/tsconfig.app.json @@ -1,11 +1,14 @@ { "extends": "@vue/tsconfig/tsconfig.dom.json", - "include": ["src/**/*", "src/**/*.vue"], + "include": ["src/**/*"], "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "resolveJsonModule": true, "paths": { - "@/*": ["./src/*"] - } + "@/*": ["./src/*"], + "/*": ["./*"] + }, + "types": ["node"] } }