running-tools

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

commit 033fc052c51ba689bd8e44ccf370f9262f951760
parent 4c16d9764e24b6f53bb2ff8afb813f75f884246c
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Sat, 12 Jul 2025 14:36:28 -0700

Rename src/utils/ to src/core/

In addition, combine misc and storage utils into src/core/utils.ts and
rename races.ts to racePrediction.ts.

Diffstat:
Msrc/components/AdvancedOptionsInput.vue | 8++++----
Msrc/components/DecimalInput.vue | 2+-
Msrc/components/DoubleOutputTable.vue | 10+++++-----
Msrc/components/PaceInput.vue | 4++--
Msrc/components/RaceOptionsInput.vue | 2+-
Msrc/components/SingleOutputTable.vue | 4++--
Msrc/components/SplitOutputTable.vue | 6+++---
Msrc/components/TargetEditor.vue | 8++++----
Msrc/components/TargetSetSelector.vue | 10+++++-----
Msrc/composables/useObjectModel.ts | 2+-
Msrc/composables/useStorage.ts | 8++++----
Asrc/core/calculators.ts | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/core/racePrediction.ts | 491+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/core/targets.ts | 211+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/core/units.ts | 396+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/core/utils.ts | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.ts | 4++--
Dsrc/utils/calculators.ts | 243-------------------------------------------------------------------------------
Dsrc/utils/misc.ts | 18------------------
Dsrc/utils/races.ts | 487-------------------------------------------------------------------------------
Dsrc/utils/storage.ts | 94-------------------------------------------------------------------------------
Dsrc/utils/targets.ts | 207-------------------------------------------------------------------------------
Dsrc/utils/units.ts | 392-------------------------------------------------------------------------------
Msrc/views/BatchCalculator.vue | 61+++++++++++++++++++++++++++++++------------------------------
Msrc/views/PaceCalculator.vue | 12++++++------
Msrc/views/RaceCalculator.vue | 12++++++------
Msrc/views/SplitCalculator.vue | 10+++++-----
Msrc/views/UnitCalculator.vue | 66+++++++++++++++++++++++++++++++++---------------------------------
Msrc/views/WorkoutCalculator.vue | 12++++++------
Atests/unit/core/calculators.spec.js | 255+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/core/racePrediction.spec.js | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/core/targets.spec.js | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/core/units.spec.js | 439+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/core/utils.spec.js | 267+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dtests/unit/utils/calculators.spec.js | 255-------------------------------------------------------------------------------
Dtests/unit/utils/misc.spec.js | 127-------------------------------------------------------------------------------
Dtests/unit/utils/races.spec.js | 166-------------------------------------------------------------------------------
Dtests/unit/utils/storage.spec.js | 142-------------------------------------------------------------------------------
Dtests/unit/utils/targets.spec.js | 59-----------------------------------------------------------
Dtests/unit/utils/units.spec.js | 439-------------------------------------------------------------------------------
Mtests/unit/views/PaceCalculator.spec.js | 2+-
Mtests/unit/views/RaceCalculator.spec.js | 2+-
Mtests/unit/views/WorkoutCalculator.spec.js | 2+-
43 files changed, 2771 insertions(+), 2752 deletions(-)

diff --git a/src/components/AdvancedOptionsInput.vue b/src/components/AdvancedOptionsInput.vue @@ -44,10 +44,10 @@ </template> <script setup lang="ts"> -import { Calculators } from '@/utils/calculators'; -import type { StandardOptions, RaceOptions, WorkoutOptions } from '@/utils/calculators'; -import type { TargetSets } from '@/utils/targets'; -import { UnitSystems } from '@/utils/units'; +import { Calculators } from '@/core/calculators'; +import type { StandardOptions, RaceOptions, WorkoutOptions } from '@/core/calculators'; +import type { TargetSets } from '@/core/targets'; +import { UnitSystems } from '@/core/units'; import DecimalInput from '@/components/DecimalInput.vue'; import TargetSetSelector from '@/components/TargetSetSelector.vue'; diff --git a/src/components/DecimalInput.vue b/src/components/DecimalInput.vue @@ -4,7 +4,7 @@ <script setup lang="ts"> import { ref, watch } from 'vue'; -import { formatNumber } from '@/utils/units'; +import { formatNumber } from '@/core/units'; /** * The component value diff --git a/src/components/DoubleOutputTable.vue b/src/components/DoubleOutputTable.vue @@ -29,11 +29,11 @@ <script setup lang="ts"> import { computed } from 'vue'; -import { ResultType } from '@/utils/calculators'; -import type { TargetResult } from '@/utils/calculators'; -import type { Target } from '@/utils/targets'; -import { formatDistance, formatDuration } from '@/utils/units'; -import type { Distance, DistanceTime } from '@/utils/units'; +import { ResultType } from '@/core/calculators'; +import type { TargetResult } from '@/core/calculators'; +import type { Target } from '@/core/targets'; +import { formatDistance, formatDuration } from '@/core/units'; +import type { Distance, DistanceTime } from '@/core/units'; interface Props { /** diff --git a/src/components/PaceInput.vue b/src/components/PaceInput.vue @@ -18,8 +18,8 @@ </template> <script setup lang="ts"> -import { DistanceUnits, DistanceUnitData } from '@/utils/units'; -import type { DistanceTime } from '@/utils/units'; +import { DistanceUnits, DistanceUnitData } from '@/core/units'; +import type { DistanceTime } from '@/core/units'; import DecimalInput from '@/components/DecimalInput.vue'; import TimeInput from '@/components/TimeInput.vue'; diff --git a/src/components/RaceOptionsInput.vue b/src/components/RaceOptionsInput.vue @@ -18,7 +18,7 @@ </template> <script setup lang="ts"> -import type { RaceOptions } from '@/utils/calculators'; +import type { RaceOptions } from '@/core/calculators'; import DecimalInput from '@/components/DecimalInput.vue'; import useObjectModel from '@/composables/useObjectModel'; diff --git a/src/components/SingleOutputTable.vue b/src/components/SingleOutputTable.vue @@ -39,8 +39,8 @@ <script setup lang="ts"> import { computed } from 'vue'; -import type { TargetResult } from '@/utils/calculators'; -import type { Target } from '@/utils/targets'; +import type { TargetResult } from '@/core/calculators'; +import type { Target } from '@/core/targets'; interface Props { /** diff --git a/src/components/SplitOutputTable.vue b/src/components/SplitOutputTable.vue @@ -46,10 +46,10 @@ <script setup lang="ts"> import { computed } from 'vue'; -import type { SplitTarget } from '@/utils/targets'; +import type { SplitTarget } from '@/core/targets'; import { DistanceUnits, UnitSystems, convertDistance, formatDistance, formatDuration, - formatPace, getDefaultPaceUnit } from '@/utils/units'; -import type { Distance, DistanceTime } from '@/utils/units'; + formatPace, getDefaultPaceUnit } from '@/core/units'; +import type { Distance, DistanceTime } from '@/core/units'; import TimeInput from '@/components/TimeInput.vue'; import useObjectModel from '@/composables/useObjectModel'; diff --git a/src/components/TargetEditor.vue b/src/components/TargetEditor.vue @@ -90,10 +90,10 @@ <script setup lang="ts"> import VueFeather from 'vue-feather'; -import { Calculators } from '@/utils/calculators'; -import { TargetTypes, workoutTargetToString } from '@/utils/targets'; -import type { StandardTargetSet, TargetSet, WorkoutTarget, WorkoutTargetSet } from '@/utils/targets'; -import { DistanceUnitData, UnitSystems, getDefaultDistanceUnit } from '@/utils/units'; +import { Calculators } from '@/core/calculators'; +import { TargetTypes, workoutTargetToString } from '@/core/targets'; +import type { StandardTargetSet, TargetSet, WorkoutTarget, WorkoutTargetSet } from '@/core/targets'; +import { DistanceUnitData, UnitSystems, getDefaultDistanceUnit } from '@/core/units'; import DecimalInput from '@/components/DecimalInput.vue'; import TimeInput from '@/components/TimeInput.vue'; diff --git a/src/components/TargetSetSelector.vue b/src/components/TargetSetSelector.vue @@ -25,11 +25,11 @@ import { computed, nextTick, ref } from 'vue'; import VueFeather from 'vue-feather'; -import { Calculators } from '@/utils/calculators'; -import { deepCopy } from '@/utils/misc'; -import { sort, defaultTargetSets } from '@/utils/targets'; -import type { TargetSet, TargetSets } from '@/utils/targets'; -import { UnitSystems } from '@/utils/units'; +import { Calculators } from '@/core/calculators'; +import { sort, defaultTargetSets } from '@/core/targets'; +import type { TargetSet, TargetSets } from '@/core/targets'; +import { deepCopy } from '@/core/utils'; +import { UnitSystems } from '@/core/units'; import TargetEditor from '@/components/TargetEditor.vue'; import useObjectModel from '@/composables/useObjectModel'; diff --git a/src/composables/useObjectModel.ts b/src/composables/useObjectModel.ts @@ -1,7 +1,7 @@ import { ref, watch } from 'vue'; import type { Ref } from 'vue'; -import { deepCopy, deepEqual } from '@/utils/misc'; +import { deepCopy, deepEqual } from '@/core/utils'; /* * Generate an internal ref that implements support for v-model with objects diff --git a/src/composables/useStorage.ts b/src/composables/useStorage.ts @@ -1,7 +1,7 @@ import { ref, onActivated, watchEffect } from 'vue'; import type { Ref } from 'vue'; -import * as storage from '@/utils/storage'; +import { deepCopy, getLocalStorage, setLocalStorage } from '@/core/utils'; /* * Create a reactive value that is synced with a localStorage item @@ -10,12 +10,12 @@ import * as storage from '@/utils/storage'; * @returns {Ref<Type>} The synchronized ref */ export default function useStorage<Type>(key: string, defaultValue: Type): Ref<Type> { - const clonedDefault: Type = JSON.parse(JSON.stringify(defaultValue)); + const clonedDefault: Type = deepCopy(defaultValue); const value: Ref<Type> = ref<Type>(clonedDefault) as Ref<Type>; // (Re)load value from localStorage function updateValue() { - const parsedValue = storage.get<Type>(key); + const parsedValue = getLocalStorage<Type>(key); if (parsedValue !== null) value.value = parsedValue; } updateValue(); @@ -24,7 +24,7 @@ export default function useStorage<Type>(key: string, defaultValue: Type): Ref<T // Save value to localStorage when modified watchEffect(() => { if (typeof localStorage !== 'undefined') { - storage.set<Type>(key, value.value); + setLocalStorage<Type>(key, value.value); } }) diff --git a/src/core/calculators.ts b/src/core/calculators.ts @@ -0,0 +1,247 @@ +/* + * Contains types and functions for core calculator functionality + */ + +import * as racePrediction from '@/core/racePrediction'; +import { TargetTypes, workoutTargetToString } from '@/core/targets'; +import type { StandardTarget, WorkoutTarget } from '@/core/targets'; +import { DistanceUnits, UnitSystems, convertDistance, formatDistance, formatDuration, formatPace, + getDefaultDistanceUnit, getDefaultPaceUnit } from '@/core/units'; +import type { DistanceTime } from '@/core/units'; + +/* + * The four main calculators (batch and unit calculators not included) + * + * Used to determine available options and target set format + */ +export enum Calculators { + Pace = 'pace', + Race = 'race', + Split = 'split', + Workout = 'workout', +} + +/* + * The type for the available race statistics + */ +export interface RaceStats { + purdyPoints: number, + vo2Max: number, + vo2: number, + vo2MaxPercentage: number, +}; + +/* + * The type for the options specific to each calculator + */ +export interface StandardOptions { + selectedTargetSet: string, +} +export interface RaceOptions extends StandardOptions { + model: racePrediction.RacePredictionModel, + riegelExponent: number, +}; +export interface WorkoutOptions extends RaceOptions { + customTargetNames: boolean, +}; +export interface BatchOptions { + calculator: Calculators.Pace | Calculators.Race | Calculators.Workout, + increment: number, + rows: number, +}; + +/* + * The two possible result fields of a target result: "key" and "value" + */ +export enum ResultType { + Key = 'key', + Value = 'value', +}; + +/* + * The type for target results + */ +export interface TargetResult { + key: string, + value: string, + pace: string, + result: ResultType, + sort: number, +}; + +/* + * The default input and options for each calculator + */ +export const defaultInput: DistanceTime = { + distanceValue: 5, + distanceUnit: DistanceUnits.Kilometers, + time: 1200, +}; +export const defaultBatchOptions: BatchOptions = { + calculator: Calculators.Workout, + increment: 15, + rows: 20, +}; +export const defaultPaceOptions: StandardOptions = { + selectedTargetSet: '_pace_targets', +}; +export const defaultRaceOptions: RaceOptions = { + model: racePrediction.RacePredictionModel.AverageModel, + riegelExponent: 1.06, + selectedTargetSet: '_race_targets', +}; +export const defaultSplitOptions: StandardOptions = { + selectedTargetSet: '_split_targets', +}; +export const defaultWorkoutOptions: WorkoutOptions = { + customTargetNames: false, + ...defaultRaceOptions, + selectedTargetSet: '_workout_targets', +}; + +/** + * Calculate results for a standard target + * @param {DistanceTime} input The input pace + * @param {StandardTarget} target The standard target + * @param {Function} calculateTime The function for calculating time results + * @param {Function} calculateDistance The function for calculating distance results + * @param {UnitSystems} defaultUnitSystem The default unit system (imperial or metric) + * @param {Boolean} preciseDurations Whether to return precise, unrounded, durations + * @returns {TargetResult} The result + */ +function calculateStandardResult(input: DistanceTime, target: StandardTarget, + calculateTime: (d1: number, t1: number, d2: number) => number, + calculateDistance: (t1: number, d1: number, t2: number) => number, defaultUnitSystem: UnitSystems, + preciseDurations: boolean = true): TargetResult { + + let distanceValue, distanceUnit, time; + const d1 = convertDistance(input.distanceValue, input.distanceUnit, DistanceUnits.Meters); + if (target.type === TargetTypes.Distance) { + // Add target distance to result + distanceValue = target.distanceValue; + distanceUnit = target.distanceUnit; + + // Calculate time result + const d2 = convertDistance(target.distanceValue, target.distanceUnit, DistanceUnits.Meters); + time = calculateTime(d1, input.time, d2); + } else { + // Add target time to result + time = target.time; + + // Calculate distance result + const d2 = calculateDistance(input.time, d1, target.time); + const units = getDefaultDistanceUnit(defaultUnitSystem); + distanceValue = convertDistance(d2, DistanceUnits.Meters, units); + distanceUnit = units; + } + + return { + // Convert distance to key string + key: formatDistance({ distanceValue, distanceUnit }, target.type === TargetTypes.Time), + + // Convert time to time string + value: formatDuration(time, 3, preciseDurations ? 2 : 0, target.type === TargetTypes.Distance), + + // Convert pace to pace string + pace: formatPace({ time, distanceValue, distanceUnit }, getDefaultPaceUnit(defaultUnitSystem)), + + // Convert dist/time result to key/value + result: target.type === TargetTypes.Distance ? ResultType.Value : ResultType.Key, + + // Use time (in seconds) as sort key + sort: time, + }; +} + +/** + * Calculate paces from a target + * @param {DistanceTime} input The input pace + * @param {StandardTarget} target The pace target + * @param {UnitSystems} defaultUnitSystem The default unit system (imperial or metric) + * @param {Boolean} preciseDurations Whether to return precise, unrounded, durations + * @returns {TargetResult} The result + */ +export function calculatePaceResults(input: DistanceTime, target: StandardTarget, + defaultUnitSystem: UnitSystems, + preciseDurations: boolean = true): TargetResult { + + return calculateStandardResult(input, target, (d1, t1, d2) => ((t1 / d1) * d2), + (t1, d1, t2) => ((d1 / t1) * t2), defaultUnitSystem, preciseDurations); +} + +/** + * Predict race results from a target + * @param {DistanceTime} input The input race + * @param {StandardTarget} target The race target + * @param {RaceOptions} options The race prediction options + * @param {UnitSystems} defaultUnitSystem The default unit system (imperial or metric) + * @param {Boolean} preciseDurations Whether to return precise, unrounded, durations + * @returns {TargetResult} The result + */ +export function calculateRaceResults(input: DistanceTime, target: StandardTarget, + options: RaceOptions, defaultUnitSystem: UnitSystems, + preciseDurations: boolean = true): TargetResult { + + return calculateStandardResult(input, target, + (d1, t1, d2) => racePrediction.predictTime(d1, t1, d2, options.model, options.riegelExponent), + (t1, d1, t2) => racePrediction.predictDistance(t1, d1, t2, options.model, options.riegelExponent), + defaultUnitSystem, preciseDurations); +} + +/** + * Calculate race statistics from an input race + * @param {DistanceTime} input The input race + * @returns {RaceStats} The race statistics + */ +export function calculateRaceStats(input: DistanceTime): RaceStats { + const d1 = convertDistance(input.distanceValue, input.distanceUnit, DistanceUnits.Meters); + + return { + purdyPoints: racePrediction.getPurdyPoints(d1, input.time), + vo2Max: racePrediction.getVO2Max(d1, input.time), + vo2: racePrediction.getVO2(d1, input.time), + vo2MaxPercentage: racePrediction.getVO2Percentage(input.time) * 100, + } +} + +/** + * Predict workout results from a target + * @param {DistanceTime} input The input race + * @param {WorkoutTarget} target The workout target + * @param {WorkoutOptions} options The workout options + * @param {Boolean} preciseDurations Whether to return precise, unrounded, durations + * @returns {TargetResult} The result + */ +export function calculateWorkoutResults(input: DistanceTime, target: WorkoutTarget, + options: WorkoutOptions, + preciseDurations: boolean = true): TargetResult { + // Initialize distance and time variables + const d1 = convertDistance(input.distanceValue, input.distanceUnit, DistanceUnits.Meters); + const t1 = input.time; + const d3 = convertDistance(target.splitValue, target.splitUnit, DistanceUnits.Meters); + let d2, t2; + + // Calculate result + if (target.type === 'distance') { + // Convert target distance into meters + d2 = convertDistance(target.distanceValue, target.distanceUnit, DistanceUnits.Meters); + + // Get workout split prediction + t2 = racePrediction.predictTime(d1, input.time, d2, options.model, options.riegelExponent); + } else { + t2 = target.time; + + // Get workout split prediction + d2 = racePrediction.predictDistance(t1, d1, t2, options.model, options.riegelExponent); + } + const t3 = (t2 / d2) * d3; + + // Return result + return { + key: (options.customTargetNames && target.customName) || workoutTargetToString(target), + value: formatDuration(t3, 3, preciseDurations ? 2 : 0, true), + pace: '', // Pace not used in workout calculator + result: ResultType.Value, + sort: t3, + } +} diff --git a/src/core/racePrediction.ts b/src/core/racePrediction.ts @@ -0,0 +1,491 @@ +/* + * Implements various race prediction models + */ + +/* + * The available race prediction models + */ +export enum RacePredictionModel { + AverageModel = 'AverageModel', + PurdyPointsModel = 'PurdyPointsModel', + VO2MaxModel = 'VO2MaxModel', + RiegelModel = 'RiegelModel', + CameronModel = 'CameronModel', +}; + +/* + * The type for internal variables used by the Purdy Points race prediction model + */ +interface PurdyPointsVariables { + twsec: number, + a: number, + b: number, +}; + +/** + * 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 + * @returns {number} The refined estimate + */ +function NewtonsMethod(initialEstimate: number, target: number, method: (x: number) => number, + derivative: (x: number) => number, precision: number): number { + // Initialize estimate + let estimate = initialEstimate; + let estimateValue; + + for (let i = 0; i < 500; i += 1) { + // Evaluate function at estimate + estimateValue = method(estimate); + + // Check if estimate is close enough (usually occurs way before i = 500) + if (Math.abs(target - estimateValue) < precision) { + break; + } + + // Refine estimate + estimate -= (estimateValue - target) / derivative(estimate); + } + + // Return refined estimate + return estimate; +} + +/* + * Methods that implement the Purdy Points race prediction model + * https://www.cs.uml.edu/~phoffman/xcinfo3.html + */ +const PurdyPointsModel = { + /** + * Calculate the Purdy Point variables for a distance + * @param {number} d The distance in meters + * @returns {PurdyPointsVariables} The Purdy Point variables + */ + getVariables(d: number): PurdyPointsVariables { + // 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 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: number, t: number): number { + // Get variables + const variables = PurdyPointsModel.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: number, t1: number, d2: number): number { + // Calculate Purdy Points for distance 1 + const points = PurdyPointsModel.getPurdyPoints(d1, t1); + + // Calculate time for distance 2 + const variables = PurdyPointsModel.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: number, t1: number, d2: number): number { + 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: number, d1: number, t2: number): number { + // Initialize estimate + let estimate = (d1 * t2) / t1; + + // Refine estimate (derivative on its own is too slow) + const method = (x: number) => PurdyPointsModel.predictTime(d1, t1, x); + const derivative = (x: number) => PurdyPointsModel.derivative(d1, t1, x) / 500; + 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 + * https://vdoto2.com/Calculator + */ +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: number, t: number): number { + const minutes = t / 60; + const v = d / minutes; + const result = -4.6 + (0.182258 * v) + (0.000104 * (v ** 2)); + return result; + }, + + /** + * 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: number): number { + const minutes = t / 60; + const result = 0.8 + (0.189439 * Math.exp(-0.012778 * minutes)) + (0.298956 * Math.exp(-0.193261 + * minutes)); + return result; + }, + + /** + * 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: number, t: number): number { + const result = VO2MaxModel.getVO2(d, t) / VO2MaxModel.getVO2Percentage(t); + return result; + }, + + /** + * 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: number, t: number): number { + 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: number, t1: number, d2: number): number { + // Calculate input VO2 max + const inputVO2Max = VO2MaxModel.getVO2Max(d1, t1); + + // Initialize estimate + let estimate = (t1 * d2) / d1; + + // Refine estimate + const method = (x: number) => VO2MaxModel.getVO2Max(d2, x); + const derivative = (x: number) => VO2MaxModel.VO2MaxTimeDerivative(d2, x); + estimate = NewtonsMethod(estimate, inputVO2Max, method, derivative, 0.0001); + + // Return estimate + return estimate; + }, + + /** + * 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: number, t: number): number { + 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: number, d1: number, t2: number): number { + // Calculate input VO2 max + const inputVO2 = VO2MaxModel.getVO2Max(d1, t1); + + // Initialize estimate + let estimate = (d1 * t2) / t1; + + // Refine estimate + const method = (x: number) => VO2MaxModel.getVO2Max(x, t2); + const derivative = (x: number) => VO2MaxModel.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 + */ +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: number, t1: number, d2: number): number { + 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; + }, + + /** + * 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: number, t1: number, d2: number): number { + 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: number, d1: number, t2: number): number { + // Initialize estimate + let estimate = (d1 * t2) / t1; + + // Refine estimate + const method = (x: number) => CameronModel.predictTime(d1, t1, x); + const derivative = (x: number) => CameronModel.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 + */ +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: number, t1: number, d2: number, c: number = 1.06): number { + return t1 * ((d2 / d1) ** c); + }, + + /** + * 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: number, d1: number, t2: number, c: number = 1.06) { + return d1 * ((t2 / t1) ** (1 / c)); + }, +}; + +/* + * Methods that average the results of different race prediction models + */ +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: number, t1: number, d2: number, c: number = 1.06): number { + 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: number, d1: number, t2: number, c: number = 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; + }, +}; + +/** + * Predict a race time + * @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 {string} model The race prediction model to use + * @param {number} c The value of the exponent in Pete Riegel's Model + */ +export function predictTime(d1: number, t1: number, d2: number, + model: RacePredictionModel = RacePredictionModel.AverageModel, + c: number = 1.06): number { + switch (model) { + default: + case RacePredictionModel.AverageModel: + return AverageModel.predictTime(d1, t1, d2, c); + case RacePredictionModel.PurdyPointsModel: + return PurdyPointsModel.predictTime(d1, t1, d2); + case RacePredictionModel.VO2MaxModel: + return VO2MaxModel.predictTime(d1, t1, d2); + case RacePredictionModel.RiegelModel: + return RiegelModel.predictTime(d1, t1, d2, c); + case RacePredictionModel.CameronModel: + return CameronModel.predictTime(d1, t1, d2); + } +} + +/** + * Predict a race distance + * @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 {string} model The race prediction model to use + * @param {number} c The value of the exponent in Pete Riegel's Model + */ +export function predictDistance(t1: number, d1: number, t2: number, + model: RacePredictionModel = RacePredictionModel.AverageModel, + c: number = 1.06) { + switch (model) { + default: + case RacePredictionModel.AverageModel: + return AverageModel.predictDistance(t1, d1, t2, c); + case RacePredictionModel.PurdyPointsModel: + return PurdyPointsModel.predictDistance(t1, d1, t2); + case RacePredictionModel.VO2MaxModel: + return VO2MaxModel.predictDistance(t1, d1, t2); + case RacePredictionModel.RiegelModel: + return RiegelModel.predictDistance(t1, d1, t2, c); + case RacePredictionModel.CameronModel: + return CameronModel.predictDistance(t1, d1, t2); + } +} + +export const getPurdyPoints = PurdyPointsModel.getPurdyPoints; +export const getVO2 = VO2MaxModel.getVO2; +export const getVO2Percentage = VO2MaxModel.getVO2Percentage; +export const getVO2Max = VO2MaxModel.getVO2Max; diff --git a/src/core/targets.ts b/src/core/targets.ts @@ -0,0 +1,211 @@ +/* + * Contains types and helper functions for calculator target sets + */ + +import { DistanceUnits, convertDistance, formatDistance, formatDuration } from '@/core/units'; +import type { Distance } from '@/core/units'; + +/* + * The two basic types of targets: those defined by distance and those defined by time + */ +export enum TargetTypes { + Distance = 'distance', + Time = 'time', +}; + +/* + * The types for basic standard targets and target sets used by the pace and race calculators + */ +interface DistanceTarget { + type: TargetTypes.Distance, + distanceValue: number, + distanceUnit: DistanceUnits, +}; +interface TimeTarget { + type: TargetTypes.Time, + time: number, +}; +export type StandardTarget = DistanceTarget | TimeTarget; +export interface StandardTargetSet { + name: string, + targets: Array<StandardTarget>, +}; +export interface StandardTargetSets { + [key: string]: StandardTargetSet, +}; + +/* + * The types for split calculator targets and target sets + */ +export type SplitTarget = DistanceTarget & { + splitTime?: number +}; +export interface SplitTargetSet { + name: string, + targets: Array<SplitTarget>, +}; +export interface SplitTargetSets { + [key: string]: SplitTargetSet, +}; + +/* + * The types for workout calculator targets and target sets + */ +export type WorkoutTarget = StandardTarget & { + splitValue: number, + splitUnit: DistanceUnits, + customName?: string, +}; +export interface WorkoutTargetSet { + name: string, + targets: Array<WorkoutTarget>, +}; +export interface WorkoutTargetSets { + [key: string]: WorkoutTargetSet, +}; + +/* + * The types for generic targets and target sets + */ +export type Target = StandardTarget | SplitTarget | WorkoutTarget; +export type TargetSet = StandardTargetSet | SplitTargetSet | WorkoutTargetSet; +export type TargetSets = StandardTargetSets | SplitTargetSets | WorkoutTargetSets; + +/** + * Sort an array of targets + * @param {Array<Target>} targets The array of targets + * @returns {Array<Target>} The sorted targets + */ +export function sort(targets: Array<Target>): Array<Target> { + return [ + ...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 === TargetTypes.Time) + .sort((a, b) => a.time - b.time), + ]; +} + +/** + * Generate a string description of a workout target + * @param {WorkoutTarget} target The workout target + * @return {string} The string description + */ +export function workoutTargetToString(target: WorkoutTarget): string { + let result = formatDistance({ distanceValue: target.splitValue, distanceUnit: target.splitUnit }, + false); + + if (target.type === TargetTypes.Time) { + result += ' @ ' + formatDuration(target.time, 3, 2, false); + } else if (target.distanceValue != target.splitValue || target.distanceUnit != target.splitUnit) { + result += ' @ ' + formatDistance(target as Distance, false); + } + return result; +} + +/* + * The default target sets for each calculator + */ +export const defaultTargetSets: { [key: string]: TargetSet } = { + '_pace_targets': { + name: 'Common Pace Targets', + targets: sort([ + { 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: 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: 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: TargetTypes.Distance, distanceValue: 0.5, distanceUnit: DistanceUnits.Marathons }, + { type: TargetTypes.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Marathons }, + + { type: TargetTypes.Time, time: 600 }, + { type: TargetTypes.Time, time: 1800 }, + { type: TargetTypes.Time, time: 3600 }, + ]), + }, '_race_targets': { + name: 'Common Race Targets', + targets: sort([ + { 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: 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: TargetTypes.Distance, distanceValue: 0.5, distanceUnit: DistanceUnits.Marathons }, + { type: TargetTypes.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Marathons }, + ]), + }, '_split_targets': { + name: '5K Mile Splits', + targets: [ + { type: TargetTypes.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Miles }, + { type: TargetTypes.Distance, distanceValue: 2, distanceUnit: DistanceUnits.Miles }, + { type: TargetTypes.Distance, distanceValue: 5, distanceUnit: DistanceUnits.Kilometers }, + ], + }, '_workout_targets': { + name: 'Common Workout Targets', + targets: [ + { + splitValue: 400, splitUnit: DistanceUnits.Meters, + type: TargetTypes.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Miles, + }, + { + splitValue: 800, splitUnit: DistanceUnits.Meters, + type: TargetTypes.Distance, distanceValue: 5, distanceUnit: DistanceUnits.Kilometers, + }, + { + splitValue: 1600, splitUnit: DistanceUnits.Meters, + type: TargetTypes.Time, time: 3600, + }, + { + splitValue: 1, splitUnit: DistanceUnits.Miles, + type: TargetTypes.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Marathons, + }, + ], + }, +}; +export const defaultPaceTargetSets: StandardTargetSets = { + '_pace_targets': defaultTargetSets._pace_targets, +}; +export const defaultRaceTargetSets: StandardTargetSets = { + '_race_targets': defaultTargetSets._race_targets, +}; +export const defaultSplitTargetSets: SplitTargetSets = { + '_split_targets': defaultTargetSets._split_targets as SplitTargetSet, +}; +export const defaultWorkoutTargetSets: WorkoutTargetSets = { + '_workout_targets': defaultTargetSets._workout_targets as WorkoutTargetSet, +}; diff --git a/src/core/units.ts b/src/core/units.ts @@ -0,0 +1,396 @@ +/* + * Implements handling of distance, pace, speed, and time units + */ + +/* + * The type for the data available for each unit + */ +export interface UnitData { + name: string, + symbol: string, + value: number, +}; + +/* + * The available time units + */ +export enum TimeUnits { + Seconds = 'seconds', + Minutes = 'minutes', + Hours = 'hours', +}; +export const TimeUnitData: { [key in TimeUnits]: UnitData } = { + [TimeUnits.Seconds]: { + name: 'Seconds', + symbol: 's', + value: 1, + }, + [TimeUnits.Minutes]: { + name: 'Minutes', + symbol: 'min', + value: 60, + }, + [TimeUnits.Hours]: { + name: 'Hours', + symbol: 'hr', + value: 3600, + }, +}; + +/* + * The available distance units + */ +export enum DistanceUnits { + Meters = 'meters', + Yards = 'yards', + Kilometers = 'kilometers', + Miles = 'miles', + Marathons = 'marathons', +}; +export const DistanceUnitData: { [key in DistanceUnits]: UnitData } = { + [DistanceUnits.Meters]: { + name: 'Meters', + symbol: 'm', + value: 1, + }, + [DistanceUnits.Yards]: { + name: 'Yards', + symbol: 'yd', + value: 0.9144, + }, + [DistanceUnits.Kilometers]: { + name: 'Kilometers', + symbol: 'km', + value: 1000, + }, + [DistanceUnits.Miles]: { + name: 'Miles', + symbol: 'mi', + value: 1609.344, + }, + [DistanceUnits.Marathons]: { + name: 'Marathons', + symbol: 'Mar', + value: 42195, + }, +}; + +/* + * The available speed units + */ +export enum SpeedUnits { + MetersPerSecond = 'meters_per_second', + KilometersPerHour = 'kilometers_per_hour', + MilesPerHour = 'miles_per_hour', +}; +export const SpeedUnitData: { [key in SpeedUnits]: UnitData } = { + [SpeedUnits.MetersPerSecond]: { + name: 'Meters per Second', + symbol: 'm/s', + value: 1, + }, + [SpeedUnits.KilometersPerHour]: { + name: 'Kilometers per Hour', + symbol: 'kph', + value: DistanceUnitData[DistanceUnits.Kilometers].value / TimeUnitData[TimeUnits.Hours].value, + }, + [SpeedUnits.MilesPerHour]: { + name: 'Miles per Hour', + symbol: 'mph', + value: DistanceUnitData[DistanceUnits.Miles].value / TimeUnitData[TimeUnits.Hours].value, + }, +}; + +/* + * The available pace units + */ +export enum PaceUnits { + SecondsPerMeter = 'seconds_per_meter', + TimePerKilometer = 'seconds_per_kilometer', + TimePerMile = 'seconds_per_mile', +}; +export const PaceUnitData: { [key in PaceUnits]: UnitData } = { + [PaceUnits.SecondsPerMeter]: { + name: 'Seconds per Meter', + symbol: 's/m', + value: 1, + }, + [PaceUnits.TimePerKilometer]: { + name: 'Time per Kilometer', + symbol: '/ km', + value: TimeUnitData[TimeUnits.Seconds].value / DistanceUnitData[DistanceUnits.Kilometers].value, + }, + [PaceUnits.TimePerMile]: { + name: 'Time per Mile', + symbol: '/ mi', + value: TimeUnitData[TimeUnits.Seconds].value / DistanceUnitData[DistanceUnits.Miles].value, + }, +}; + +/* + * The available speed and pace units + */ +export type SpeedPaceUnits = SpeedUnits | PaceUnits; + +/* + * The type for a distance input + */ +export interface Distance { + distanceValue: number, + distanceUnit: DistanceUnits, +}; + +/* + * The type for a distance/time input pair + */ +export interface DistanceTime extends Distance { + time: number, +}; + +/* + * The available unit systems + */ +export enum UnitSystems { + Metric = 'metric', + Imperial = 'imperial', +}; + +/** + * Convert between time units + * @param {number} inputValue The input value + * @param {string} inputUnit The unit of the input + * @param {string} outputUnit The unit of the output + * @returns {number} The output + */ +export function convertTime(inputValue: number, inputUnit: TimeUnits, + outputUnit: TimeUnits): number { + return (inputValue * TimeUnitData[inputUnit].value) / TimeUnitData[outputUnit].value; +} + +/** + * Convert between distance units + * @param {number} inputValue The input value + * @param {string} inputUnit The unit of the input + * @param {string} outputUnit The unit of the output + * @returns {number} The output + */ +export function convertDistance(inputValue: number, inputUnit: DistanceUnits, + outputUnit: DistanceUnits): number { + return (inputValue * DistanceUnitData[inputUnit].value) / DistanceUnitData[outputUnit].value; +} + +/** + * Convert between speed units + * @param {number} inputValue The input value + * @param {string} inputUnit The unit of the input + * @param {string} outputUnit The unit of the output + * @returns {number} The output + */ +export function convertSpeed(inputValue: number, inputUnit: SpeedUnits, + outputUnit: SpeedUnits): number { + return (inputValue * SpeedUnitData[inputUnit].value) / SpeedUnitData[outputUnit].value; +} + +/** + * Convert between pace units + * @param {number} inputValue The input value + * @param {string} inputUnit The unit of the input + * @param {string} outputUnit The unit of the output + * @returns {number} The output + */ +export function convertPace(inputValue: number, inputUnit: PaceUnits, + outputUnit: PaceUnits): number { + return (inputValue * PaceUnitData[inputUnit].value) / PaceUnitData[outputUnit].value; +} + +/** + * Convert between speed and/or pace units + * @param {number} inputValue The input value + * @param {string} inputUnit The unit of the input + * @param {string} outputUnit The unit of the output + * @returns {number} The output + */ +export function convertSpeedPace(inputValue: number, inputUnit: SpeedPaceUnits, + outputUnit: SpeedPaceUnits): number { + // Calculate input speed + let speed; + if (inputUnit in PaceUnitData) { + speed = 1 / (inputValue * PaceUnitData[inputUnit as PaceUnits].value); + } else { + speed = inputValue * SpeedUnitData[inputUnit as SpeedUnits].value; + } + + // Calculate output + if (outputUnit in PaceUnitData) { + return (1 / speed) / PaceUnitData[outputUnit as PaceUnits].value; + } + return speed / SpeedUnitData[outputUnit as SpeedUnits].value; +} + +/** + * Detect the user's default unit system + * @returns {UnitSystems} The default unit system + */ +export function detectDefaultUnitSystem(): UnitSystems { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const language = (navigator.language || (navigator as any).userLanguage).toLowerCase(); + if (language.endsWith('-us') || language.endsWith('-mm')) { + return UnitSystems.Imperial; + } + return UnitSystems.Metric; +} + +/** + * Format a number as a string + * @param {number} value The number + * @param {number} minPadding The minimum number of digits to show before the decimal point + * @param {number} maxDigits The maximum number of digits to show after the decimal point + * @param {boolean} extraDigits Whether to show extra zeros after the decimal point + * @returns {string} The formatted number + */ +export function formatNumber(value: number, minPadding: number = 0, maxDigits: number = 2, + extraDigits: boolean = true): string { + + // Initialize result + let result = ''; + + // Remove sign + const negative = value < 0; + const fixedValue = Math.abs(value); + + // Address edge cases + if (Number.isNaN(fixedValue)) { + return 'NaN'; + } + if (fixedValue === Infinity) { + return negative ? '-Infinity' : 'Infinity'; + } + + // Convert number to string + if (extraDigits) { + result = fixedValue.toFixed(maxDigits); + } else { + const power = 10 ** maxDigits; + result = (Math.round((fixedValue + Number.EPSILON) * power) / power).toString(); + } + + // Add padding + const currentPadding = result.split('.')[0].length; + result = result.padStart(result.length - currentPadding + minPadding, '0'); + + // Add negative sign + if (negative) { + result = `-${result}`; + } + + // Return result + return result; +} + +/** + * Format a distance as a string + * @param {Distance} input The distance + * @param {boolean} extraDigits Whether to show extra zeros after the decimal point + * @returns {string} The formatted distance + */ +export function formatDistance(input: Distance, extraDigits: boolean) { + return formatNumber(input.distanceValue, 0, 2, extraDigits) + ' ' + + DistanceUnitData[input.distanceUnit].symbol; +} + +/** + * Format a duration as a string + * @param {number} value The duration (in seconds) + * @param {number} minPadding The minimum number of digits to show before the decimal point + * @param {number} maxDigits The maximum number of digits to show after the decimal point + * @param {boolean} extraDigits Whether to show extra zeros after the decimal point + * @returns {string} The formatted duration + */ +export function formatDuration(value: number, minPadding: number = 6, maxDigits: number = 2, + extraDigits: boolean = true): string { + // Check if value is NaN + if (Number.isNaN(value)) { + return 'NaN'; + } + + // Initialize result + let result = ''; + + // Check value sign + if (value < 0) { + result += '-'; + } + + // Check if value is valid + if (Math.abs(value) === Infinity) { + return `${result}Infinity`; + } + + // Validate padding + let fixedPadding = Math.min(minPadding, 6); + + // Prevent rounding errors + const fixedValue = parseFloat(Math.abs(value).toFixed(maxDigits)); + + // Calculate parts + const hours = Math.floor(fixedValue / 3600); + const minutes = Math.floor((fixedValue % 3600) / 60); + const seconds = fixedValue % 60; + + // Format parts + if (hours !== 0 || fixedPadding >= 5) { + result += hours.toString().padStart(fixedPadding - 4, '0'); + result += ':'; + fixedPadding = 4; + } + if (minutes !== 0 || fixedPadding >= 3) { + result += minutes.toString().padStart(fixedPadding - 2, '0'); + result += ':'; + fixedPadding = 2; + } + result += formatNumber(seconds, fixedPadding, maxDigits, extraDigits); + + // Return result + return result; +} + +/** + * Calculate the pace of a distance/time pair and format it as a string + * @param {DistanceTime} input The input distance/time pair + * @param {PaceUnits} unit The desired pace unit + * @returns {string} The formatted pace + */ +export function formatPace(input: DistanceTime, unit: PaceUnits) { + const dist = convertDistance(input.distanceValue, input.distanceUnit, DistanceUnits.Meters); + const pace = convertPace(input.time / dist, PaceUnits.SecondsPerMeter, unit) + const result = formatDuration(pace, 3, 0, true) + ' ' + PaceUnitData[unit].symbol; + return result; +} + +/** + * Get the default distance unit in a unit system + * @param {UnitSystems} unitSystem The unit system + * @returns {DistanceUnits} The default distance unit + */ +export function getDefaultDistanceUnit(unitSystem: UnitSystems): DistanceUnits { + return unitSystem === UnitSystems.Metric ? DistanceUnits.Kilometers : DistanceUnits.Miles; +} + +/** + * Get the default speed unit in a unit system + * @param {UnitSystems} unitSystem The unit system + * @returns {SpeedUnits} The default speed unit + */ +export function getDefaultSpeedUnit(unitSystem: UnitSystems): SpeedUnits { + return unitSystem === UnitSystems.Metric ? SpeedUnits.KilometersPerHour + : SpeedUnits.MilesPerHour; +} + +/** + * Get the default pace unit in a unit system + * @param {UnitSystems} unitSystem The unit system + * @returns {PaceUnits} The default pace unit + */ +export function getDefaultPaceUnit(unitSystem: UnitSystems): PaceUnits { + return unitSystem === UnitSystems.Metric ? PaceUnits.TimePerKilometer : PaceUnits.TimePerMile; +} diff --git a/src/core/utils.ts b/src/core/utils.ts @@ -0,0 +1,116 @@ +/* + * Contains utility functions for handling nested objects and interacting with localStorage + */ + +import { defaultRaceOptions, defaultWorkoutOptions } from '@/core/calculators'; + +// The global localStorage prefix +const LocalStoragePrefix = 'running-tools'; + +/** + * Create a deep copy of an object + * @param {Type} value The object to copy + * @returns {Type} The copied object + */ +export function deepCopy<Type>(value: Type): Type { + return JSON.parse(JSON.stringify(value)); +} + +/** + * Test whether two objects are deeply equal + * @param {Type} value1 The first object + * @param {Type} value2 The second object + * @returns {boolean} Whether the two objects are equal + */ +export function deepEqual<Type>(value1: Type, value2: Type): boolean { + return JSON.stringify(value1) === JSON.stringify(value2); +} + +/** + * Read an object from a localStorage item + * @param {string} key The localStorage item's key + * @returns {Type} The object + */ +export function getLocalStorage<Type>(key: string): Type | null { + try { + return JSON.parse(localStorage.getItem(`${LocalStoragePrefix}.${key}`) || ''); + } catch { + return null; + } +} + +/** + * Write an object to a localStorage item + * @param {string} key The localStorage item's key + * @param {Type} value The object to write + */ +export function setLocalStorage<Type>(key: string, value: Type) { + localStorage.setItem(`${LocalStoragePrefix}.${key}`, JSON.stringify(value)); +} + +/** + * Delete a localStorage item + * @param {string} key The localStorage item's key + */ +export function unsetLocalStorage(key: string) { + localStorage.removeItem(`${LocalStoragePrefix}.${key}`); +} + +/** + * Migrate outdated localStorage options + */ +export function migrateLocalStorage() { + /* eslint-disable @typescript-eslint/no-explicit-any */ + + // Move pace-calculator-target-set into new pace-calculator-options (>1.4.1) + const paceSelectedTargetSet = getLocalStorage<string>('pace-calculator-target-set'); + if (paceSelectedTargetSet !== null) { + const paceOptions = { selectedTargetSet: paceSelectedTargetSet }; + setLocalStorage('pace-calculator-options', paceOptions); + unsetLocalStorage('pace-calculator-target-set'); + } + + // Move race-calculator-target-set into race-calculator-options (>1.4.1) + const raceSelectedTargetSet = getLocalStorage<string>('race-calculator-target-set'); + const raceOptions = getLocalStorage<any>('race-calculator-options') + || deepCopy(defaultRaceOptions); + if (raceSelectedTargetSet !== null) { + raceOptions.selectedTargetSet = raceSelectedTargetSet; + setLocalStorage('race-calculator-options', raceOptions); + unsetLocalStorage('race-calculator-target-set'); + } + if (raceOptions !== null && raceOptions.selectedTargetSet === undefined) { + raceOptions.selectedTargetSet = defaultRaceOptions.selectedTargetSet; + setLocalStorage('race-calculator-options', raceOptions); + } + + // Move split-calculator-target-set into new split-calculator-options (>1.4.1) + const splitSelectedTargetSet = getLocalStorage<string>('split-calculator-target-set'); + if (splitSelectedTargetSet !== null) { + const splitOptions = { selectedTargetSet: splitSelectedTargetSet }; + setLocalStorage('split-calculator-options', splitOptions); + unsetLocalStorage('split-calculator-target-set'); + } + + // Move workout-calculator-target-set into workout-calculator-options (>1.4.1) + const workoutSelectedTargetSet = getLocalStorage<string>('workout-calculator-target-set'); + const workoutOptions = getLocalStorage<any>('workout-calculator-options') + || deepCopy(defaultWorkoutOptions); + if (workoutSelectedTargetSet !== null) { + workoutOptions.selectedTargetSet = workoutSelectedTargetSet; + setLocalStorage('workout-calculator-options', workoutOptions); + unsetLocalStorage('workout-calculator-target-set'); + } + if (workoutOptions !== null && workoutOptions.selectedTargetSet === undefined) { + workoutOptions.selectedTargetSet = defaultWorkoutOptions.selectedTargetSet; + setLocalStorage('workout-calculator-options', workoutOptions); + } + + // Add customTargetNames property to workout-calculator-options (>1.4.1) + if (workoutOptions.customTargetNames === undefined) { + workoutOptions.customTargetNames = false; + setLocalStorage('workout-calculator-options', workoutOptions); + } + + /* eslint-enable @typescript-eslint/no-explicit-any */ +} diff --git a/src/main.ts b/src/main.ts @@ -2,11 +2,11 @@ import { createApp } from 'vue'; import App from '@/App.vue'; import router from '@/router'; -import * as storage from '@/utils/storage'; +import { migrateLocalStorage } from '@/core/utils'; import '@/assets/global.css'; -storage.migrate(); +migrateLocalStorage(); const app = createApp(App); app.use(router); diff --git a/src/utils/calculators.ts b/src/utils/calculators.ts @@ -1,243 +0,0 @@ -import * as raceUtils from '@/utils/races'; -import { TargetTypes, workoutTargetToString } from '@/utils/targets'; -import type { StandardTarget, WorkoutTarget } from '@/utils/targets'; -import { DistanceUnits, UnitSystems, convertDistance, formatDistance, formatDuration, formatPace, - getDefaultDistanceUnit, getDefaultPaceUnit } from '@/utils/units'; -import type { DistanceTime } from '@/utils/units'; - -/* - * The four main calculators (batch and unit calculators not included) - * - * Used to determine available options and target set format - */ -export enum Calculators { - Pace = 'pace', - Race = 'race', - Split = 'split', - Workout = 'workout', -} - -/* - * The type for the available race statistics - */ -export interface RaceStats { - purdyPoints: number, - vo2Max: number, - vo2: number, - vo2MaxPercentage: number, -}; - -/* - * The type for the options specific to each calculator - */ -export interface StandardOptions { - selectedTargetSet: string, -} -export interface RaceOptions extends StandardOptions { - model: raceUtils.RacePredictionModel, - riegelExponent: number, -}; -export interface WorkoutOptions extends RaceOptions { - customTargetNames: boolean, -}; -export interface BatchOptions { - calculator: Calculators.Pace | Calculators.Race | Calculators.Workout, - increment: number, - rows: number, -}; - -/* - * The two possible result fields of a target result: "key" and "value" - */ -export enum ResultType { - Key = 'key', - Value = 'value', -}; - -/* - * The type for target results - */ -export interface TargetResult { - key: string, - value: string, - pace: string, - result: ResultType, - sort: number, -}; - -/* - * The default input and options for each calculator - */ -export const defaultInput: DistanceTime = { - distanceValue: 5, - distanceUnit: DistanceUnits.Kilometers, - time: 1200, -}; -export const defaultBatchOptions: BatchOptions = { - calculator: Calculators.Workout, - increment: 15, - rows: 20, -}; -export const defaultPaceOptions: StandardOptions = { - selectedTargetSet: '_pace_targets', -}; -export const defaultRaceOptions: RaceOptions = { - model: raceUtils.RacePredictionModel.AverageModel, - riegelExponent: 1.06, - selectedTargetSet: '_race_targets', -}; -export const defaultSplitOptions: StandardOptions = { - selectedTargetSet: '_split_targets', -}; -export const defaultWorkoutOptions: WorkoutOptions = { - customTargetNames: false, - ...defaultRaceOptions, - selectedTargetSet: '_workout_targets', -}; - -/** - * Calculate results for a standard target - * @param {DistanceTime} input The input pace - * @param {StandardTarget} target The standard target - * @param {Function} calculateTime The function for calculating time results - * @param {Function} calculateDistance The function for calculating distance results - * @param {UnitSystems} defaultUnitSystem The default unit system (imperial or metric) - * @param {Boolean} preciseDurations Whether to return precise, unrounded, durations - * @returns {TargetResult} The result - */ -function calculateStandardResult(input: DistanceTime, target: StandardTarget, - calculateTime: (d1: number, t1: number, d2: number) => number, - calculateDistance: (t1: number, d1: number, t2: number) => number, defaultUnitSystem: UnitSystems, - preciseDurations: boolean = true): TargetResult { - - let distanceValue, distanceUnit, time; - const d1 = convertDistance(input.distanceValue, input.distanceUnit, DistanceUnits.Meters); - if (target.type === TargetTypes.Distance) { - // Add target distance to result - distanceValue = target.distanceValue; - distanceUnit = target.distanceUnit; - - // Calculate time result - const d2 = convertDistance(target.distanceValue, target.distanceUnit, DistanceUnits.Meters); - time = calculateTime(d1, input.time, d2); - } else { - // Add target time to result - time = target.time; - - // Calculate distance result - const d2 = calculateDistance(input.time, d1, target.time); - const units = getDefaultDistanceUnit(defaultUnitSystem); - distanceValue = convertDistance(d2, DistanceUnits.Meters, units); - distanceUnit = units; - } - - return { - // Convert distance to key string - key: formatDistance({ distanceValue, distanceUnit }, target.type === TargetTypes.Time), - - // Convert time to time string - value: formatDuration(time, 3, preciseDurations ? 2 : 0, target.type === TargetTypes.Distance), - - // Convert pace to pace string - pace: formatPace({ time, distanceValue, distanceUnit }, getDefaultPaceUnit(defaultUnitSystem)), - - // Convert dist/time result to key/value - result: target.type === TargetTypes.Distance ? ResultType.Value : ResultType.Key, - - // Use time (in seconds) as sort key - sort: time, - }; -} - -/** - * Calculate paces from a target - * @param {DistanceTime} input The input pace - * @param {StandardTarget} target The pace target - * @param {UnitSystems} defaultUnitSystem The default unit system (imperial or metric) - * @param {Boolean} preciseDurations Whether to return precise, unrounded, durations - * @returns {TargetResult} The result - */ -export function calculatePaceResults(input: DistanceTime, target: StandardTarget, - defaultUnitSystem: UnitSystems, - preciseDurations: boolean = true): TargetResult { - - return calculateStandardResult(input, target, (d1, t1, d2) => ((t1 / d1) * d2), - (t1, d1, t2) => ((d1 / t1) * t2), defaultUnitSystem, preciseDurations); -} - -/** - * Predict race results from a target - * @param {DistanceTime} input The input race - * @param {StandardTarget} target The race target - * @param {RaceOptions} options The race prediction options - * @param {UnitSystems} defaultUnitSystem The default unit system (imperial or metric) - * @param {Boolean} preciseDurations Whether to return precise, unrounded, durations - * @returns {TargetResult} The result - */ -export function calculateRaceResults(input: DistanceTime, target: StandardTarget, - options: RaceOptions, defaultUnitSystem: UnitSystems, - preciseDurations: boolean = true): TargetResult { - - return calculateStandardResult(input, target, - (d1, t1, d2) => raceUtils.predictTime(d1, t1, d2, options.model, options.riegelExponent), - (t1, d1, t2) => raceUtils.predictDistance(t1, d1, t2, options.model, options.riegelExponent), - defaultUnitSystem, preciseDurations); -} - -/** - * Calculate race statistics from an input race - * @param {DistanceTime} input The input race - * @returns {RaceStats} The race statistics - */ -export function calculateRaceStats(input: DistanceTime): RaceStats { - const d1 = convertDistance(input.distanceValue, input.distanceUnit, DistanceUnits.Meters); - - return { - purdyPoints: raceUtils.getPurdyPoints(d1, input.time), - vo2Max: raceUtils.getVO2Max(d1, input.time), - vo2: raceUtils.getVO2(d1, input.time), - vo2MaxPercentage: raceUtils.getVO2Percentage(input.time) * 100, - } -} - -/** - * Predict workout results from a target - * @param {DistanceTime} input The input race - * @param {WorkoutTarget} target The workout target - * @param {WorkoutOptions} options The workout options - * @param {Boolean} preciseDurations Whether to return precise, unrounded, durations - * @returns {TargetResult} The result - */ -export function calculateWorkoutResults(input: DistanceTime, target: WorkoutTarget, - options: WorkoutOptions, - preciseDurations: boolean = true): TargetResult { - // Initialize distance and time variables - const d1 = convertDistance(input.distanceValue, input.distanceUnit, DistanceUnits.Meters); - const t1 = input.time; - const d3 = convertDistance(target.splitValue, target.splitUnit, DistanceUnits.Meters); - let d2, t2; - - // Calculate result - if (target.type === 'distance') { - // Convert target distance into meters - d2 = convertDistance(target.distanceValue, target.distanceUnit, DistanceUnits.Meters); - - // Get workout split prediction - t2 = raceUtils.predictTime(d1, input.time, d2, options.model, options.riegelExponent); - } else { - t2 = target.time; - - // Get workout split prediction - d2 = raceUtils.predictDistance(t1, d1, t2, options.model, options.riegelExponent); - } - const t3 = (t2 / d2) * d3; - - // Return result - return { - key: (options.customTargetNames && target.customName) || workoutTargetToString(target), - value: formatDuration(t3, 3, preciseDurations ? 2 : 0, true), - pace: '', // Pace not used in workout calculator - result: ResultType.Value, - sort: t3, - } -} diff --git a/src/utils/misc.ts b/src/utils/misc.ts @@ -1,18 +0,0 @@ -/** - * Create a deep copy of an object - * @param {Type} value The object to copy - * @returns {Type} The copied object - */ -export function deepCopy<Type>(value: Type): Type { - return JSON.parse(JSON.stringify(value)); -} - -/** - * Test whether two objects are deeply equal - * @param {Type} value1 The first object - * @param {Type} value2 The second object - * @returns {boolean} Whether the two objects are equal - */ -export function deepEqual<Type>(value1: Type, value2: Type): boolean { - return JSON.stringify(value1) === JSON.stringify(value2); -} diff --git a/src/utils/races.ts b/src/utils/races.ts @@ -1,487 +0,0 @@ -/* - * The available race prediction models - */ -export enum RacePredictionModel { - AverageModel = 'AverageModel', - PurdyPointsModel = 'PurdyPointsModel', - VO2MaxModel = 'VO2MaxModel', - RiegelModel = 'RiegelModel', - CameronModel = 'CameronModel', -}; - -/* - * The type for internal variables used by the Purdy Points race prediction model - */ -interface PurdyPointsVariables { - twsec: number, - a: number, - b: number, -}; - -/** - * 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 - * @returns {number} The refined estimate - */ -function NewtonsMethod(initialEstimate: number, target: number, method: (x: number) => number, - derivative: (x: number) => number, precision: number): number { - // Initialize estimate - let estimate = initialEstimate; - let estimateValue; - - for (let i = 0; i < 500; i += 1) { - // Evaluate function at estimate - estimateValue = method(estimate); - - // Check if estimate is close enough (usually occurs way before i = 500) - if (Math.abs(target - estimateValue) < precision) { - break; - } - - // Refine estimate - estimate -= (estimateValue - target) / derivative(estimate); - } - - // Return refined estimate - return estimate; -} - -/* - * Methods that implement the Purdy Points race prediction model - * https://www.cs.uml.edu/~phoffman/xcinfo3.html - */ -const PurdyPointsModel = { - /** - * Calculate the Purdy Point variables for a distance - * @param {number} d The distance in meters - * @returns {PurdyPointsVariables} The Purdy Point variables - */ - getVariables(d: number): PurdyPointsVariables { - // 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 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: number, t: number): number { - // Get variables - const variables = PurdyPointsModel.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: number, t1: number, d2: number): number { - // Calculate Purdy Points for distance 1 - const points = PurdyPointsModel.getPurdyPoints(d1, t1); - - // Calculate time for distance 2 - const variables = PurdyPointsModel.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: number, t1: number, d2: number): number { - 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: number, d1: number, t2: number): number { - // Initialize estimate - let estimate = (d1 * t2) / t1; - - // Refine estimate (derivative on its own is too slow) - const method = (x: number) => PurdyPointsModel.predictTime(d1, t1, x); - const derivative = (x: number) => PurdyPointsModel.derivative(d1, t1, x) / 500; - 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 - * https://vdoto2.com/Calculator - */ -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: number, t: number): number { - const minutes = t / 60; - const v = d / minutes; - const result = -4.6 + (0.182258 * v) + (0.000104 * (v ** 2)); - return result; - }, - - /** - * 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: number): number { - const minutes = t / 60; - const result = 0.8 + (0.189439 * Math.exp(-0.012778 * minutes)) + (0.298956 * Math.exp(-0.193261 - * minutes)); - return result; - }, - - /** - * 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: number, t: number): number { - const result = VO2MaxModel.getVO2(d, t) / VO2MaxModel.getVO2Percentage(t); - return result; - }, - - /** - * 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: number, t: number): number { - 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: number, t1: number, d2: number): number { - // Calculate input VO2 max - const inputVO2Max = VO2MaxModel.getVO2Max(d1, t1); - - // Initialize estimate - let estimate = (t1 * d2) / d1; - - // Refine estimate - const method = (x: number) => VO2MaxModel.getVO2Max(d2, x); - const derivative = (x: number) => VO2MaxModel.VO2MaxTimeDerivative(d2, x); - estimate = NewtonsMethod(estimate, inputVO2Max, method, derivative, 0.0001); - - // Return estimate - return estimate; - }, - - /** - * 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: number, t: number): number { - 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: number, d1: number, t2: number): number { - // Calculate input VO2 max - const inputVO2 = VO2MaxModel.getVO2Max(d1, t1); - - // Initialize estimate - let estimate = (d1 * t2) / t1; - - // Refine estimate - const method = (x: number) => VO2MaxModel.getVO2Max(x, t2); - const derivative = (x: number) => VO2MaxModel.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 - */ -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: number, t1: number, d2: number): number { - 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; - }, - - /** - * 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: number, t1: number, d2: number): number { - 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: number, d1: number, t2: number): number { - // Initialize estimate - let estimate = (d1 * t2) / t1; - - // Refine estimate - const method = (x: number) => CameronModel.predictTime(d1, t1, x); - const derivative = (x: number) => CameronModel.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 - */ -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: number, t1: number, d2: number, c: number = 1.06): number { - return t1 * ((d2 / d1) ** c); - }, - - /** - * 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: number, d1: number, t2: number, c: number = 1.06) { - return d1 * ((t2 / t1) ** (1 / c)); - }, -}; - -/* - * Methods that average the results of different race prediction models - */ -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: number, t1: number, d2: number, c: number = 1.06): number { - 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: number, d1: number, t2: number, c: number = 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; - }, -}; - -/** - * Predict a race time - * @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 {string} model The race prediction model to use - * @param {number} c The value of the exponent in Pete Riegel's Model - */ -export function predictTime(d1: number, t1: number, d2: number, - model: RacePredictionModel = RacePredictionModel.AverageModel, - c: number = 1.06): number { - switch (model) { - default: - case RacePredictionModel.AverageModel: - return AverageModel.predictTime(d1, t1, d2, c); - case RacePredictionModel.PurdyPointsModel: - return PurdyPointsModel.predictTime(d1, t1, d2); - case RacePredictionModel.VO2MaxModel: - return VO2MaxModel.predictTime(d1, t1, d2); - case RacePredictionModel.RiegelModel: - return RiegelModel.predictTime(d1, t1, d2, c); - case RacePredictionModel.CameronModel: - return CameronModel.predictTime(d1, t1, d2); - } -} - -/** - * Predict a race distance - * @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 {string} model The race prediction model to use - * @param {number} c The value of the exponent in Pete Riegel's Model - */ -export function predictDistance(t1: number, d1: number, t2: number, - model: RacePredictionModel = RacePredictionModel.AverageModel, - c: number = 1.06) { - switch (model) { - default: - case RacePredictionModel.AverageModel: - return AverageModel.predictDistance(t1, d1, t2, c); - case RacePredictionModel.PurdyPointsModel: - return PurdyPointsModel.predictDistance(t1, d1, t2); - case RacePredictionModel.VO2MaxModel: - return VO2MaxModel.predictDistance(t1, d1, t2); - case RacePredictionModel.RiegelModel: - return RiegelModel.predictDistance(t1, d1, t2, c); - case RacePredictionModel.CameronModel: - return CameronModel.predictDistance(t1, d1, t2); - } -} - -export const getPurdyPoints = PurdyPointsModel.getPurdyPoints; -export const getVO2 = VO2MaxModel.getVO2; -export const getVO2Percentage = VO2MaxModel.getVO2Percentage; -export const getVO2Max = VO2MaxModel.getVO2Max; diff --git a/src/utils/storage.ts b/src/utils/storage.ts @@ -1,94 +0,0 @@ -import { deepCopy } from '@/utils/misc'; -import { defaultRaceOptions, defaultWorkoutOptions } from '@/utils/calculators'; - -// The global localStorage prefix -const LocalStoragePrefix = 'running-tools'; - -/** - * Read an object from a localStorage item - * @param {string} key The localStorage item's key - * @returns {Type} The object - */ -export function get<Type>(key: string): Type | null { - try { - return JSON.parse(localStorage.getItem(`${LocalStoragePrefix}.${key}`) || ''); - } catch { - return null; - } -} - -/** - * Write an object to a localStorage item - * @param {string} key The localStorage item's key - * @param {Type} value The object to write - */ -export function set<Type>(key: string, value: Type) { - localStorage.setItem(`${LocalStoragePrefix}.${key}`, JSON.stringify(value)); -} - -/** - * Delete a localStorage item - * @param {string} key The localStorage item's key - */ -export function unset(key: string) { - localStorage.removeItem(`${LocalStoragePrefix}.${key}`); -} - -/** - * Migrate outdated localStorage options - */ -export function migrate() { - /* eslint-disable @typescript-eslint/no-explicit-any */ - - // Move pace-calculator-target-set into new pace-calculator-options (>1.4.1) - const paceSelectedTargetSet = get<string>('pace-calculator-target-set'); - if (paceSelectedTargetSet !== null) { - const paceOptions = { selectedTargetSet: paceSelectedTargetSet }; - set('pace-calculator-options', paceOptions); - unset('pace-calculator-target-set'); - } - - // Move race-calculator-target-set into race-calculator-options (>1.4.1) - const raceSelectedTargetSet = get<string>('race-calculator-target-set'); - const raceOptions = get<any>('race-calculator-options') - || deepCopy(defaultRaceOptions); - if (raceSelectedTargetSet !== null) { - raceOptions.selectedTargetSet = raceSelectedTargetSet; - set('race-calculator-options', raceOptions); - unset('race-calculator-target-set'); - } - if (raceOptions !== null && raceOptions.selectedTargetSet === undefined) { - raceOptions.selectedTargetSet = defaultRaceOptions.selectedTargetSet; - set('race-calculator-options', raceOptions); - } - - // Move split-calculator-target-set into new split-calculator-options (>1.4.1) - const splitSelectedTargetSet = get<string>('split-calculator-target-set'); - if (splitSelectedTargetSet !== null) { - const splitOptions = { selectedTargetSet: splitSelectedTargetSet }; - set('split-calculator-options', splitOptions); - unset('split-calculator-target-set'); - } - - // Move workout-calculator-target-set into workout-calculator-options (>1.4.1) - const workoutSelectedTargetSet = get<string>('workout-calculator-target-set'); - const workoutOptions = get<any>('workout-calculator-options') - || deepCopy(defaultWorkoutOptions); - if (workoutSelectedTargetSet !== null) { - workoutOptions.selectedTargetSet = workoutSelectedTargetSet; - set('workout-calculator-options', workoutOptions); - unset('workout-calculator-target-set'); - } - if (workoutOptions !== null && workoutOptions.selectedTargetSet === undefined) { - workoutOptions.selectedTargetSet = defaultWorkoutOptions.selectedTargetSet; - set('workout-calculator-options', workoutOptions); - } - - // Add customTargetNames property to workout-calculator-options (>1.4.1) - if (workoutOptions.customTargetNames === undefined) { - workoutOptions.customTargetNames = false; - set('workout-calculator-options', workoutOptions); - } - - /* eslint-enable @typescript-eslint/no-explicit-any */ -} diff --git a/src/utils/targets.ts b/src/utils/targets.ts @@ -1,207 +0,0 @@ -import { DistanceUnits, convertDistance, formatDistance, formatDuration } from '@/utils/units'; -import type { Distance } from '@/utils/units'; - -/* - * The two basic types of targets: those defined by distance and those defined by time - */ -export enum TargetTypes { - Distance = 'distance', - Time = 'time', -}; - -/* - * The types for basic standard targets and target sets used by the pace and race calculators - */ -interface DistanceTarget { - type: TargetTypes.Distance, - distanceValue: number, - distanceUnit: DistanceUnits, -}; -interface TimeTarget { - type: TargetTypes.Time, - time: number, -}; -export type StandardTarget = DistanceTarget | TimeTarget; -export interface StandardTargetSet { - name: string, - targets: Array<StandardTarget>, -}; -export interface StandardTargetSets { - [key: string]: StandardTargetSet, -}; - -/* - * The types for split calculator targets and target sets - */ -export type SplitTarget = DistanceTarget & { - splitTime?: number -}; -export interface SplitTargetSet { - name: string, - targets: Array<SplitTarget>, -}; -export interface SplitTargetSets { - [key: string]: SplitTargetSet, -}; - -/* - * The types for workout calculator targets and target sets - */ -export type WorkoutTarget = StandardTarget & { - splitValue: number, - splitUnit: DistanceUnits, - customName?: string, -}; -export interface WorkoutTargetSet { - name: string, - targets: Array<WorkoutTarget>, -}; -export interface WorkoutTargetSets { - [key: string]: WorkoutTargetSet, -}; - -/* - * The types for generic targets and target sets - */ -export type Target = StandardTarget | SplitTarget | WorkoutTarget; -export type TargetSet = StandardTargetSet | SplitTargetSet | WorkoutTargetSet; -export type TargetSets = StandardTargetSets | SplitTargetSets | WorkoutTargetSets; - -/** - * Sort an array of targets - * @param {Array<Target>} targets The array of targets - * @returns {Array<Target>} The sorted targets - */ -export function sort(targets: Array<Target>): Array<Target> { - return [ - ...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 === TargetTypes.Time) - .sort((a, b) => a.time - b.time), - ]; -} - -/** - * Generate a string description of a workout target - * @param {WorkoutTarget} target The workout target - * @return {string} The string description - */ -export function workoutTargetToString(target: WorkoutTarget): string { - let result = formatDistance({ distanceValue: target.splitValue, distanceUnit: target.splitUnit }, - false); - - if (target.type === TargetTypes.Time) { - result += ' @ ' + formatDuration(target.time, 3, 2, false); - } else if (target.distanceValue != target.splitValue || target.distanceUnit != target.splitUnit) { - result += ' @ ' + formatDistance(target as Distance, false); - } - return result; -} - -/* - * The default target sets for each calculator - */ -export const defaultTargetSets: { [key: string]: TargetSet } = { - '_pace_targets': { - name: 'Common Pace Targets', - targets: sort([ - { 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: 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: 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: TargetTypes.Distance, distanceValue: 0.5, distanceUnit: DistanceUnits.Marathons }, - { type: TargetTypes.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Marathons }, - - { type: TargetTypes.Time, time: 600 }, - { type: TargetTypes.Time, time: 1800 }, - { type: TargetTypes.Time, time: 3600 }, - ]), - }, '_race_targets': { - name: 'Common Race Targets', - targets: sort([ - { 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: 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: TargetTypes.Distance, distanceValue: 0.5, distanceUnit: DistanceUnits.Marathons }, - { type: TargetTypes.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Marathons }, - ]), - }, '_split_targets': { - name: '5K Mile Splits', - targets: [ - { type: TargetTypes.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Miles }, - { type: TargetTypes.Distance, distanceValue: 2, distanceUnit: DistanceUnits.Miles }, - { type: TargetTypes.Distance, distanceValue: 5, distanceUnit: DistanceUnits.Kilometers }, - ], - }, '_workout_targets': { - name: 'Common Workout Targets', - targets: [ - { - splitValue: 400, splitUnit: DistanceUnits.Meters, - type: TargetTypes.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Miles, - }, - { - splitValue: 800, splitUnit: DistanceUnits.Meters, - type: TargetTypes.Distance, distanceValue: 5, distanceUnit: DistanceUnits.Kilometers, - }, - { - splitValue: 1600, splitUnit: DistanceUnits.Meters, - type: TargetTypes.Time, time: 3600, - }, - { - splitValue: 1, splitUnit: DistanceUnits.Miles, - type: TargetTypes.Distance, distanceValue: 1, distanceUnit: DistanceUnits.Marathons, - }, - ], - }, -}; -export const defaultPaceTargetSets: StandardTargetSets = { - '_pace_targets': defaultTargetSets._pace_targets, -}; -export const defaultRaceTargetSets: StandardTargetSets = { - '_race_targets': defaultTargetSets._race_targets, -}; -export const defaultSplitTargetSets: SplitTargetSets = { - '_split_targets': defaultTargetSets._split_targets as SplitTargetSet, -}; -export const defaultWorkoutTargetSets: WorkoutTargetSets = { - '_workout_targets': defaultTargetSets._workout_targets as WorkoutTargetSet, -}; diff --git a/src/utils/units.ts b/src/utils/units.ts @@ -1,392 +0,0 @@ -/* - * The type for the data available for each unit - */ -export interface UnitData { - name: string, - symbol: string, - value: number, -}; - -/* - * The available time units - */ -export enum TimeUnits { - Seconds = 'seconds', - Minutes = 'minutes', - Hours = 'hours', -}; -export const TimeUnitData: { [key in TimeUnits]: UnitData } = { - [TimeUnits.Seconds]: { - name: 'Seconds', - symbol: 's', - value: 1, - }, - [TimeUnits.Minutes]: { - name: 'Minutes', - symbol: 'min', - value: 60, - }, - [TimeUnits.Hours]: { - name: 'Hours', - symbol: 'hr', - value: 3600, - }, -}; - -/* - * The available distance units - */ -export enum DistanceUnits { - Meters = 'meters', - Yards = 'yards', - Kilometers = 'kilometers', - Miles = 'miles', - Marathons = 'marathons', -}; -export const DistanceUnitData: { [key in DistanceUnits]: UnitData } = { - [DistanceUnits.Meters]: { - name: 'Meters', - symbol: 'm', - value: 1, - }, - [DistanceUnits.Yards]: { - name: 'Yards', - symbol: 'yd', - value: 0.9144, - }, - [DistanceUnits.Kilometers]: { - name: 'Kilometers', - symbol: 'km', - value: 1000, - }, - [DistanceUnits.Miles]: { - name: 'Miles', - symbol: 'mi', - value: 1609.344, - }, - [DistanceUnits.Marathons]: { - name: 'Marathons', - symbol: 'Mar', - value: 42195, - }, -}; - -/* - * The available speed units - */ -export enum SpeedUnits { - MetersPerSecond = 'meters_per_second', - KilometersPerHour = 'kilometers_per_hour', - MilesPerHour = 'miles_per_hour', -}; -export const SpeedUnitData: { [key in SpeedUnits]: UnitData } = { - [SpeedUnits.MetersPerSecond]: { - name: 'Meters per Second', - symbol: 'm/s', - value: 1, - }, - [SpeedUnits.KilometersPerHour]: { - name: 'Kilometers per Hour', - symbol: 'kph', - value: DistanceUnitData[DistanceUnits.Kilometers].value / TimeUnitData[TimeUnits.Hours].value, - }, - [SpeedUnits.MilesPerHour]: { - name: 'Miles per Hour', - symbol: 'mph', - value: DistanceUnitData[DistanceUnits.Miles].value / TimeUnitData[TimeUnits.Hours].value, - }, -}; - -/* - * The available pace units - */ -export enum PaceUnits { - SecondsPerMeter = 'seconds_per_meter', - TimePerKilometer = 'seconds_per_kilometer', - TimePerMile = 'seconds_per_mile', -}; -export const PaceUnitData: { [key in PaceUnits]: UnitData } = { - [PaceUnits.SecondsPerMeter]: { - name: 'Seconds per Meter', - symbol: 's/m', - value: 1, - }, - [PaceUnits.TimePerKilometer]: { - name: 'Time per Kilometer', - symbol: '/ km', - value: TimeUnitData[TimeUnits.Seconds].value / DistanceUnitData[DistanceUnits.Kilometers].value, - }, - [PaceUnits.TimePerMile]: { - name: 'Time per Mile', - symbol: '/ mi', - value: TimeUnitData[TimeUnits.Seconds].value / DistanceUnitData[DistanceUnits.Miles].value, - }, -}; - -/* - * The available speed and pace units - */ -export type SpeedPaceUnits = SpeedUnits | PaceUnits; - -/* - * The type for a distance input - */ -export interface Distance { - distanceValue: number, - distanceUnit: DistanceUnits, -}; - -/* - * The type for a distance/time input pair - */ -export interface DistanceTime extends Distance { - time: number, -}; - -/* - * The available unit systems - */ -export enum UnitSystems { - Metric = 'metric', - Imperial = 'imperial', -}; - -/** - * Convert between time units - * @param {number} inputValue The input value - * @param {string} inputUnit The unit of the input - * @param {string} outputUnit The unit of the output - * @returns {number} The output - */ -export function convertTime(inputValue: number, inputUnit: TimeUnits, - outputUnit: TimeUnits): number { - return (inputValue * TimeUnitData[inputUnit].value) / TimeUnitData[outputUnit].value; -} - -/** - * Convert between distance units - * @param {number} inputValue The input value - * @param {string} inputUnit The unit of the input - * @param {string} outputUnit The unit of the output - * @returns {number} The output - */ -export function convertDistance(inputValue: number, inputUnit: DistanceUnits, - outputUnit: DistanceUnits): number { - return (inputValue * DistanceUnitData[inputUnit].value) / DistanceUnitData[outputUnit].value; -} - -/** - * Convert between speed units - * @param {number} inputValue The input value - * @param {string} inputUnit The unit of the input - * @param {string} outputUnit The unit of the output - * @returns {number} The output - */ -export function convertSpeed(inputValue: number, inputUnit: SpeedUnits, - outputUnit: SpeedUnits): number { - return (inputValue * SpeedUnitData[inputUnit].value) / SpeedUnitData[outputUnit].value; -} - -/** - * Convert between pace units - * @param {number} inputValue The input value - * @param {string} inputUnit The unit of the input - * @param {string} outputUnit The unit of the output - * @returns {number} The output - */ -export function convertPace(inputValue: number, inputUnit: PaceUnits, - outputUnit: PaceUnits): number { - return (inputValue * PaceUnitData[inputUnit].value) / PaceUnitData[outputUnit].value; -} - -/** - * Convert between speed and/or pace units - * @param {number} inputValue The input value - * @param {string} inputUnit The unit of the input - * @param {string} outputUnit The unit of the output - * @returns {number} The output - */ -export function convertSpeedPace(inputValue: number, inputUnit: SpeedPaceUnits, - outputUnit: SpeedPaceUnits): number { - // Calculate input speed - let speed; - if (inputUnit in PaceUnitData) { - speed = 1 / (inputValue * PaceUnitData[inputUnit as PaceUnits].value); - } else { - speed = inputValue * SpeedUnitData[inputUnit as SpeedUnits].value; - } - - // Calculate output - if (outputUnit in PaceUnitData) { - return (1 / speed) / PaceUnitData[outputUnit as PaceUnits].value; - } - return speed / SpeedUnitData[outputUnit as SpeedUnits].value; -} - -/** - * Detect the user's default unit system - * @returns {UnitSystems} The default unit system - */ -export function detectDefaultUnitSystem(): UnitSystems { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const language = (navigator.language || (navigator as any).userLanguage).toLowerCase(); - if (language.endsWith('-us') || language.endsWith('-mm')) { - return UnitSystems.Imperial; - } - return UnitSystems.Metric; -} - -/** - * Format a number as a string - * @param {number} value The number - * @param {number} minPadding The minimum number of digits to show before the decimal point - * @param {number} maxDigits The maximum number of digits to show after the decimal point - * @param {boolean} extraDigits Whether to show extra zeros after the decimal point - * @returns {string} The formatted number - */ -export function formatNumber(value: number, minPadding: number = 0, maxDigits: number = 2, - extraDigits: boolean = true): string { - - // Initialize result - let result = ''; - - // Remove sign - const negative = value < 0; - const fixedValue = Math.abs(value); - - // Address edge cases - if (Number.isNaN(fixedValue)) { - return 'NaN'; - } - if (fixedValue === Infinity) { - return negative ? '-Infinity' : 'Infinity'; - } - - // Convert number to string - if (extraDigits) { - result = fixedValue.toFixed(maxDigits); - } else { - const power = 10 ** maxDigits; - result = (Math.round((fixedValue + Number.EPSILON) * power) / power).toString(); - } - - // Add padding - const currentPadding = result.split('.')[0].length; - result = result.padStart(result.length - currentPadding + minPadding, '0'); - - // Add negative sign - if (negative) { - result = `-${result}`; - } - - // Return result - return result; -} - -/** - * Format a distance as a string - * @param {Distance} input The distance - * @param {boolean} extraDigits Whether to show extra zeros after the decimal point - * @returns {string} The formatted distance - */ -export function formatDistance(input: Distance, extraDigits: boolean) { - return formatNumber(input.distanceValue, 0, 2, extraDigits) + ' ' - + DistanceUnitData[input.distanceUnit].symbol; -} - -/** - * Format a duration as a string - * @param {number} value The duration (in seconds) - * @param {number} minPadding The minimum number of digits to show before the decimal point - * @param {number} maxDigits The maximum number of digits to show after the decimal point - * @param {boolean} extraDigits Whether to show extra zeros after the decimal point - * @returns {string} The formatted duration - */ -export function formatDuration(value: number, minPadding: number = 6, maxDigits: number = 2, - extraDigits: boolean = true): string { - // Check if value is NaN - if (Number.isNaN(value)) { - return 'NaN'; - } - - // Initialize result - let result = ''; - - // Check value sign - if (value < 0) { - result += '-'; - } - - // Check if value is valid - if (Math.abs(value) === Infinity) { - return `${result}Infinity`; - } - - // Validate padding - let fixedPadding = Math.min(minPadding, 6); - - // Prevent rounding errors - const fixedValue = parseFloat(Math.abs(value).toFixed(maxDigits)); - - // Calculate parts - const hours = Math.floor(fixedValue / 3600); - const minutes = Math.floor((fixedValue % 3600) / 60); - const seconds = fixedValue % 60; - - // Format parts - if (hours !== 0 || fixedPadding >= 5) { - result += hours.toString().padStart(fixedPadding - 4, '0'); - result += ':'; - fixedPadding = 4; - } - if (minutes !== 0 || fixedPadding >= 3) { - result += minutes.toString().padStart(fixedPadding - 2, '0'); - result += ':'; - fixedPadding = 2; - } - result += formatNumber(seconds, fixedPadding, maxDigits, extraDigits); - - // Return result - return result; -} - -/** - * Calculate the pace of a distance/time pair and format it as a string - * @param {DistanceTime} input The input distance/time pair - * @param {PaceUnits} unit The desired pace unit - * @returns {string} The formatted pace - */ -export function formatPace(input: DistanceTime, unit: PaceUnits) { - const dist = convertDistance(input.distanceValue, input.distanceUnit, DistanceUnits.Meters); - const pace = convertPace(input.time / dist, PaceUnits.SecondsPerMeter, unit) - const result = formatDuration(pace, 3, 0, true) + ' ' + PaceUnitData[unit].symbol; - return result; -} - -/** - * Get the default distance unit in a unit system - * @param {UnitSystems} unitSystem The unit system - * @returns {DistanceUnits} The default distance unit - */ -export function getDefaultDistanceUnit(unitSystem: UnitSystems): DistanceUnits { - return unitSystem === UnitSystems.Metric ? DistanceUnits.Kilometers : DistanceUnits.Miles; -} - -/** - * Get the default speed unit in a unit system - * @param {UnitSystems} unitSystem The unit system - * @returns {SpeedUnits} The default speed unit - */ -export function getDefaultSpeedUnit(unitSystem: UnitSystems): SpeedUnits { - return unitSystem === UnitSystems.Metric ? SpeedUnits.KilometersPerHour - : SpeedUnits.MilesPerHour; -} - -/** - * Get the default pace unit in a unit system - * @param {UnitSystems} unitSystem The unit system - * @returns {PaceUnits} The default pace unit - */ -export function getDefaultPaceUnit(unitSystem: UnitSystems): PaceUnits { - return unitSystem === UnitSystems.Metric ? PaceUnits.TimePerKilometer : PaceUnits.TimePerMile; -} diff --git a/src/views/BatchCalculator.vue b/src/views/BatchCalculator.vue @@ -42,12 +42,12 @@ <script setup lang="ts"> import { computed } from 'vue'; -import * as calcUtils from '@/utils/calculators'; +import * as calculators from '@/core/calculators'; import type { BatchOptions, RaceOptions, StandardOptions, TargetResult, - WorkoutOptions } from '@/utils/calculators'; -import * as targetUtils from '@/utils/targets'; -import { UnitSystems, detectDefaultUnitSystem } from '@/utils/units'; -import type { Distance, DistanceTime } from '@/utils/units'; + WorkoutOptions } from '@/core/calculators'; +import * as targetUtils from '@/core/targets'; +import { UnitSystems, detectDefaultUnitSystem } from '@/core/units'; +import type { Distance, DistanceTime } from '@/core/units'; import AdvancedOptionsInput from '@/components/AdvancedOptionsInput.vue'; import DoubleOutputTable from '@/components/DoubleOutputTable.vue'; @@ -60,12 +60,13 @@ import useStorage from '@/composables/useStorage'; /* * The input pace */ -const input = useStorage<DistanceTime>('batch-calculator-input', calcUtils.defaultInput); +const input = useStorage<DistanceTime>('batch-calculator-input', calculators.defaultInput); /* * The batch input options */ -const options = useStorage<BatchOptions>('batch-calculator-options', calcUtils.defaultBatchOptions); +const options = useStorage<BatchOptions>('batch-calculator-options', + calculators.defaultBatchOptions); /* * The default unit system @@ -79,18 +80,18 @@ const paceTargetSets = useStorage<targetUtils.StandardTargetSets>('pace-calculat targetUtils.defaultPaceTargetSets); const raceTargetSets = useStorage<targetUtils.StandardTargetSets>('race-calculator-target-sets', targetUtils.defaultRaceTargetSets); -const workoutTargetSets = useStorage<targetUtils.WorkoutTargetSets>('workout-calculator-target-sets', - targetUtils.defaultWorkoutTargetSets); +const workoutTargetSets = useStorage<targetUtils.WorkoutTargetSets>( + 'workout-calculator-target-sets', targetUtils.defaultWorkoutTargetSets); /* * The options for each calculator */ const paceOptions = useStorage<StandardOptions>('pace-calculator-options', - calcUtils.defaultPaceOptions); + calculators.defaultPaceOptions); const raceOptions = useStorage<RaceOptions>('race-calculator-options', - calcUtils.defaultRaceOptions); + calculators.defaultRaceOptions); const workoutOptions = useStorage<WorkoutOptions>('workout-calculator-options', - calcUtils.defaultWorkoutOptions); + calculators.defaultWorkoutOptions); /* * The input distance @@ -117,30 +118,30 @@ const inputTimes = computed<Array<number>>(() => { const targetSets = computed<targetUtils.TargetSets>({ get: () => { switch (options.value.calculator) { - case (calcUtils.Calculators.Pace): { + case (calculators.Calculators.Pace): { return paceTargetSets.value; } - case (calcUtils.Calculators.Race): { + case (calculators.Calculators.Race): { return raceTargetSets.value; } default: - case (calcUtils.Calculators.Workout): { + case (calculators.Calculators.Workout): { return workoutTargetSets.value; } } }, set: (newValue: targetUtils.TargetSets) => { switch (options.value.calculator) { - case (calcUtils.Calculators.Pace): { + case (calculators.Calculators.Pace): { paceTargetSets.value = newValue as targetUtils.StandardTargetSets; break; } - case (calcUtils.Calculators.Race): { + case (calculators.Calculators.Race): { raceTargetSets.value = newValue as targetUtils.StandardTargetSets; break; } default: - case (calcUtils.Calculators.Workout): { + case (calculators.Calculators.Workout): { workoutTargetSets.value = newValue as targetUtils.WorkoutTargetSets; break; } @@ -154,30 +155,30 @@ const targetSets = computed<targetUtils.TargetSets>({ const calcOptions = computed<StandardOptions | RaceOptions | WorkoutOptions>({ get: () => { switch (options.value.calculator) { - case (calcUtils.Calculators.Pace): { + case (calculators.Calculators.Pace): { return paceOptions.value; } - case (calcUtils.Calculators.Race): { + case (calculators.Calculators.Race): { return raceOptions.value; } default: - case (calcUtils.Calculators.Workout): { + case (calculators.Calculators.Workout): { return workoutOptions.value; } } }, set: (newValue: StandardOptions | RaceOptions | WorkoutOptions) => { switch(options.value.calculator) { - case (calcUtils.Calculators.Pace): { + case (calculators.Calculators.Pace): { paceOptions.value = newValue as StandardOptions; break; } - case (calcUtils.Calculators.Race): { + case (calculators.Calculators.Race): { raceOptions.value = newValue as RaceOptions; break; } default: - case (calcUtils.Calculators.Workout): { + case (calculators.Calculators.Workout): { workoutOptions.value = newValue as WorkoutOptions; break; } @@ -190,16 +191,16 @@ const calcOptions = computed<StandardOptions | RaceOptions | WorkoutOptions>({ */ const calculateResult = computed<(x: DistanceTime, y: targetUtils.Target) => TargetResult>(() => { switch(options.value.calculator) { - case (calcUtils.Calculators.Pace): { - return (x,y) => calcUtils.calculatePaceResults(x, y, defaultUnitSystem.value, false); + case (calculators.Calculators.Pace): { + return (x,y) => calculators.calculatePaceResults(x, y, defaultUnitSystem.value, false); } - case (calcUtils.Calculators.Race): { - return (x,y) => calcUtils.calculateRaceResults(x, y, raceOptions.value, + case (calculators.Calculators.Race): { + return (x,y) => calculators.calculateRaceResults(x, y, raceOptions.value, defaultUnitSystem.value, false); } default: - case (calcUtils.Calculators.Workout): { - return (x,y) => calcUtils.calculateWorkoutResults(x, y as targetUtils.WorkoutTarget, + case (calculators.Calculators.Workout): { + return (x,y) => calculators.calculateWorkoutResults(x, y as targetUtils.WorkoutTarget, workoutOptions.value, false); } } diff --git a/src/views/PaceCalculator.vue b/src/views/PaceCalculator.vue @@ -23,12 +23,12 @@ <script setup lang="ts"> import { Calculators, calculatePaceResults, defaultInput, - defaultPaceOptions } from '@/utils/calculators'; -import type { StandardOptions } from '@/utils/calculators'; -import { defaultPaceTargetSets } from '@/utils/targets'; -import type { StandardTargetSets } from '@/utils/targets'; -import { UnitSystems, detectDefaultUnitSystem } from '@/utils/units'; -import type { DistanceTime } from '@/utils/units'; + defaultPaceOptions } from '@/core/calculators'; +import type { StandardOptions } from '@/core/calculators'; +import { defaultPaceTargetSets } from '@/core/targets'; +import type { StandardTargetSets } from '@/core/targets'; +import { UnitSystems, detectDefaultUnitSystem } from '@/core/units'; +import type { DistanceTime } from '@/core/units'; import AdvancedOptionsInput from '@/components/AdvancedOptionsInput.vue'; import PaceInput from '@/components/PaceInput.vue'; diff --git a/src/views/RaceCalculator.vue b/src/views/RaceCalculator.vue @@ -42,12 +42,12 @@ import { computed } from 'vue'; import { Calculators, calculateRaceResults, calculateRaceStats, defaultInput, - defaultRaceOptions } from '@/utils/calculators'; -import type { RaceOptions, RaceStats } from '@/utils/calculators'; -import { defaultRaceTargetSets } from '@/utils/targets'; -import type { StandardTargetSets } from '@/utils/targets'; -import { UnitSystems, detectDefaultUnitSystem, formatNumber } from '@/utils/units'; -import type { DistanceTime } from '@/utils/units'; + defaultRaceOptions } from '@/core/calculators'; +import type { RaceOptions, RaceStats } from '@/core/calculators'; +import { defaultRaceTargetSets } from '@/core/targets'; +import type { StandardTargetSets } from '@/core/targets'; +import { UnitSystems, detectDefaultUnitSystem, formatNumber } from '@/core/units'; +import type { DistanceTime } from '@/core/units'; import AdvancedOptionsInput from '@/components/AdvancedOptionsInput.vue'; import PaceInput from '@/components/PaceInput.vue'; diff --git a/src/views/SplitCalculator.vue b/src/views/SplitCalculator.vue @@ -14,11 +14,11 @@ <script setup lang="ts"> import { computed } from 'vue'; -import { Calculators, defaultSplitOptions } from '@/utils/calculators'; -import type { StandardOptions } from '@/utils/calculators'; -import { defaultSplitTargetSets } from '@/utils/targets'; -import type { SplitTargetSets } from '@/utils/targets'; -import { UnitSystems, detectDefaultUnitSystem } from '@/utils/units'; +import { Calculators, defaultSplitOptions } from '@/core/calculators'; +import type { StandardOptions } from '@/core/calculators'; +import { defaultSplitTargetSets } from '@/core/targets'; +import type { SplitTargetSets } from '@/core/targets'; +import { UnitSystems, detectDefaultUnitSystem } from '@/core/units'; import AdvancedOptionsInput from '@/components/AdvancedOptionsInput.vue'; import SplitOutputTable from '@/components/SplitOutputTable.vue'; diff --git a/src/views/UnitCalculator.vue b/src/views/UnitCalculator.vue @@ -12,7 +12,7 @@ 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"> + <option v-for="(value, key) in categoryUnits" :key="key" :value="key"> {{ value?.name }} </option> </select> @@ -20,14 +20,14 @@ <span class="equals"> = </span> <span v-if="isTimeUnit(input.outputUnit)" class="output-value" aria-label="Output value"> - {{ unitUtils.formatDuration(outputValue, 6, 3, true) }} + {{ units.formatDuration(outputValue, 6, 3, true) }} </span> <span v-else class="output-value" aria-label="Output value"> - {{ unitUtils.formatNumber(outputValue, 0, 3, true) }} + {{ units.formatNumber(outputValue, 0, 3, true) }} </span> <select v-model="input.outputUnit" class="output-units" aria-label="Output units"> - <option v-for="(value, key) in units" :key="key" :value="key"> + <option v-for="(value, key) in categoryUnits" :key="key" :value="key"> {{ value?.name }} </option> </select> @@ -37,7 +37,7 @@ <script setup lang="ts"> import { computed } from 'vue'; -import * as unitUtils from '@/utils/units'; +import * as units from '@/core/units'; import DecimalInput from '@/components/DecimalInput.vue'; import TimeInput from '@/components/TimeInput.vue'; @@ -56,12 +56,12 @@ enum UnitTypes { /* * The supported time units: Hours, Minutes, Seconds, and 'hh:mm:ss' */ -type ExtendedTimeUnits = unitUtils.TimeUnits | 'hh:mm:ss'; +type ExtendedTimeUnits = units.TimeUnits | 'hh:mm:ss'; /* * The support units from all categories */ -type AllUnits = unitUtils.DistanceUnits | ExtendedTimeUnits | unitUtils.SpeedPaceUnits; +type AllUnits = units.DistanceUnits | ExtendedTimeUnits | units.SpeedPaceUnits; /* * The type of the calculator inputs @@ -69,8 +69,8 @@ type AllUnits = unitUtils.DistanceUnits | ExtendedTimeUnits | unitUtils.SpeedPac interface UnitCalculatorInputs { [UnitTypes.Distance]: { inputValue: number, - inputUnit: unitUtils.DistanceUnits, - outputUnit: unitUtils.DistanceUnits, + inputUnit: units.DistanceUnits, + outputUnit: units.DistanceUnits, }, [UnitTypes.Time]: { inputValue: number, @@ -79,8 +79,8 @@ interface UnitCalculatorInputs { }, [UnitTypes.SpeedPace]: { inputValue: number, - inputUnit: unitUtils.SpeedPaceUnits, - outputUnit: unitUtils.SpeedPaceUnits, + inputUnit: units.SpeedPaceUnits, + outputUnit: units.SpeedPaceUnits, }, }; @@ -90,18 +90,18 @@ interface UnitCalculatorInputs { const inputs = useStorage<UnitCalculatorInputs>('unit-calculator-inputs', { [UnitTypes.Distance]: { inputValue: 1, - inputUnit: unitUtils.DistanceUnits.Miles, - outputUnit: unitUtils.DistanceUnits.Kilometers, + inputUnit: units.DistanceUnits.Miles, + outputUnit: units.DistanceUnits.Kilometers, }, [UnitTypes.Time]: { inputValue: 1, - inputUnit: unitUtils.TimeUnits.Seconds, + inputUnit: units.TimeUnits.Seconds, outputUnit: 'hh:mm:ss', }, [UnitTypes.SpeedPace]: { inputValue: 600, - inputUnit: unitUtils.PaceUnits.TimePerMile, - outputUnit: unitUtils.SpeedUnits.MilesPerHour, + inputUnit: units.PaceUnits.TimePerMile, + outputUnit: units.SpeedUnits.MilesPerHour, }, }); @@ -123,8 +123,8 @@ const input = computed<{ inputValue: number, inputUnit: AllUnits, outputUnit: Al case UnitTypes.Distance: { inputs.value[category.value] = { inputValue: newValue.inputValue, - inputUnit: newValue.inputUnit as unitUtils.DistanceUnits, - outputUnit: newValue.outputUnit as unitUtils.DistanceUnits, + inputUnit: newValue.inputUnit as units.DistanceUnits, + outputUnit: newValue.outputUnit as units.DistanceUnits, }; break; } @@ -139,8 +139,8 @@ const input = computed<{ inputValue: number, inputUnit: AllUnits, outputUnit: Al case UnitTypes.SpeedPace: { inputs.value[category.value] = { inputValue: newValue.inputValue, - inputUnit: newValue.inputUnit as unitUtils.SpeedPaceUnits, - outputUnit: newValue.outputUnit as unitUtils.SpeedPaceUnits, + inputUnit: newValue.inputUnit as units.SpeedPaceUnits, + outputUnit: newValue.outputUnit as units.SpeedPaceUnits, }; break; } @@ -151,15 +151,15 @@ const input = computed<{ inputValue: number, inputUnit: AllUnits, outputUnit: Al /* * The data for the units in the current category */ -const units = computed<{ [key in AllUnits]?: unitUtils.UnitData }>(() => { +const categoryUnits = computed<{ [key in AllUnits]?: units.UnitData }>(() => { switch (category.value) { default: case UnitTypes.Distance: { - return unitUtils.DistanceUnitData; + return units.DistanceUnitData; } case UnitTypes.Time: { return { - ...unitUtils.TimeUnitData, + ...units.TimeUnitData, 'hh:mm:ss': { name: 'hh:mm:ss', symbol: '', @@ -168,7 +168,7 @@ const units = computed<{ [key in AllUnits]?: unitUtils.UnitData }>(() => { }; } case UnitTypes.SpeedPace: { - return { ...unitUtils.PaceUnitData, ...unitUtils.SpeedUnitData }; + return { ...units.PaceUnitData, ...units.SpeedUnitData }; } } }); @@ -180,9 +180,9 @@ const outputValue = computed<number>(() => { switch (category.value) { default: case UnitTypes.Distance: { - return unitUtils.convertDistance(input.value.inputValue, - input.value.inputUnit as unitUtils.DistanceUnits, - input.value.outputUnit as unitUtils.DistanceUnits); + return units.convertDistance(input.value.inputValue, + input.value.inputUnit as units.DistanceUnits, + input.value.outputUnit as units.DistanceUnits); } case UnitTypes.Time: { // Correct input and output units for 'hh:mm:ss' unit @@ -190,13 +190,13 @@ const outputValue = computed<number>(() => { const realOutput = input.value.outputUnit === 'hh:mm:ss' ? 'seconds' : input.value.outputUnit; // Calculate conversion - return unitUtils.convertTime(input.value.inputValue, realInput as unitUtils.TimeUnits, - realOutput as unitUtils.TimeUnits); + return units.convertTime(input.value.inputValue, realInput as units.TimeUnits, + realOutput as units.TimeUnits); } case UnitTypes.SpeedPace: { - return unitUtils.convertSpeedPace(input.value.inputValue, - input.value.inputUnit as unitUtils.SpeedPaceUnits, - input.value.outputUnit as unitUtils.SpeedPaceUnits); + return units.convertSpeedPace(input.value.inputValue, + input.value.inputUnit as units.SpeedPaceUnits, + input.value.outputUnit as units.SpeedPaceUnits); } } }); @@ -207,7 +207,7 @@ const outputValue = computed<number>(() => { * @returns {boolean} Whether the unit should be represented as a time */ function isTimeUnit(unit: AllUnits): boolean { - return unit in unitUtils.PaceUnitData || unit === 'hh:mm:ss'; + return unit in units.PaceUnitData || unit === 'hh:mm:ss'; } </script> diff --git a/src/views/WorkoutCalculator.vue b/src/views/WorkoutCalculator.vue @@ -23,12 +23,12 @@ <script setup lang="ts"> import { Calculators, calculateWorkoutResults, defaultInput, - defaultWorkoutOptions } from '@/utils/calculators'; -import type { WorkoutOptions } from '@/utils/calculators'; -import { defaultWorkoutTargetSets } from '@/utils/targets'; -import type { WorkoutTarget, WorkoutTargetSets } from '@/utils/targets'; -import { UnitSystems, detectDefaultUnitSystem } from '@/utils/units'; -import type { DistanceTime } from '@/utils/units'; + defaultWorkoutOptions } from '@/core/calculators'; +import type { WorkoutOptions } from '@/core/calculators'; +import { defaultWorkoutTargetSets } from '@/core/targets'; +import type { WorkoutTarget, WorkoutTargetSets } from '@/core/targets'; +import { UnitSystems, detectDefaultUnitSystem } from '@/core/units'; +import type { DistanceTime } from '@/core/units'; import AdvancedOptionsInput from '@/components/AdvancedOptionsInput.vue'; import PaceInput from '@/components/PaceInput.vue'; diff --git a/tests/unit/core/calculators.spec.js b/tests/unit/core/calculators.spec.js @@ -0,0 +1,255 @@ +import { describe, test, expect } from 'vitest'; +import * as calculators from '@/core/calculators'; + +describe('calculatePaceResults method', () => { + test('should correctly calculate pace times', () => { + const input = { + distanceValue: 1, + distanceUnit: 'kilometers', + time: 100, + }; + const target = { + distanceValue: 20, + distanceUnit: 'meters', + type: 'distance', + }; + + const result = calculators.calculatePaceResults(input, target, 'metric'); + + expect(result).to.deep.equal({ + key: '20 m', + value: '0:02.00', + pace: '1:40 / km', + result: 'value', + sort: 2, + }); + }); + + test('should correctly calculate pace distances according to default units setting', () => { + const input = { + distanceValue: 2, + distanceUnit: 'miles', + time: 1200, + }; + const target = { + time: 600, + type: 'time', + }; + + const result1 = calculators.calculatePaceResults(input, target, 'metric'); + const result2 = calculators.calculatePaceResults(input, target, 'imperial'); + + expect(result1.key).to.equal('1.61 km'); + expect(result1.value).to.equal('10:00'); + expect(result1.pace).to.equal('6:13 / km'); + expect(result1.result).to.equal('key'); + expect(result1.sort).to.be.closeTo(600, 0.01); + + expect(result2.key).to.equal('1.00 mi'); + expect(result2.value).to.equal('10:00'); + expect(result2.pace).to.equal('10:00 / mi'); + expect(result2.result).to.equal('key'); + expect(result2.sort).to.be.closeTo(600, 0.01); + }); +}); + +describe('calculateRaceResults method', () => { + test('should correctly predict race times', () => { + const input = { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }; + const target = { + distanceValue: 10, + distanceUnit: 'kilometers', + type: 'distance', + }; + const options = { + model: 'AverageModel', + riegelExponent: 1.06, + } + + const result = calculators.calculateRaceResults(input, target, options, 'imperial'); + + expect(result.key).to.equal('10 km'); + expect(result.value).to.equal('41:34.80'); + expect(result.pace).to.equal('6:41 / mi'); + expect(result.result).to.equal('value'); + expect(result.sort).to.be.closeTo(2494.80, 0.01); + }); + + test('should correctly calculate race distances according to default units setting', () => { + const input = { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }; + const target = { + time: 2495, + type: 'time', + }; + const options = { + model: 'AverageModel', + riegelExponent: 1.06, + } + + const result1 = calculators.calculateRaceResults(input, target, options, 'metric'); + const result2 = calculators.calculateRaceResults(input, target, options, 'imperial'); + + expect(result1.key).to.equal('10.00 km'); + expect(result1.value).to.equal('41:35'); + expect(result1.pace).to.equal('4:09 / km'); + expect(result1.result).to.equal('key'); + expect(result1.sort).to.equal(2495); + + expect(result2.key).to.equal('6.21 mi'); + expect(result2.value).to.equal('41:35'); + expect(result2.pace).to.equal('6:41 / mi'); + expect(result2.result).to.equal('key'); + expect(result2.sort).to.equal(2495); + }); + + test('should correctly predict race times according to race options', () => { + const input = { + distanceValue: 2, + distanceUnit: 'miles', + time: 630, + }; + const target = { + distanceValue: 5, + distanceUnit: 'kilometers', + type: 'distance', + }; + const options = { + model: 'RiegelModel', + riegelExponent: 1.12, + } + + const result = calculators.calculateRaceResults(input, target, options, 'imperial'); + + expect(result.key).to.equal('5 km'); + expect(result.value).to.equal('17:11.78'); + expect(result.pace).to.equal('5:32 / mi'); + expect(result.result).to.equal('value'); + expect(result.sort).to.be.closeTo(1031.77, 0.01); + }); +}); + +describe('calculateRaceStats method', () => { + test('should correctly calculate race statistics', () => { + const input = { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }; + + const results = calculators.calculateRaceStats(input); + + expect(results.purdyPoints).to.be.closeTo(454.5, 0.1); + expect(results.vo2).to.be.closeTo(47.4, 0.1); + expect(results.vo2MaxPercentage).to.be.closeTo(95.3, 0.1); + expect(results.vo2Max).to.be.closeTo(49.8, 0.1); + }); +}); + +describe('calculateWorkoutResults method', () => { + test('should correctly calculate distance-based workouts according to race options', () => { + const input = { + distanceValue: 2, + distanceUnit: 'miles', + time: 630, + }; + const target = { + distanceValue: 5, + distanceUnit: 'kilometers', // 5k split is ~17:11.77 + splitValue: 1000, + splitUnit: 'meters', + type: 'distance', + }; + const options = { + customTargetNames: false, + model: 'RiegelModel', + riegelExponent: 1.12, + } + + const result = calculators.calculateWorkoutResults(input, target, options); + + expect(result.key).to.equal('1000 m @ 5 km'); + expect(result.value).to.equal('3:26.36'); + expect(result.pace).to.equal(''); + expect(result.result).to.equal('value'); + expect(result.sort).to.be.closeTo(206.35, 0.01); + }); + + test('should correctly calculate distance-based workouts according to custom names', () => { + const input = { + distanceValue: 2, + distanceUnit: 'miles', + time: 630, + }; + const target_1 = { + distanceValue: 5, + distanceUnit: 'kilometers', // 5k split is ~17:11.77 + splitValue: 1000, + splitUnit: 'meters', + type: 'distance', + // no custom name + }; + const target_2 = { + distanceValue: 5, + distanceUnit: 'kilometers', // 5k split is ~17:11.77 + splitValue: 1000, + splitUnit: 'meters', + type: 'distance', + customName: 'my custom name', + }; + const options_a = { + customTargetNames: false, + model: 'RiegelModel', + riegelExponent: 1.12, + }; + const options_b = { + customTargetNames: true, + model: 'RiegelModel', + riegelExponent: 1.12, + }; + + const result1a = calculators.calculateWorkoutResults(input, target_1, options_a); + const result1b = calculators.calculateWorkoutResults(input, target_1, options_b); + const result2a = calculators.calculateWorkoutResults(input, target_2, options_a); + const result2b = calculators.calculateWorkoutResults(input, target_2, options_b); + + expect(result1a.key).to.equal('1000 m @ 5 km'); + expect(result1b.key).to.equal('1000 m @ 5 km'); + expect(result2a.key).to.equal('1000 m @ 5 km'); + expect(result2b.key).to.equal('my custom name'); + }); + + test('should correctly calculate time-based workouts', () => { + const input = { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }; + const target = { + time: 2495, // ~10k split is 41:35 + splitValue: 1, + splitUnit: 'miles', + type: 'time', + }; + const options = { + customTargetNames: false, + model: 'AverageModel', + riegelExponent: 1.06, + } + + const result = calculators.calculateWorkoutResults(input, target, options); + + expect(result.key).to.equal('1 mi @ 41:35'); + expect(result.value).to.equal('6:41.50'); + expect(result.pace).to.equal(''); + expect(result.result).to.equal('value'); + expect(result.sort).to.be.closeTo(401.50, 0.01); + }); +}); diff --git a/tests/unit/core/racePrediction.spec.js b/tests/unit/core/racePrediction.spec.js @@ -0,0 +1,166 @@ +import { describe, test, expect } from 'vitest'; +import * as racePrediction from '@/core/racePrediction'; + +describe('predictTime method', () => { + describe('PredictTime method', () => { + test('Average Model', () => { + const riegel = racePrediction.predictTime(5000, 1200, 10000, 'AverageModel'); + const cameron = racePrediction.predictTime(5000, 1200, 10000, 'AverageModel'); + const purdyPoints = racePrediction.predictTime(5000, 1200, 10000, 'AverageModel'); + const vo2Max = racePrediction.predictTime(5000, 1200, 10000, 'AverageModel'); + const expected = (riegel + cameron + purdyPoints + vo2Max) / 4; + + const result = racePrediction.predictTime(5000, 1200, 10000, 'AverageModel'); + expect(result).to.equal(expected); + }); + + test('Should predict identical times for itentical distances', () => { + const result = racePrediction.predictTime(5000, 1200, 5000, 'AverageModel'); + expect(result).to.be.closeTo(1200, 0.001); + }); + }); + + describe('Purdy Points Model', () => { + test('Predictions should be approximately correct', () => { + const result = racePrediction.predictTime(5000, 1200, 10000, 'PurdyPointsModel'); + expect(result).to.be.closeTo(2490, 1); + }); + + test('Should predict identical times for itentical distances', () => { + const result = racePrediction.predictTime(5000, 1200, 5000, 'PurdyPointsModel'); + expect(result).to.be.closeTo(1200, 0.001); + }); + }); + + describe('VO2 Max Model', () => { + test('Predictions should be approximately correct', () => { + const result = racePrediction.predictTime(5000, 1200, 10000, 'VO2MaxModel'); + expect(result).to.be.closeTo(2488, 1); + }); + + test('Should predict identical times for itentical distances', () => { + const result = racePrediction.predictTime(5000, 1200, 5000, 'VO2MaxModel'); + expect(result).to.be.closeTo(1200, 0.001); + }); + }); + + describe('Cameron Model', () => { + test('Predictions should be approximately correct', () => { + const result = racePrediction.predictTime(5000, 1200, 10000, 'CameronModel'); + expect(result).to.be.closeTo(2500, 1); + }); + + test('Should predict identical times for itentical distances', () => { + const result = racePrediction.predictTime(5000, 1200, 5000, 'CameronModel'); + expect(result).to.be.closeTo(1200, 0.001); + }); + }); + + describe('Riegel Model', () => { + test('Predictions should be approximately correct', () => { + const result = racePrediction.predictTime(5000, 1200, 10000, 'RiegelModel'); + expect(result).to.be.closeTo(2502, 1); + }); + + test('Should predict identical times for itentical distances', () => { + const result = racePrediction.predictTime(5000, 1200, 5000, 'RiegelModel'); + expect(result).to.be.closeTo(1200, 0.001); + }); + }); +}); + +describe('predictDistance method', () => { + describe('Average Model', () => { + test('Predictions should be correct', () => { + const riegel = racePrediction.predictTime(5000, 1200, 10000, 'AverageModel'); + const cameron = racePrediction.predictTime(5000, 1200, 10000, 'AverageModel'); + const purdyPoints = racePrediction.predictTime(5000, 1200, 10000, 'AverageModel'); + const vo2Max = racePrediction.predictTime(5000, 1200, 10000, 'AverageModel'); + const expected = (riegel + cameron + purdyPoints + vo2Max) / 4; + + const result = racePrediction.predictDistance(1200, 5000, expected); + expect(result).to.be.closeTo(10000, 10); + }); + + test('Should predict identical times for itentical distances', () => { + const result = racePrediction.predictDistance(1200, 5000, 1200, 'AverageModel'); + expect(result).to.be.closeTo(5000, 0.001); + }); + }); + + describe('Purdy Points Model', () => { + test('Predictions should be approximately correct', () => { + const result = racePrediction.predictDistance(1200, 5000, 2490, 'PurdyPointsModel'); + expect(result).to.be.closeTo(10000, 10); + }); + + test('Should predict identical times for itentical distances', () => { + const result = racePrediction.predictDistance(1200, 5000, 1200, 'PurdyPointsModel'); + expect(result).to.be.closeTo(5000, 0.001); + }); + }); + + describe('VO2 Max Model', () => { + test('Predictions should be approximately correct', () => { + const result = racePrediction.predictDistance(1200, 5000, 2488, 'VO2MaxModel'); + expect(result).to.be.closeTo(10000, 10); + }); + + test('Should predict identical times for itentical distances', () => { + const result = racePrediction.predictDistance(1200, 5000, 1200, 'VO2MaxModel'); + expect(result).to.be.closeTo(5000, 0.001); + }); + }); + + describe('Cameron Model', () => { + test('Predictions should be approximately correct', () => { + const result = racePrediction.predictDistance(1200, 5000, 2500, 'CameronModel'); + expect(result).to.be.closeTo(10000, 10); + }); + + test('Should predict identical times for itentical distances', () => { + const result = racePrediction.predictDistance(1200, 5000, 1200, 'CameronModel'); + expect(result).to.be.closeTo(5000, 0.001); + }); + }); + + describe('Riegel Model', () => { + test('Predictions should be approximately correct', () => { + const result = racePrediction.predictDistance(1200, 5000, 2502, 'RiegelModel'); + expect(result).to.be.closeTo(10000, 10); + }); + + test('Should predict identical times for itentical distances', () => { + const result = racePrediction.predictDistance(1200, 5000, 1200, 'RiegelModel'); + expect(result).to.be.closeTo(5000, 0.001); + }); + }); +}); + +describe('getVO2 method', () => { + test('Result should be approximately correct', () => { + const result = racePrediction.getVO2(5000, 1200); + expect(result).to.be.closeTo(47.4, 0.1); + }); +}); + +describe('getVO2Percentage method', () => { + test('Result should be approximately correct', () => { + const result = racePrediction.getVO2Percentage(660); + expect(result).to.be.closeTo(1, 0.001); + }); +}); + +describe('getVO2Max method', () => { + test('Result should be approximately correct', () => { + const result = racePrediction.getVO2Max(5000, 1200); + expect(result).to.be.closeTo(49.8, 0.1); + }); +}); + +describe('getPurdyPoints method', () => { + test('Result should be approximately correct', () => { + const result = racePrediction.getPurdyPoints(5000, 1200); + expect(result).to.be.closeTo(454, 1); + }); +}); diff --git a/tests/unit/core/targets.spec.js b/tests/unit/core/targets.spec.js @@ -0,0 +1,59 @@ +import { describe, test, expect } from 'vitest'; +import * as targets from '@/core/targets'; + +describe('sort method', () => { + test('should correctly sort targets', () => { + // Initialize unsorted and sorted targets + const input = [ + { time: 60, type: 'time' }, + { distanceUnit: 'kilometers', distanceValue: 5, type: 'distance' }, + { distanceUnit: 'miles', distanceValue: 3, type: 'distance' }, + ]; + const expected = [ + { distanceUnit: 'miles', distanceValue: 3, type: 'distance' }, + { distanceUnit: 'kilometers', distanceValue: 5, type: 'distance' }, + { time: 60, type: 'time' }, + ]; + + // Assert sort method sorts targets correctly + expect(targets.sort(input)).to.deep.equal(expected); + }); +}); + +describe('workoutTargetToString method', () => { + test('should correctly stringify time target', () => { + // Initialize original and stringified target + const input = { + splitValue: 1600, splitUnit: 'meters', + type: 'time', time: 3600, + }; + const expected = '1600 m @ 1:00:00'; + + // Assert sort method sorts targets correctly + expect(targets.workoutTargetToString(input)).to.deep.equal(expected); + }); + + test('should correctly stringify distance target', () => { + // Initialize original and stringified target + const input = { + splitValue: 800, splitUnit: 'meters', + type: 'distance', distanceValue: 5, distanceUnit: 'kilometers', + }; + const expected = '800 m @ 5 km'; + + // Assert sort method sorts targets correctly + expect(targets.workoutTargetToString(input)).to.deep.equal(expected); + }); + + test('should correctly stringify race target', () => { + // Initialize original and stringified target + const input = { + splitValue: 5, splitUnit: 'kilometers', + type: 'distance', distanceValue: 5, distanceUnit: 'kilometers', + }; + const expected = '5 km'; + + // Assert sort method sorts targets correctly + expect(targets.workoutTargetToString(input)).to.deep.equal(expected); + }); +}); diff --git a/tests/unit/core/units.spec.js b/tests/unit/core/units.spec.js @@ -0,0 +1,439 @@ +import { describe, test, expect } from 'vitest'; +import * as units from '@/core/units'; + +describe('convertTime method', () => { + test('90 seconds should equal 1.5 minutes', () => { + const result = units.convertTime(90, 'seconds', 'minutes'); + expect(result).to.equal(1.5); + }); + + test('1.5 minutes should equal 95 seconds', () => { + const result = units.convertTime(1.5, 'minutes', 'seconds'); + expect(result).to.equal(90); + }); +}); + +describe('convertDistance method', () => { + test('100 meters should equal 0.1 kilometers', () => { + const result = units.convertDistance(100, 'meters', 'kilometers'); + expect(result).to.equal(0.1); + }); + + test('0.1 kilometers should equal 100 meters', () => { + const result = units.convertDistance(0.1, 'kilometers', 'meters'); + expect(result).to.equal(100); + }); +}); + +describe('convertSpeed method', () => { + test('1000 meters per seconds should equal 3600 kilometers per hour', () => { + const result = units.convertSpeed(1000, 'meters_per_second', 'kilometers_per_hour'); + expect(result).to.equal(3600); + }); + + test('3600 kilometers per hour should equal 1000 meters per second', () => { + const result = units.convertSpeed(3600, 'kilometers_per_hour', 'meters_per_second'); + expect(result).to.equal(1000); + }); +}); + +describe('convertPace method', () => { + test('1 second per meter should equal 1000 seconds per kilometer', () => { + const result = units.convertPace(1, 'seconds_per_meter', 'seconds_per_kilometer'); + expect(result).to.equal(1000); + }); + + test('1000 seconds per kilometer should equal 1 second per meter', () => { + const result = units.convertPace(1000, 'seconds_per_kilometer', 'seconds_per_meter'); + expect(result).to.equal(1); + }); +}); + +describe('convertSpeedPace method', () => { + test('3600 kilometers per hour should equal 1 second per kilometer', () => { + const result = units.convertSpeedPace(3600, 'kilometers_per_hour', 'seconds_per_kilometer'); + expect(result).to.equal(1); + }); + + test('1 second per kilometer should equal 3600 kilometers per hour', () => { + const result = units.convertSpeedPace(3600, 'seconds_per_kilometer', 'kilometers_per_hour'); + expect(result).to.equal(1); + }); +}); + +describe('formatNumber method', () => { + test('should correctly format number when padding is not 0', () => { + let result = units.formatNumber(12.3, 3, 0); + expect(result).to.equal('012'); + + result = units.formatNumber(12.3, 3, 2); + expect(result).to.equal('012.30'); + + result = units.formatNumber(123, 2, 0); + expect(result).to.equal('123'); + + result = units.formatNumber(-12, 3, 0); + expect(result).to.equal('-012'); + }); + + test('should correctly format number when extraDigits is true', () => { + let result = units.formatNumber(1234, 0, 2); + expect(result).to.equal('1234.00'); + + result = units.formatNumber(1234.5, 0, 2); + expect(result).to.equal('1234.50'); + + result = units.formatNumber(1234.56, 0, 2); + expect(result).to.equal('1234.56'); + + result = units.formatNumber(1234.567, 0, 2); + expect(result).to.equal('1234.57'); + + result = units.formatNumber(1234.56, 0, 0); + expect(result).to.equal('1235'); + }); + + test('should correctly format number when extraDigits is false', () => { + let result = units.formatNumber(1234, 0, 2, false); + expect(result).to.equal('1234'); + + result = units.formatNumber(1234.5, 0, 2, false); + expect(result).to.equal('1234.5'); + + result = units.formatNumber(1234.56, 0, 2, false); + expect(result).to.equal('1234.56'); + + result = units.formatNumber(1234.567, 0, 2, false); + expect(result).to.equal('1234.57'); + + result = units.formatNumber(1234.56, 0, 0, false); + expect(result).to.equal('1235'); + }); + + test('should correctly format undefined', () => { + let result = units.formatNumber(undefined, 0, 2); + expect(result).to.equal('NaN'); + + result = units.formatNumber(undefined, 0, 2, false); + expect(result).to.equal('NaN'); + + result = units.formatNumber(undefined, 5, 2); + expect(result).to.equal('NaN'); + }); + + test('should correctly format NaN', () => { + let result = units.formatNumber(NaN, 0, 0); + expect(result).to.equal('NaN'); + + result = units.formatNumber(NaN, 0, 2, false); + expect(result).to.equal('NaN'); + + result = units.formatNumber(NaN, 5, 2); + expect(result).to.equal('NaN'); + }); + + test('should correctly format +/- Infinity', () => { + let result = units.formatNumber(Infinity); + expect(result).to.equal('Infinity'); + + result = units.formatNumber(Infinity, 10, 2); + expect(result).to.equal('Infinity'); + + result = units.formatNumber(-Infinity); + expect(result).to.equal('-Infinity'); + }); + + test('should correctly format numbers smaller than 1', () => { + let result = units.formatNumber(0.123, 0, 0); + expect(result).to.equal('0'); + + result = units.formatNumber(0.123, 0, 2); + expect(result).to.equal('0.12'); + }); + + test('should correctly format negative numbers', () => { + let result = units.formatNumber(-12, 0, 2, false); + expect(result).to.equal('-12'); + + result = units.formatNumber(-12, 0, 2); + expect(result).to.equal('-12.00'); + + result = units.formatNumber(-12.34, 0, 2); + expect(result).to.equal('-12.34'); + + result = units.formatNumber(-12.34, 3, 2); + expect(result).to.equal('-012.34'); + + result = units.formatNumber(-0.12, 0, 2); + expect(result).to.equal('-0.12'); + }); +}); + +describe('formatDistance method', () => { + test('should correctly format distances with a variety of units', () => { + let result = units.formatDistance({ + distanceValue: 1, + distanceUnit: units.DistanceUnits.Yards, + }, false); + expect(result).to.equal('1 yd'); + + result = units.formatDistance({ + distanceValue: 2, + distanceUnit: units.DistanceUnits.Meters, + }, false); + expect(result).to.equal('2 m'); + + result = units.formatDistance({ + distanceValue: 3, + distanceUnit: units.DistanceUnits.Kilometers, + }, false); + expect(result).to.equal('3 km'); + + result = units.formatDistance({ + distanceValue: 4, + distanceUnit: units.DistanceUnits.Miles, + }, false); + expect(result).to.equal('4 mi'); + + result = units.formatDistance({ + distanceValue: 5, + distanceUnit: units.DistanceUnits.Marathons, + }, false); + expect(result).to.equal('5 Mar'); + }); + + test('should correctly format distance when extraDigits is true', () => { + let result = units.formatDistance({ + distanceValue: 1234, + distanceUnit: units.DistanceUnits.Miles, + }, true); + expect(result).to.equal('1234.00 mi'); + + result = units.formatDistance({ + distanceValue: 1234.5, + distanceUnit: units.DistanceUnits.Miles, + }, true); + expect(result).to.equal('1234.50 mi'); + + result = units.formatDistance({ + distanceValue: 1234.56, + distanceUnit: units.DistanceUnits.Miles, + }, true); + expect(result).to.equal('1234.56 mi'); + + result = units.formatDistance({ + distanceValue: 1234.567, + distanceUnit: units.DistanceUnits.Miles, + }, true); + expect(result).to.equal('1234.57 mi'); + }); + + test('should correctly format distance when extraDigits is false', () => { + let result = units.formatDistance({ + distanceValue: 1234, + distanceUnit: units.DistanceUnits.Miles, + }, false); + expect(result).to.equal('1234 mi'); + + result = units.formatDistance({ + distanceValue: 1234.5, + distanceUnit: units.DistanceUnits.Miles, + }, false); + expect(result).to.equal('1234.5 mi'); + + result = units.formatDistance({ + distanceValue: 1234.56, + distanceUnit: units.DistanceUnits.Miles, + }, false); + expect(result).to.equal('1234.56 mi'); + + result = units.formatDistance({ + distanceValue: 1234.567, + distanceUnit: units.DistanceUnits.Miles, + }, false); + expect(result).to.equal('1234.57 mi'); + }); + + test('should correctly format distances smaller than 1', () => { + let result = units.formatDistance({ + distanceValue: 0, + distanceUnit: units.DistanceUnits.Miles, + }, false); + expect(result).to.equal('0 mi'); + + result = units.formatDistance({ + distanceValue: 0, + distanceUnit: units.DistanceUnits.Miles, + }, true); + expect(result).to.equal('0.00 mi'); + + result = units.formatDistance({ + distanceValue: 0.1, + distanceUnit: units.DistanceUnits.Miles, + }, false); + expect(result).to.equal('0.1 mi'); + + result = units.formatDistance({ + distanceValue: 0.1, + distanceUnit: units.DistanceUnits.Miles, + }, true); + expect(result).to.equal('0.10 mi'); + + result = units.formatDistance({ + distanceValue: 0.12, + distanceUnit: units.DistanceUnits.Miles, + }, false); + expect(result).to.equal('0.12 mi'); + }); + + test('should correctly format negative distances', () => { + let result = units.formatDistance({ + distanceValue: -1234, + distanceUnit: units.DistanceUnits.Miles, + }, false); + expect(result).to.equal('-1234 mi'); + + result = units.formatDistance({ + distanceValue: -1234, + distanceUnit: units.DistanceUnits.Miles, + }, true); + expect(result).to.equal('-1234.00 mi'); + + result = units.formatDistance({ + distanceValue: -1234.56, + distanceUnit: units.DistanceUnits.Miles, + }, false); + expect(result).to.equal('-1234.56 mi'); + }); +}); + +describe('formatDuration method', () => { + test('should correctly divide durations into parts', () => { + const result = units.formatDuration(3600 + 120 + 3 + 0.4); + expect(result).to.equal('01:02:03.40'); + }); + + test('should correctly format duration when padding is 7', () => { + const result = units.formatDuration(3600 + 120 + 3 + 0.4, 7); + expect(result).to.equal('01:02:03.40'); + }); + + test('should correctly format duration when padding is 3', () => { + let result = units.formatDuration(3600 + 120 + 3 + 0.4, 3); + expect(result).to.equal('1:02:03.40'); + + result = units.formatDuration(120 + 3 + 0.4, 3); + expect(result).to.equal('2:03.40'); + + result = units.formatDuration(3 + 0.4, 3); + expect(result).to.equal('0:03.40'); + }); + + test('should correctly format duration when padding is 0', () => { + const result = units.formatDuration(0.4, 0); + expect(result).to.equal('0.40'); + }); + + test('should correctly format duration when digits is 3', () => { + const result = units.formatDuration(3600 + 120 + 3 + 0.4567, 0, 3); + expect(result).to.equal('1:02:03.457'); + }); + + test('should correctly format duration when digits is 0', () => { + const result = units.formatDuration(3600 + 120 + 3 + 0.456, 0, 0); + expect(result).to.equal('1:02:03'); + }); + + test('should correctly format NaN', () => { + const result = units.formatDuration(NaN); + expect(result).to.equal('NaN'); + }); + + test('should correctly format +/- Infinity', () => { + let result = units.formatDuration(Infinity); + expect(result).to.equal('Infinity'); + + result = units.formatDuration(-Infinity); + expect(result).to.equal('-Infinity'); + }); + + test('should correctly format 0 when padding is 0', () => { + const result = units.formatDuration(0, 0); + expect(result).to.equal('0.00'); + }); + + test('should correctly format negative durations', () => { + const result = units.formatDuration(-3600 - 120 - 3 - 0.4); + expect(result).to.equal('-01:02:03.40'); + }); + + test('should correctly format 59.9999', () => { + const result = units.formatDuration(59.9999); + expect(result).to.equal('00:01:00.00'); + }); + + test('should correctly format duration when extraDigits is false', () => { + let result = units.formatDuration(83, 0, 2, false); + expect(result).to.equal('1:23'); + + result = units.formatDuration(83.4, 0, 2, false); + expect(result).to.equal('1:23.4'); + + result = units.formatDuration(83.45, 0, 2, false); + expect(result).to.equal('1:23.45'); + + result = units.formatDuration(83.456, 0, 2, false); + expect(result).to.equal('1:23.46'); + + result = units.formatDuration(83.45, 0, 0, false); + expect(result).to.equal('1:23'); + }); +}); + +describe('formatPace method', () => { + test('should correctly format paces in a variety of units', () => { + let result = units.formatPace({ + distanceValue: 1, + distanceUnit: units.DistanceUnits.Meters, + time: 600, + }, units.PaceUnits.SecondsPerMeter); + expect(result).to.equal('10:00 s/m'); + + result = units.formatPace({ + distanceValue: 2, + distanceUnit: units.DistanceUnits.Kilometers, + time: 600, + }, units.PaceUnits.TimePerKilometer); + expect(result).to.equal('5:00 / km'); + + result = units.formatPace({ + distanceValue: 3, + distanceUnit: units.DistanceUnits.Miles, + time: 600, + }, units.PaceUnits.TimePerMile); + expect(result).to.equal('3:20 / mi'); + }); + + test('should correctly format paces that require distance conversion', () => { + let result = units.formatPace({ + distanceValue: 100, + distanceUnit: units.DistanceUnits.Meters, + time: 600, + }, units.PaceUnits.TimePerKilometer); + expect(result).to.equal('1:40:00 / km'); + + result = units.formatPace({ + distanceValue: 2, + distanceUnit: units.DistanceUnits.Kilometers, + time: 600, + }, units.PaceUnits.TimePerMile); + expect(result).to.equal('8:03 / mi'); + + result = units.formatPace({ + distanceValue: 0.03, + distanceUnit: units.DistanceUnits.Miles, + time: 600, + }, units.PaceUnits.SecondsPerMeter); + expect(result).to.equal('0:12 s/m'); + }); +}); diff --git a/tests/unit/core/utils.spec.js b/tests/unit/core/utils.spec.js @@ -0,0 +1,267 @@ +import { beforeEach, describe, test, expect } from 'vitest'; +import * as utils from '@/core/utils'; + +beforeEach(() => { + localStorage.clear(); +}); + +describe('deepCopy method', () => { + test('should deeply clone an object', () => { + let input = { + foo: 123, + bar: ['a', 'b', 'c'], + baz: { + baz: { + baz: { + baz: 'baz' + } + } + } + }; + let output = utils.deepCopy(input); + + // Output should equal input + expect(output).to.deep.equal(input); + + // Modifying input should not modify output + input.foo = 1234; + input.baz.baz.baz.baz = 'baz2'; + expect(output).to.deep.equal({ + foo: 123, + bar: ['a', 'b', 'c'], + baz: { + baz: { + baz: { + baz: 'baz' + } + } + } + }); + + // Modifying output should not modify input + output.foo = 12345; + output.baz.baz.baz.baz = 'baz3'; + expect(input).to.deep.equal({ + foo: 1234, + bar: ['a', 'b', 'c'], + baz: { + baz: { + baz: { + baz: 'baz2' + } + } + } + }); + }); +}); + +describe('deepEqual method', () => { + test('should correctly compare an object with itself', () => { + let obj1 = { + foo: 123, + bar: ['a', 'b', 'c'], + baz: { + baz: { + baz: { + baz: 'baz' + } + } + } + }; + + // obj1 should equal obj1 + expect(utils.deepEqual(obj1, obj1)).to.equal(true); + }); + + test('should correctly compare identical objects', () => { + let obj1 = { + foo: 123, + bar: ['a', 'b', 'c'], + baz: { + baz: { + baz: { + baz: 'baz' + } + } + } + }; + let obj2 = { + foo: 123, + bar: ['a', 'b', 'c'], + baz: { + baz: { + baz: { + baz: 'baz' + } + } + } + }; + + // obj1 should equal obj2 + expect(utils.deepEqual(obj1, obj2)).to.equal(true); + }); + + test('should correctly compare unequal objects', () => { + let obj1 = { + foo: 123, + bar: ['a', 'b', 'c'], + baz: { + baz: { + baz: { + baz: 'baz' + } + } + } + }; + let obj2 = { + foo: 123, + bar: ['a', 'b', 'c'], + baz: { + baz: { + baz: { + baz: 'baz2' + } + } + } + }; + + // obj1 should not equal obj2 + expect(utils.deepEqual(obj1, obj2)).to.equal(false); + }); +}); + +describe('get method', () => { + test('should correctly parse correct localStorage item', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.foo', '{"bar":123}'); + + // Assert result is correct + expect(utils.getLocalStorage('foo')).to.deep.equal({ bar: 123 }); + }); + + test('should return null for corrupt localStorage item', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.foo', 'invalid json'); + + // Assert result is correct + expect(utils.getLocalStorage('foo')).to.equal(null); + }); + + test('should return null for missing localStorage item', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.foo', '{"bar":123}'); + + // Assert result is correct + expect(utils.getLocalStorage('baz')).to.equal(null); + }); +}); + +describe('set method', () => { + test('should correctly set new localStorage item', async () => { + // Set localStorage item + utils.setLocalStorage('foo', { baz: 456 }); + + // Assert result is correct + expect(localStorage.getItem('running-tools.foo')).to.equal('{"baz":456}'); + }); + + test('should correctly override existing localStorage item', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.foo', '{"bar":123}'); + + // Set localStorage item + utils.setLocalStorage('foo', { baz: 456 }); + + // Assert result is correct + expect(localStorage.getItem('running-tools.foo')).to.equal('{"baz":456}'); + }); +}); + +describe('migrate method', () => { + test('should correctly migrate <=1.4.1 calculator options', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.pace-calculator-target-set', '"A"'); + localStorage.setItem('running-tools.race-calculator-options', + '{"model":"RiegelModel","riegelExponent":1.07}'); + localStorage.setItem('running-tools.race-calculator-target-set', '"B"'); + localStorage.setItem('running-tools.split-calculator-target-set', '"C"'); + localStorage.setItem('running-tools.workout-calculator-options', + '{"model":"RiegelModel","riegelExponent":1.08}'); + localStorage.setItem('running-tools.workout-calculator-target-set', '"D"'); + + // Run migrations + utils.migrateLocalStorage(); + + // Assert localStorage entries correctly migrated + expect(localStorage.getItem('running-tools.pace-calculator-options')).to.equal( + '{"selectedTargetSet":"A"}'); + expect(localStorage.getItem('running-tools.pace-calculator-target-set')).to.equal(null); + expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal( + '{"model":"RiegelModel","riegelExponent":1.07,"selectedTargetSet":"B"}'); + expect(localStorage.getItem('running-tools.race-calculator-target-set')).to.equal(null); + expect(localStorage.getItem('running-tools.split-calculator-options')).to.equal( + '{"selectedTargetSet":"C"}'); + expect(localStorage.getItem('running-tools.split-calculator-target-set')).to.equal(null); + expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal( + '{"model":"RiegelModel","riegelExponent":1.08,"selectedTargetSet":"D",' + + '"customTargetNames":false}'); + expect(localStorage.getItem('running-tools.workout-calculator-target-set')).to.equal(null); + }); + + test('should correctly migrate partial <=1.4.1 calculator options', async () => { + // Initialize localStorage (*-target-set options missing) + localStorage.setItem('running-tools.race-calculator-options', + '{"model":"RiegelModel","riegelExponent":1.07}'); + localStorage.setItem('running-tools.workout-calculator-options', + '{"model":"RiegelModel","riegelExponent":1.08}'); + + // Run migrations + utils.migrateLocalStorage(); + + // Assert localStorage entries correctly migrated + expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal( + '{"model":"RiegelModel","riegelExponent":1.07,"selectedTargetSet":"_race_targets"}'); + expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal( + '{"model":"RiegelModel","riegelExponent":1.08,"selectedTargetSet":"_workout_targets",' + + '"customTargetNames":false}'); + }); + + + test('should not modify >1.4.1 calculator options', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.pace-calculator-options', + '{"selectedTargetSet":"A"}'); + localStorage.setItem('running-tools.race-calculator-options', + '{"model":"RiegelModel","riegelExponent":1.07,"selectedTargetSet":"B"}'); + localStorage.setItem('running-tools.split-calculator-options', + '{"selectedTargetSet":"C"}'); + localStorage.setItem('running-tools.workout-calculator-options', + '{"customTargetNames":true,"model":"PurdyPointsModel","riegelExponent":1.08,' + + '"selectedTargetSet":"D"}'); + + // Run migrations + utils.migrateLocalStorage(); + + // Assert localStorage entries not modified + expect(localStorage.getItem('running-tools.pace-calculator-options')).to.equal( + '{"selectedTargetSet":"A"}'); + expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal( + '{"model":"RiegelModel","riegelExponent":1.07,"selectedTargetSet":"B"}'); + expect(localStorage.getItem('running-tools.split-calculator-options')).to.equal( + '{"selectedTargetSet":"C"}'); + expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal( + '{"customTargetNames":true,"model":"PurdyPointsModel","riegelExponent":1.08,' + + '"selectedTargetSet":"D"}'); + }); + + test('should not modify missing calculator options', async () => { + // Run migrations + utils.migrateLocalStorage(); + + // Assert localStorage entries not modified + expect(localStorage.getItem('running-tools.pace-calculator-options')).to.equal(null); + expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal(null); + expect(localStorage.getItem('running-tools.split-calculator-options')).to.equal(null); + expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal(null); + }); +}); diff --git a/tests/unit/utils/calculators.spec.js b/tests/unit/utils/calculators.spec.js @@ -1,255 +0,0 @@ -import { describe, test, expect } from 'vitest'; -import * as calculatorUtils from '@/utils/calculators'; - -describe('calculatePaceResults method', () => { - test('should correctly calculate pace times', () => { - const input = { - distanceValue: 1, - distanceUnit: 'kilometers', - time: 100, - }; - const target = { - distanceValue: 20, - distanceUnit: 'meters', - type: 'distance', - }; - - const result = calculatorUtils.calculatePaceResults(input, target, 'metric'); - - expect(result).to.deep.equal({ - key: '20 m', - value: '0:02.00', - pace: '1:40 / km', - result: 'value', - sort: 2, - }); - }); - - test('should correctly calculate pace distances according to default units setting', () => { - const input = { - distanceValue: 2, - distanceUnit: 'miles', - time: 1200, - }; - const target = { - time: 600, - type: 'time', - }; - - const result1 = calculatorUtils.calculatePaceResults(input, target, 'metric'); - const result2 = calculatorUtils.calculatePaceResults(input, target, 'imperial'); - - expect(result1.key).to.equal('1.61 km'); - expect(result1.value).to.equal('10:00'); - expect(result1.pace).to.equal('6:13 / km'); - expect(result1.result).to.equal('key'); - expect(result1.sort).to.be.closeTo(600, 0.01); - - expect(result2.key).to.equal('1.00 mi'); - expect(result2.value).to.equal('10:00'); - expect(result2.pace).to.equal('10:00 / mi'); - expect(result2.result).to.equal('key'); - expect(result2.sort).to.be.closeTo(600, 0.01); - }); -}); - -describe('calculateRaceResults method', () => { - test('should correctly predict race times', () => { - const input = { - distanceValue: 5, - distanceUnit: 'kilometers', - time: 1200, - }; - const target = { - distanceValue: 10, - distanceUnit: 'kilometers', - type: 'distance', - }; - const options = { - model: 'AverageModel', - riegelExponent: 1.06, - } - - const result = calculatorUtils.calculateRaceResults(input, target, options, 'imperial'); - - expect(result.key).to.equal('10 km'); - expect(result.value).to.equal('41:34.80'); - expect(result.pace).to.equal('6:41 / mi'); - expect(result.result).to.equal('value'); - expect(result.sort).to.be.closeTo(2494.80, 0.01); - }); - - test('should correctly calculate race distances according to default units setting', () => { - const input = { - distanceValue: 5, - distanceUnit: 'kilometers', - time: 1200, - }; - const target = { - time: 2495, - type: 'time', - }; - const options = { - model: 'AverageModel', - riegelExponent: 1.06, - } - - const result1 = calculatorUtils.calculateRaceResults(input, target, options, 'metric'); - const result2 = calculatorUtils.calculateRaceResults(input, target, options, 'imperial'); - - expect(result1.key).to.equal('10.00 km'); - expect(result1.value).to.equal('41:35'); - expect(result1.pace).to.equal('4:09 / km'); - expect(result1.result).to.equal('key'); - expect(result1.sort).to.equal(2495); - - expect(result2.key).to.equal('6.21 mi'); - expect(result2.value).to.equal('41:35'); - expect(result2.pace).to.equal('6:41 / mi'); - expect(result2.result).to.equal('key'); - expect(result2.sort).to.equal(2495); - }); - - test('should correctly predict race times according to race options', () => { - const input = { - distanceValue: 2, - distanceUnit: 'miles', - time: 630, - }; - const target = { - distanceValue: 5, - distanceUnit: 'kilometers', - type: 'distance', - }; - const options = { - model: 'RiegelModel', - riegelExponent: 1.12, - } - - const result = calculatorUtils.calculateRaceResults(input, target, options, 'imperial'); - - expect(result.key).to.equal('5 km'); - expect(result.value).to.equal('17:11.78'); - expect(result.pace).to.equal('5:32 / mi'); - expect(result.result).to.equal('value'); - expect(result.sort).to.be.closeTo(1031.77, 0.01); - }); -}); - -describe('calculateRaceStats method', () => { - test('should correctly calculate race statistics', () => { - const input = { - distanceValue: 5, - distanceUnit: 'kilometers', - time: 1200, - }; - - const results = calculatorUtils.calculateRaceStats(input); - - expect(results.purdyPoints).to.be.closeTo(454.5, 0.1); - expect(results.vo2).to.be.closeTo(47.4, 0.1); - expect(results.vo2MaxPercentage).to.be.closeTo(95.3, 0.1); - expect(results.vo2Max).to.be.closeTo(49.8, 0.1); - }); -}); - -describe('calculateWorkoutResults method', () => { - test('should correctly calculate distance-based workouts according to race options', () => { - const input = { - distanceValue: 2, - distanceUnit: 'miles', - time: 630, - }; - const target = { - distanceValue: 5, - distanceUnit: 'kilometers', // 5k split is ~17:11.77 - splitValue: 1000, - splitUnit: 'meters', - type: 'distance', - }; - const options = { - customTargetNames: false, - model: 'RiegelModel', - riegelExponent: 1.12, - } - - const result = calculatorUtils.calculateWorkoutResults(input, target, options); - - expect(result.key).to.equal('1000 m @ 5 km'); - expect(result.value).to.equal('3:26.36'); - expect(result.pace).to.equal(''); - expect(result.result).to.equal('value'); - expect(result.sort).to.be.closeTo(206.35, 0.01); - }); - - test('should correctly calculate distance-based workouts according to custom names', () => { - const input = { - distanceValue: 2, - distanceUnit: 'miles', - time: 630, - }; - const target_1 = { - distanceValue: 5, - distanceUnit: 'kilometers', // 5k split is ~17:11.77 - splitValue: 1000, - splitUnit: 'meters', - type: 'distance', - // no custom name - }; - const target_2 = { - distanceValue: 5, - distanceUnit: 'kilometers', // 5k split is ~17:11.77 - splitValue: 1000, - splitUnit: 'meters', - type: 'distance', - customName: 'my custom name', - }; - const options_a = { - customTargetNames: false, - model: 'RiegelModel', - riegelExponent: 1.12, - }; - const options_b = { - customTargetNames: true, - model: 'RiegelModel', - riegelExponent: 1.12, - }; - - const result1a = calculatorUtils.calculateWorkoutResults(input, target_1, options_a); - const result1b = calculatorUtils.calculateWorkoutResults(input, target_1, options_b); - const result2a = calculatorUtils.calculateWorkoutResults(input, target_2, options_a); - const result2b = calculatorUtils.calculateWorkoutResults(input, target_2, options_b); - - expect(result1a.key).to.equal('1000 m @ 5 km'); - expect(result1b.key).to.equal('1000 m @ 5 km'); - expect(result2a.key).to.equal('1000 m @ 5 km'); - expect(result2b.key).to.equal('my custom name'); - }); - - test('should correctly calculate time-based workouts', () => { - const input = { - distanceValue: 5, - distanceUnit: 'kilometers', - time: 1200, - }; - const target = { - time: 2495, // ~10k split is 41:35 - splitValue: 1, - splitUnit: 'miles', - type: 'time', - }; - const options = { - customTargetNames: false, - model: 'AverageModel', - riegelExponent: 1.06, - } - - const result = calculatorUtils.calculateWorkoutResults(input, target, options); - - expect(result.key).to.equal('1 mi @ 41:35'); - expect(result.value).to.equal('6:41.50'); - expect(result.pace).to.equal(''); - expect(result.result).to.equal('value'); - expect(result.sort).to.be.closeTo(401.50, 0.01); - }); -}); diff --git a/tests/unit/utils/misc.spec.js b/tests/unit/utils/misc.spec.js @@ -1,127 +0,0 @@ -import { describe, test, expect } from 'vitest'; -import * as miscUtils from '@/utils/misc'; - -describe('deepCopy method', () => { - test('should deeply clone an object', () => { - let input = { - foo: 123, - bar: ['a', 'b', 'c'], - baz: { - baz: { - baz: { - baz: 'baz' - } - } - } - }; - let output = miscUtils.deepCopy(input); - - // Output should equal input - expect(output).to.deep.equal(input); - - // Modifying input should not modify output - input.foo = 1234; - input.baz.baz.baz.baz = 'baz2'; - expect(output).to.deep.equal({ - foo: 123, - bar: ['a', 'b', 'c'], - baz: { - baz: { - baz: { - baz: 'baz' - } - } - } - }); - - // Modifying output should not modify input - output.foo = 12345; - output.baz.baz.baz.baz = 'baz3'; - expect(input).to.deep.equal({ - foo: 1234, - bar: ['a', 'b', 'c'], - baz: { - baz: { - baz: { - baz: 'baz2' - } - } - } - }); - }); -}); - -describe('deepEqual method', () => { - test('should correctly compare an object with itself', () => { - let obj1 = { - foo: 123, - bar: ['a', 'b', 'c'], - baz: { - baz: { - baz: { - baz: 'baz' - } - } - } - }; - - // obj1 should equal obj1 - expect(miscUtils.deepEqual(obj1, obj1)).to.equal(true); - }); - - test('should correctly compare identical objects', () => { - let obj1 = { - foo: 123, - bar: ['a', 'b', 'c'], - baz: { - baz: { - baz: { - baz: 'baz' - } - } - } - }; - let obj2 = { - foo: 123, - bar: ['a', 'b', 'c'], - baz: { - baz: { - baz: { - baz: 'baz' - } - } - } - }; - - // obj1 should equal obj2 - expect(miscUtils.deepEqual(obj1, obj2)).to.equal(true); - }); - - test('should correctly compare unequal objects', () => { - let obj1 = { - foo: 123, - bar: ['a', 'b', 'c'], - baz: { - baz: { - baz: { - baz: 'baz' - } - } - } - }; - let obj2 = { - foo: 123, - bar: ['a', 'b', 'c'], - baz: { - baz: { - baz: { - baz: 'baz2' - } - } - } - }; - - // obj1 should not equal obj2 - expect(miscUtils.deepEqual(obj1, obj2)).to.equal(false); - }); -}); diff --git a/tests/unit/utils/races.spec.js b/tests/unit/utils/races.spec.js @@ -1,166 +0,0 @@ -import { describe, test, expect } from 'vitest'; -import * as raceUtils from '@/utils/races'; - -describe('predictTime method', () => { - describe('PredictTime method', () => { - test('Average Model', () => { - const riegel = raceUtils.predictTime(5000, 1200, 10000, 'AverageModel'); - const cameron = raceUtils.predictTime(5000, 1200, 10000, 'AverageModel'); - const purdyPoints = raceUtils.predictTime(5000, 1200, 10000, 'AverageModel'); - const vo2Max = raceUtils.predictTime(5000, 1200, 10000, 'AverageModel'); - const expected = (riegel + cameron + purdyPoints + vo2Max) / 4; - - const result = raceUtils.predictTime(5000, 1200, 10000, 'AverageModel'); - expect(result).to.equal(expected); - }); - - test('Should predict identical times for itentical distances', () => { - const result = raceUtils.predictTime(5000, 1200, 5000, 'AverageModel'); - expect(result).to.be.closeTo(1200, 0.001); - }); - }); - - describe('Purdy Points Model', () => { - test('Predictions should be approximately correct', () => { - const result = raceUtils.predictTime(5000, 1200, 10000, 'PurdyPointsModel'); - expect(result).to.be.closeTo(2490, 1); - }); - - test('Should predict identical times for itentical distances', () => { - const result = raceUtils.predictTime(5000, 1200, 5000, 'PurdyPointsModel'); - expect(result).to.be.closeTo(1200, 0.001); - }); - }); - - describe('VO2 Max Model', () => { - test('Predictions should be approximately correct', () => { - const result = raceUtils.predictTime(5000, 1200, 10000, 'VO2MaxModel'); - expect(result).to.be.closeTo(2488, 1); - }); - - test('Should predict identical times for itentical distances', () => { - const result = raceUtils.predictTime(5000, 1200, 5000, 'VO2MaxModel'); - expect(result).to.be.closeTo(1200, 0.001); - }); - }); - - describe('Cameron Model', () => { - test('Predictions should be approximately correct', () => { - const result = raceUtils.predictTime(5000, 1200, 10000, 'CameronModel'); - expect(result).to.be.closeTo(2500, 1); - }); - - test('Should predict identical times for itentical distances', () => { - const result = raceUtils.predictTime(5000, 1200, 5000, 'CameronModel'); - expect(result).to.be.closeTo(1200, 0.001); - }); - }); - - describe('Riegel Model', () => { - test('Predictions should be approximately correct', () => { - const result = raceUtils.predictTime(5000, 1200, 10000, 'RiegelModel'); - expect(result).to.be.closeTo(2502, 1); - }); - - test('Should predict identical times for itentical distances', () => { - const result = raceUtils.predictTime(5000, 1200, 5000, 'RiegelModel'); - expect(result).to.be.closeTo(1200, 0.001); - }); - }); -}); - -describe('predictDistance method', () => { - describe('Average Model', () => { - test('Predictions should be correct', () => { - const riegel = raceUtils.predictTime(5000, 1200, 10000, 'AverageModel'); - const cameron = raceUtils.predictTime(5000, 1200, 10000, 'AverageModel'); - const purdyPoints = raceUtils.predictTime(5000, 1200, 10000, 'AverageModel'); - const vo2Max = raceUtils.predictTime(5000, 1200, 10000, 'AverageModel'); - const expected = (riegel + cameron + purdyPoints + vo2Max) / 4; - - const result = raceUtils.predictDistance(1200, 5000, expected); - expect(result).to.be.closeTo(10000, 10); - }); - - test('Should predict identical times for itentical distances', () => { - const result = raceUtils.predictDistance(1200, 5000, 1200, 'AverageModel'); - expect(result).to.be.closeTo(5000, 0.001); - }); - }); - - describe('Purdy Points Model', () => { - test('Predictions should be approximately correct', () => { - const result = raceUtils.predictDistance(1200, 5000, 2490, 'PurdyPointsModel'); - expect(result).to.be.closeTo(10000, 10); - }); - - test('Should predict identical times for itentical distances', () => { - const result = raceUtils.predictDistance(1200, 5000, 1200, 'PurdyPointsModel'); - expect(result).to.be.closeTo(5000, 0.001); - }); - }); - - describe('VO2 Max Model', () => { - test('Predictions should be approximately correct', () => { - const result = raceUtils.predictDistance(1200, 5000, 2488, 'VO2MaxModel'); - expect(result).to.be.closeTo(10000, 10); - }); - - test('Should predict identical times for itentical distances', () => { - const result = raceUtils.predictDistance(1200, 5000, 1200, 'VO2MaxModel'); - expect(result).to.be.closeTo(5000, 0.001); - }); - }); - - describe('Cameron Model', () => { - test('Predictions should be approximately correct', () => { - const result = raceUtils.predictDistance(1200, 5000, 2500, 'CameronModel'); - expect(result).to.be.closeTo(10000, 10); - }); - - test('Should predict identical times for itentical distances', () => { - const result = raceUtils.predictDistance(1200, 5000, 1200, 'CameronModel'); - expect(result).to.be.closeTo(5000, 0.001); - }); - }); - - describe('Riegel Model', () => { - test('Predictions should be approximately correct', () => { - const result = raceUtils.predictDistance(1200, 5000, 2502, 'RiegelModel'); - expect(result).to.be.closeTo(10000, 10); - }); - - test('Should predict identical times for itentical distances', () => { - const result = raceUtils.predictDistance(1200, 5000, 1200, 'RiegelModel'); - expect(result).to.be.closeTo(5000, 0.001); - }); - }); -}); - -describe('getVO2 method', () => { - test('Result should be approximately correct', () => { - const result = raceUtils.getVO2(5000, 1200); - expect(result).to.be.closeTo(47.4, 0.1); - }); -}); - -describe('getVO2Percentage method', () => { - test('Result should be approximately correct', () => { - const result = raceUtils.getVO2Percentage(660); - expect(result).to.be.closeTo(1, 0.001); - }); -}); - -describe('getVO2Max method', () => { - test('Result should be approximately correct', () => { - const result = raceUtils.getVO2Max(5000, 1200); - expect(result).to.be.closeTo(49.8, 0.1); - }); -}); - -describe('getPurdyPoints method', () => { - test('Result should be approximately correct', () => { - const result = raceUtils.getPurdyPoints(5000, 1200); - expect(result).to.be.closeTo(454, 1); - }); -}); diff --git a/tests/unit/utils/storage.spec.js b/tests/unit/utils/storage.spec.js @@ -1,142 +0,0 @@ -import { beforeEach, describe, test, expect } from 'vitest'; -import * as storage from '@/utils/storage'; - -beforeEach(() => { - localStorage.clear(); -}) - -describe('get method', () => { - test('should correctly parse correct localStorage item', async () => { - // Initialize localStorage - localStorage.setItem('running-tools.foo', '{"bar":123}'); - - // Assert result is correct - expect(storage.get('foo')).to.deep.equal({ bar: 123 }); - }); - - test('should return null for corrupt localStorage item', async () => { - // Initialize localStorage - localStorage.setItem('running-tools.foo', 'invalid json'); - - // Assert result is correct - expect(storage.get('foo')).to.equal(null); - }); - - test('should return null for missing localStorage item', async () => { - // Initialize localStorage - localStorage.setItem('running-tools.foo', '{"bar":123}'); - - // Assert result is correct - expect(storage.get('baz')).to.equal(null); - }); -}); - -describe('set method', () => { - test('should correctly set new localStorage item', async () => { - // Set localStorage item - storage.set('foo', { baz: 456 }); - - // Assert result is correct - expect(localStorage.getItem('running-tools.foo')).to.equal('{"baz":456}'); - }); - - test('should correctly override existing localStorage item', async () => { - // Initialize localStorage - localStorage.setItem('running-tools.foo', '{"bar":123}'); - - // Set localStorage item - storage.set('foo', { baz: 456 }); - - // Assert result is correct - expect(localStorage.getItem('running-tools.foo')).to.equal('{"baz":456}'); - }); -}); - -describe('migrate method', () => { - test('should correctly migrate <=1.4.1 calculator options', async () => { - // Initialize localStorage - localStorage.setItem('running-tools.pace-calculator-target-set', '"A"'); - localStorage.setItem('running-tools.race-calculator-options', - '{"model":"RiegelModel","riegelExponent":1.07}'); - localStorage.setItem('running-tools.race-calculator-target-set', '"B"'); - localStorage.setItem('running-tools.split-calculator-target-set', '"C"'); - localStorage.setItem('running-tools.workout-calculator-options', - '{"model":"RiegelModel","riegelExponent":1.08}'); - localStorage.setItem('running-tools.workout-calculator-target-set', '"D"'); - - // Run migrations - storage.migrate(); - - // Assert localStorage entries correctly migrated - expect(localStorage.getItem('running-tools.pace-calculator-options')).to.equal( - '{"selectedTargetSet":"A"}'); - expect(localStorage.getItem('running-tools.pace-calculator-target-set')).to.equal(null); - expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal( - '{"model":"RiegelModel","riegelExponent":1.07,"selectedTargetSet":"B"}'); - expect(localStorage.getItem('running-tools.race-calculator-target-set')).to.equal(null); - expect(localStorage.getItem('running-tools.split-calculator-options')).to.equal( - '{"selectedTargetSet":"C"}'); - expect(localStorage.getItem('running-tools.split-calculator-target-set')).to.equal(null); - expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal( - '{"model":"RiegelModel","riegelExponent":1.08,"selectedTargetSet":"D",' + - '"customTargetNames":false}'); - expect(localStorage.getItem('running-tools.workout-calculator-target-set')).to.equal(null); - }); - - test('should correctly migrate partial <=1.4.1 calculator options', async () => { - // Initialize localStorage (*-target-set options missing) - localStorage.setItem('running-tools.race-calculator-options', - '{"model":"RiegelModel","riegelExponent":1.07}'); - localStorage.setItem('running-tools.workout-calculator-options', - '{"model":"RiegelModel","riegelExponent":1.08}'); - - // Run migrations - storage.migrate(); - - // Assert localStorage entries correctly migrated - expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal( - '{"model":"RiegelModel","riegelExponent":1.07,"selectedTargetSet":"_race_targets"}'); - expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal( - '{"model":"RiegelModel","riegelExponent":1.08,"selectedTargetSet":"_workout_targets",' + - '"customTargetNames":false}'); - }); - - - test('should not modify >1.4.1 calculator options', async () => { - // Initialize localStorage - localStorage.setItem('running-tools.pace-calculator-options', - '{"selectedTargetSet":"A"}'); - localStorage.setItem('running-tools.race-calculator-options', - '{"model":"RiegelModel","riegelExponent":1.07,"selectedTargetSet":"B"}'); - localStorage.setItem('running-tools.split-calculator-options', - '{"selectedTargetSet":"C"}'); - localStorage.setItem('running-tools.workout-calculator-options', - '{"customTargetNames":true,"model":"PurdyPointsModel","riegelExponent":1.08,' + - '"selectedTargetSet":"D"}'); - - // Run migrations - storage.migrate(); - - // Assert localStorage entries not modified - expect(localStorage.getItem('running-tools.pace-calculator-options')).to.equal( - '{"selectedTargetSet":"A"}'); - expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal( - '{"model":"RiegelModel","riegelExponent":1.07,"selectedTargetSet":"B"}'); - expect(localStorage.getItem('running-tools.split-calculator-options')).to.equal( - '{"selectedTargetSet":"C"}'); - expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal( - '{"customTargetNames":true,"model":"PurdyPointsModel","riegelExponent":1.08,' + - '"selectedTargetSet":"D"}'); - }); - - test('should not modify missing calculator options', async () => { - // Run migrations - storage.migrate(); - - // Assert localStorage entries not modified - expect(localStorage.getItem('running-tools.pace-calculator-options')).to.equal(null); - expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal(null); - expect(localStorage.getItem('running-tools.split-calculator-options')).to.equal(null); - expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal(null); - }); -}); diff --git a/tests/unit/utils/targets.spec.js b/tests/unit/utils/targets.spec.js @@ -1,59 +0,0 @@ -import { describe, test, expect } from 'vitest'; -import * as targets from '@/utils/targets'; - -describe('sort method', () => { - test('should correctly sort targets', () => { - // Initialize unsorted and sorted targets - const input = [ - { time: 60, type: 'time' }, - { distanceUnit: 'kilometers', distanceValue: 5, type: 'distance' }, - { distanceUnit: 'miles', distanceValue: 3, type: 'distance' }, - ]; - const expected = [ - { distanceUnit: 'miles', distanceValue: 3, type: 'distance' }, - { distanceUnit: 'kilometers', distanceValue: 5, type: 'distance' }, - { time: 60, type: 'time' }, - ]; - - // Assert sort method sorts targets correctly - expect(targets.sort(input)).to.deep.equal(expected); - }); -}); - -describe('workoutTargetToString method', () => { - test('should correctly stringify time target', () => { - // Initialize original and stringified target - const input = { - splitValue: 1600, splitUnit: 'meters', - type: 'time', time: 3600, - }; - const expected = '1600 m @ 1:00:00'; - - // Assert sort method sorts targets correctly - expect(targets.workoutTargetToString(input)).to.deep.equal(expected); - }); - - test('should correctly stringify distance target', () => { - // Initialize original and stringified target - const input = { - splitValue: 800, splitUnit: 'meters', - type: 'distance', distanceValue: 5, distanceUnit: 'kilometers', - }; - const expected = '800 m @ 5 km'; - - // Assert sort method sorts targets correctly - expect(targets.workoutTargetToString(input)).to.deep.equal(expected); - }); - - test('should correctly stringify race target', () => { - // Initialize original and stringified target - const input = { - splitValue: 5, splitUnit: 'kilometers', - type: 'distance', distanceValue: 5, distanceUnit: 'kilometers', - }; - const expected = '5 km'; - - // Assert sort method sorts targets correctly - expect(targets.workoutTargetToString(input)).to.deep.equal(expected); - }); -}); diff --git a/tests/unit/utils/units.spec.js b/tests/unit/utils/units.spec.js @@ -1,439 +0,0 @@ -import { describe, test, expect } from 'vitest'; -import * as units from '@/utils/units'; - -describe('convertTime method', () => { - test('90 seconds should equal 1.5 minutes', () => { - const result = units.convertTime(90, 'seconds', 'minutes'); - expect(result).to.equal(1.5); - }); - - test('1.5 minutes should equal 95 seconds', () => { - const result = units.convertTime(1.5, 'minutes', 'seconds'); - expect(result).to.equal(90); - }); -}); - -describe('convertDistance method', () => { - test('100 meters should equal 0.1 kilometers', () => { - const result = units.convertDistance(100, 'meters', 'kilometers'); - expect(result).to.equal(0.1); - }); - - test('0.1 kilometers should equal 100 meters', () => { - const result = units.convertDistance(0.1, 'kilometers', 'meters'); - expect(result).to.equal(100); - }); -}); - -describe('convertSpeed method', () => { - test('1000 meters per seconds should equal 3600 kilometers per hour', () => { - const result = units.convertSpeed(1000, 'meters_per_second', 'kilometers_per_hour'); - expect(result).to.equal(3600); - }); - - test('3600 kilometers per hour should equal 1000 meters per second', () => { - const result = units.convertSpeed(3600, 'kilometers_per_hour', 'meters_per_second'); - expect(result).to.equal(1000); - }); -}); - -describe('convertPace method', () => { - test('1 second per meter should equal 1000 seconds per kilometer', () => { - const result = units.convertPace(1, 'seconds_per_meter', 'seconds_per_kilometer'); - expect(result).to.equal(1000); - }); - - test('1000 seconds per kilometer should equal 1 second per meter', () => { - const result = units.convertPace(1000, 'seconds_per_kilometer', 'seconds_per_meter'); - expect(result).to.equal(1); - }); -}); - -describe('convertSpeedPace method', () => { - test('3600 kilometers per hour should equal 1 second per kilometer', () => { - const result = units.convertSpeedPace(3600, 'kilometers_per_hour', 'seconds_per_kilometer'); - expect(result).to.equal(1); - }); - - test('1 second per kilometer should equal 3600 kilometers per hour', () => { - const result = units.convertSpeedPace(3600, 'seconds_per_kilometer', 'kilometers_per_hour'); - expect(result).to.equal(1); - }); -}); - -describe('formatNumber method', () => { - test('should correctly format number when padding is not 0', () => { - let result = units.formatNumber(12.3, 3, 0); - expect(result).to.equal('012'); - - result = units.formatNumber(12.3, 3, 2); - expect(result).to.equal('012.30'); - - result = units.formatNumber(123, 2, 0); - expect(result).to.equal('123'); - - result = units.formatNumber(-12, 3, 0); - expect(result).to.equal('-012'); - }); - - test('should correctly format number when extraDigits is true', () => { - let result = units.formatNumber(1234, 0, 2); - expect(result).to.equal('1234.00'); - - result = units.formatNumber(1234.5, 0, 2); - expect(result).to.equal('1234.50'); - - result = units.formatNumber(1234.56, 0, 2); - expect(result).to.equal('1234.56'); - - result = units.formatNumber(1234.567, 0, 2); - expect(result).to.equal('1234.57'); - - result = units.formatNumber(1234.56, 0, 0); - expect(result).to.equal('1235'); - }); - - test('should correctly format number when extraDigits is false', () => { - let result = units.formatNumber(1234, 0, 2, false); - expect(result).to.equal('1234'); - - result = units.formatNumber(1234.5, 0, 2, false); - expect(result).to.equal('1234.5'); - - result = units.formatNumber(1234.56, 0, 2, false); - expect(result).to.equal('1234.56'); - - result = units.formatNumber(1234.567, 0, 2, false); - expect(result).to.equal('1234.57'); - - result = units.formatNumber(1234.56, 0, 0, false); - expect(result).to.equal('1235'); - }); - - test('should correctly format undefined', () => { - let result = units.formatNumber(undefined, 0, 2); - expect(result).to.equal('NaN'); - - result = units.formatNumber(undefined, 0, 2, false); - expect(result).to.equal('NaN'); - - result = units.formatNumber(undefined, 5, 2); - expect(result).to.equal('NaN'); - }); - - test('should correctly format NaN', () => { - let result = units.formatNumber(NaN, 0, 0); - expect(result).to.equal('NaN'); - - result = units.formatNumber(NaN, 0, 2, false); - expect(result).to.equal('NaN'); - - result = units.formatNumber(NaN, 5, 2); - expect(result).to.equal('NaN'); - }); - - test('should correctly format +/- Infinity', () => { - let result = units.formatNumber(Infinity); - expect(result).to.equal('Infinity'); - - result = units.formatNumber(Infinity, 10, 2); - expect(result).to.equal('Infinity'); - - result = units.formatNumber(-Infinity); - expect(result).to.equal('-Infinity'); - }); - - test('should correctly format numbers smaller than 1', () => { - let result = units.formatNumber(0.123, 0, 0); - expect(result).to.equal('0'); - - result = units.formatNumber(0.123, 0, 2); - expect(result).to.equal('0.12'); - }); - - test('should correctly format negative numbers', () => { - let result = units.formatNumber(-12, 0, 2, false); - expect(result).to.equal('-12'); - - result = units.formatNumber(-12, 0, 2); - expect(result).to.equal('-12.00'); - - result = units.formatNumber(-12.34, 0, 2); - expect(result).to.equal('-12.34'); - - result = units.formatNumber(-12.34, 3, 2); - expect(result).to.equal('-012.34'); - - result = units.formatNumber(-0.12, 0, 2); - expect(result).to.equal('-0.12'); - }); -}); - -describe('formatDistance method', () => { - test('should correctly format distances with a variety of units', () => { - let result = units.formatDistance({ - distanceValue: 1, - distanceUnit: units.DistanceUnits.Yards, - }, false); - expect(result).to.equal('1 yd'); - - result = units.formatDistance({ - distanceValue: 2, - distanceUnit: units.DistanceUnits.Meters, - }, false); - expect(result).to.equal('2 m'); - - result = units.formatDistance({ - distanceValue: 3, - distanceUnit: units.DistanceUnits.Kilometers, - }, false); - expect(result).to.equal('3 km'); - - result = units.formatDistance({ - distanceValue: 4, - distanceUnit: units.DistanceUnits.Miles, - }, false); - expect(result).to.equal('4 mi'); - - result = units.formatDistance({ - distanceValue: 5, - distanceUnit: units.DistanceUnits.Marathons, - }, false); - expect(result).to.equal('5 Mar'); - }); - - test('should correctly format distance when extraDigits is true', () => { - let result = units.formatDistance({ - distanceValue: 1234, - distanceUnit: units.DistanceUnits.Miles, - }, true); - expect(result).to.equal('1234.00 mi'); - - result = units.formatDistance({ - distanceValue: 1234.5, - distanceUnit: units.DistanceUnits.Miles, - }, true); - expect(result).to.equal('1234.50 mi'); - - result = units.formatDistance({ - distanceValue: 1234.56, - distanceUnit: units.DistanceUnits.Miles, - }, true); - expect(result).to.equal('1234.56 mi'); - - result = units.formatDistance({ - distanceValue: 1234.567, - distanceUnit: units.DistanceUnits.Miles, - }, true); - expect(result).to.equal('1234.57 mi'); - }); - - test('should correctly format distance when extraDigits is false', () => { - let result = units.formatDistance({ - distanceValue: 1234, - distanceUnit: units.DistanceUnits.Miles, - }, false); - expect(result).to.equal('1234 mi'); - - result = units.formatDistance({ - distanceValue: 1234.5, - distanceUnit: units.DistanceUnits.Miles, - }, false); - expect(result).to.equal('1234.5 mi'); - - result = units.formatDistance({ - distanceValue: 1234.56, - distanceUnit: units.DistanceUnits.Miles, - }, false); - expect(result).to.equal('1234.56 mi'); - - result = units.formatDistance({ - distanceValue: 1234.567, - distanceUnit: units.DistanceUnits.Miles, - }, false); - expect(result).to.equal('1234.57 mi'); - }); - - test('should correctly format distances smaller than 1', () => { - let result = units.formatDistance({ - distanceValue: 0, - distanceUnit: units.DistanceUnits.Miles, - }, false); - expect(result).to.equal('0 mi'); - - result = units.formatDistance({ - distanceValue: 0, - distanceUnit: units.DistanceUnits.Miles, - }, true); - expect(result).to.equal('0.00 mi'); - - result = units.formatDistance({ - distanceValue: 0.1, - distanceUnit: units.DistanceUnits.Miles, - }, false); - expect(result).to.equal('0.1 mi'); - - result = units.formatDistance({ - distanceValue: 0.1, - distanceUnit: units.DistanceUnits.Miles, - }, true); - expect(result).to.equal('0.10 mi'); - - result = units.formatDistance({ - distanceValue: 0.12, - distanceUnit: units.DistanceUnits.Miles, - }, false); - expect(result).to.equal('0.12 mi'); - }); - - test('should correctly format negative distances', () => { - let result = units.formatDistance({ - distanceValue: -1234, - distanceUnit: units.DistanceUnits.Miles, - }, false); - expect(result).to.equal('-1234 mi'); - - result = units.formatDistance({ - distanceValue: -1234, - distanceUnit: units.DistanceUnits.Miles, - }, true); - expect(result).to.equal('-1234.00 mi'); - - result = units.formatDistance({ - distanceValue: -1234.56, - distanceUnit: units.DistanceUnits.Miles, - }, false); - expect(result).to.equal('-1234.56 mi'); - }); -}); - -describe('formatDuration method', () => { - test('should correctly divide durations into parts', () => { - const result = units.formatDuration(3600 + 120 + 3 + 0.4); - expect(result).to.equal('01:02:03.40'); - }); - - test('should correctly format duration when padding is 7', () => { - const result = units.formatDuration(3600 + 120 + 3 + 0.4, 7); - expect(result).to.equal('01:02:03.40'); - }); - - test('should correctly format duration when padding is 3', () => { - let result = units.formatDuration(3600 + 120 + 3 + 0.4, 3); - expect(result).to.equal('1:02:03.40'); - - result = units.formatDuration(120 + 3 + 0.4, 3); - expect(result).to.equal('2:03.40'); - - result = units.formatDuration(3 + 0.4, 3); - expect(result).to.equal('0:03.40'); - }); - - test('should correctly format duration when padding is 0', () => { - const result = units.formatDuration(0.4, 0); - expect(result).to.equal('0.40'); - }); - - test('should correctly format duration when digits is 3', () => { - const result = units.formatDuration(3600 + 120 + 3 + 0.4567, 0, 3); - expect(result).to.equal('1:02:03.457'); - }); - - test('should correctly format duration when digits is 0', () => { - const result = units.formatDuration(3600 + 120 + 3 + 0.456, 0, 0); - expect(result).to.equal('1:02:03'); - }); - - test('should correctly format NaN', () => { - const result = units.formatDuration(NaN); - expect(result).to.equal('NaN'); - }); - - test('should correctly format +/- Infinity', () => { - let result = units.formatDuration(Infinity); - expect(result).to.equal('Infinity'); - - result = units.formatDuration(-Infinity); - expect(result).to.equal('-Infinity'); - }); - - test('should correctly format 0 when padding is 0', () => { - const result = units.formatDuration(0, 0); - expect(result).to.equal('0.00'); - }); - - test('should correctly format negative durations', () => { - const result = units.formatDuration(-3600 - 120 - 3 - 0.4); - expect(result).to.equal('-01:02:03.40'); - }); - - test('should correctly format 59.9999', () => { - const result = units.formatDuration(59.9999); - expect(result).to.equal('00:01:00.00'); - }); - - test('should correctly format duration when extraDigits is false', () => { - let result = units.formatDuration(83, 0, 2, false); - expect(result).to.equal('1:23'); - - result = units.formatDuration(83.4, 0, 2, false); - expect(result).to.equal('1:23.4'); - - result = units.formatDuration(83.45, 0, 2, false); - expect(result).to.equal('1:23.45'); - - result = units.formatDuration(83.456, 0, 2, false); - expect(result).to.equal('1:23.46'); - - result = units.formatDuration(83.45, 0, 0, false); - expect(result).to.equal('1:23'); - }); -}); - -describe('formatPace method', () => { - test('should correctly format paces in a variety of units', () => { - let result = units.formatPace({ - distanceValue: 1, - distanceUnit: units.DistanceUnits.Meters, - time: 600, - }, units.PaceUnits.SecondsPerMeter); - expect(result).to.equal('10:00 s/m'); - - result = units.formatPace({ - distanceValue: 2, - distanceUnit: units.DistanceUnits.Kilometers, - time: 600, - }, units.PaceUnits.TimePerKilometer); - expect(result).to.equal('5:00 / km'); - - result = units.formatPace({ - distanceValue: 3, - distanceUnit: units.DistanceUnits.Miles, - time: 600, - }, units.PaceUnits.TimePerMile); - expect(result).to.equal('3:20 / mi'); - }); - - test('should correctly format paces that require distance conversion', () => { - let result = units.formatPace({ - distanceValue: 100, - distanceUnit: units.DistanceUnits.Meters, - time: 600, - }, units.PaceUnits.TimePerKilometer); - expect(result).to.equal('1:40:00 / km'); - - result = units.formatPace({ - distanceValue: 2, - distanceUnit: units.DistanceUnits.Kilometers, - time: 600, - }, units.PaceUnits.TimePerMile); - expect(result).to.equal('8:03 / mi'); - - result = units.formatPace({ - distanceValue: 0.03, - distanceUnit: units.DistanceUnits.Miles, - time: 600, - }, units.PaceUnits.SecondsPerMeter); - expect(result).to.equal('0:12 s/m'); - }); -}); diff --git a/tests/unit/views/PaceCalculator.spec.js b/tests/unit/views/PaceCalculator.spec.js @@ -1,7 +1,7 @@ import { beforeEach, test, expect } from 'vitest'; import { shallowMount } from '@vue/test-utils'; import PaceCalculator from '@/views/PaceCalculator.vue'; -import { defaultTargetSets } from '@/utils/targets'; +import { defaultTargetSets } from '@/core/targets'; beforeEach(() => { localStorage.clear(); diff --git a/tests/unit/views/RaceCalculator.spec.js b/tests/unit/views/RaceCalculator.spec.js @@ -1,7 +1,7 @@ import { beforeEach, test, expect } from 'vitest'; import { shallowMount } from '@vue/test-utils'; import RaceCalculator from '@/views/RaceCalculator.vue'; -import { defaultTargetSets } from '@/utils/targets'; +import { defaultTargetSets } from '@/core/targets'; beforeEach(() => { localStorage.clear(); diff --git a/tests/unit/views/WorkoutCalculator.spec.js b/tests/unit/views/WorkoutCalculator.spec.js @@ -1,7 +1,7 @@ import { beforeEach, test, expect } from 'vitest'; import { shallowMount } from '@vue/test-utils'; import WorkoutCalculator from '@/views/WorkoutCalculator.vue'; -import { defaultTargetSets } from '@/utils/targets'; +import { defaultTargetSets } from '@/core/targets'; beforeEach(() => { localStorage.clear();