running-tools

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

commit 1828052daf6a5ed1eb59f78af31dd120f0232755
parent 60bce6002bc55339e17e058da596529bd614a49c
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Sun, 22 Jun 2025 08:27:02 -0700

Convert most components to TypeScript

Diffstat:
Meslint.config.js | 8++++++--
Mpackage-lock.json | 29+++++++++++++++++++++++++++--
Mpackage.json | 1+
Msrc/components/DecimalInput.vue | 14+++++++-------
Msrc/components/DoubleOutputTable.vue | 14++++++++------
Msrc/components/IntegerInput.vue | 12++++++------
Msrc/components/PaceInput.vue | 20++++++++++++++------
Msrc/components/RaceOptions.vue | 16+++++++++++++---
Msrc/components/SplitOutputTable.vue | 2+-
Msrc/components/TargetEditor.vue | 2+-
Msrc/components/TargetSetSelector.vue | 13++++++++-----
Msrc/components/TimeInput.vue | 10+++++-----
Msrc/composables/useObjectModel.ts | 19++++++++-----------
Msrc/utils/misc.ts | 12++++++------
Msrc/utils/units.ts | 8++++----
15 files changed, 115 insertions(+), 65 deletions(-)

diff --git a/eslint.config.js b/eslint.config.js @@ -1,11 +1,14 @@ -import { defineConfig, globalIgnores } from 'eslint/config' +import { globalIgnores } from 'eslint/config' +import { configureVueProject, defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript' import globals from 'globals' import js from '@eslint/js' import ts from 'typescript-eslint' import pluginVue from 'eslint-plugin-vue' import pluginPlaywright from 'eslint-plugin-playwright' -export default defineConfig([ +configureVueProject({ scriptLangs: ['ts', 'js'] }) + +export default defineConfigWithVueTs([ { name: 'app/files-to-lint', files: ['**/*.{js,mjs,jsx,vue}'], @@ -25,6 +28,7 @@ export default defineConfig([ js.configs.recommended, ts.configs.recommended, ...pluginVue.configs['flat/essential'], + vueTsConfigs.recommended, { ...pluginPlaywright.configs['flat/recommended'], diff --git a/package-lock.json b/package-lock.json @@ -19,6 +19,7 @@ "@tsconfig/node22": "^22.0.2", "@types/node": "^24.0.3", "@vitejs/plugin-vue": "^5.2.4", + "@vue/eslint-config-typescript": "^14.5.1", "@vue/test-utils": "^2.4.6", "@vue/tsconfig": "^0.7.0", "eslint": "^9.22.0", @@ -3577,6 +3578,32 @@ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "license": "MIT" }, + "node_modules/@vue/eslint-config-typescript": { + "version": "14.5.1", + "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.5.1.tgz", + "integrity": "sha512-ys6qdYHGXS/WLt0r5vUcTiG163F4NbNpx3ABTsGITw8k5uCFiv4g9E1N9Jydlw62KzJMVKGcpXbg6LCA3fV+eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.34.1", + "fast-glob": "^3.3.3", + "typescript-eslint": "^8.34.1", + "vue-eslint-parser": "^10.1.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.10.0", + "eslint-plugin-vue": "^9.28.0 || ^10.0.0", + "typescript": ">=4.8.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@vue/language-core": { "version": "2.2.10", "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.10.tgz", @@ -10151,7 +10178,6 @@ "integrity": "sha512-dbCBnd2e02dYWsXoqX5yKUZlOt+ExIpq7hmHKPb5ZqKcjf++Eo0hMseFTZMLKThrUk61m+Uv6A2YSBve6ZvuDQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", @@ -10177,7 +10203,6 @@ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, diff --git a/package.json b/package.json @@ -30,6 +30,7 @@ "@tsconfig/node22": "^22.0.2", "@types/node": "^24.0.3", "@vitejs/plugin-vue": "^5.2.4", + "@vue/eslint-config-typescript": "^14.5.1", "@vue/test-utils": "^2.4.6", "@vue/tsconfig": "^0.7.0", "eslint": "^9.22.0", diff --git a/src/components/DecimalInput.vue b/src/components/DecimalInput.vue @@ -2,7 +2,7 @@ <input ref="inputElement" type="number" step="any" required @blur="onblur" v-model="stringValue"> </template> -<script setup> +<script setup lang="ts"> import { ref, watch } from 'vue'; import { formatNumber } from '@/utils/format'; @@ -21,7 +21,7 @@ const props = defineProps({ padding: { type: Number, default: 0, - validator(value) { + validator(value: number) { return value >= 0; }, }, @@ -32,7 +32,7 @@ const props = defineProps({ digits: { type: Number, default: 1, - validator(value) { + validator(value: number) { return value > 0; }, }, @@ -51,7 +51,7 @@ const stringValue = ref(format(model.value)); /** * The input element */ -const inputElement = ref(null); +const inputElement = ref(); /* * Update the internal value when the component value changes @@ -84,10 +84,10 @@ function onblur() { /** * Format a decimal as a string - * @param {Number} value The decimal - * @returns {String} The formated string + * @param {number} value The decimal + * @returns {string} The formated string */ -function format(value) { +function format(value: number): string { return formatNumber(value, props.padding, props.digits, true); } </script> diff --git a/src/components/DoubleOutputTable.vue b/src/components/DoubleOutputTable.vue @@ -26,10 +26,12 @@ </div> </template> -<script setup> +<script setup lang="ts"> import { computed } from 'vue'; +import type { PropType } from 'vue'; + import { formatDuration, formatNumber } from '@/utils/format'; -import { DISTANCE_UNITS } from '@/utils/units'; +import { DISTANCE_UNITS, DISTANCE_UNIT_KEYS } from '@/utils/units'; const props = defineProps({ /** @@ -52,7 +54,7 @@ const props = defineProps({ * The set of input times */ inputTimes: { - type: Array, + type: Array<number>, default: () => [], }, @@ -60,7 +62,7 @@ const props = defineProps({ * The input distance */ inputDistance: { - type: Object, + type: Object as PropType<{ distanceValue: number, distanceUnit: DISTANCE_UNIT_KEYS }>, default: () => ({ distanceValue: 5, distanceUnit: 'kilometers', @@ -79,10 +81,10 @@ const results = computed(() => { ]]; props.inputTimes.forEach((input, y) => { - let row = [formatDuration(input, 3, 2, false)]; + const row = [formatDuration(input, 3, 2, false)]; props.targets.forEach(target => { - let result = props.calculateResult({ ...props.inputDistance, time: input }, target); + const result = props.calculateResult({ ...props.inputDistance, time: input }, target); if (y === 0) { results[0].push(result[result.result === 'key' ? 'value' : 'key']); diff --git a/src/components/IntegerInput.vue b/src/components/IntegerInput.vue @@ -2,7 +2,7 @@ <input ref="inputElement" type="number" step="1" required @blur="onblur" v-model="stringValue"> </template> -<script setup> +<script setup lang="ts"> import { ref, watch } from 'vue'; /** @@ -20,7 +20,7 @@ const props = defineProps({ padding: { type: Number, default: 0, - validator(value) { + validator(value: number) { return value >= 0; }, }, @@ -39,7 +39,7 @@ const stringValue = ref(format(model.value)); /** * The input element */ -const inputElement = ref(null); +const inputElement = ref(); /** * Update the internal value when the component value changes @@ -72,10 +72,10 @@ function onblur() { /** * Format an integer as a string - * @param {Number} value The integer - * @returns {String} The formated string + * @param {number} value The integer + * @returns {string} The formated string */ -function format(value) { +function format(value: number): string { return value.toString().padStart(props.padding, '0'); } </script> diff --git a/src/components/PaceInput.vue b/src/components/PaceInput.vue @@ -5,8 +5,8 @@ <decimal-input v-model="model.distanceValue" :aria-label="label + ' distance value'" :min="0" :digits="2"/> <select v-model="model.distanceUnit" :aria-label="label + ' distance unit'"> - <option v-for="(value, key) in DISTANCE_UNITS" :key="key" :value="key"> - {{ value.name }} + <option v-for="key in DISTANCE_UNIT_KEYS" :key="key" :value="key"> + {{ DISTANCE_UNITS[key].name }} </option> </select> </div> @@ -17,13 +17,21 @@ </div> </template> -<script setup> -import { DISTANCE_UNITS } from '@/utils/units'; +<script setup lang="ts"> +import type { PropType } from 'vue'; + +import { DISTANCE_UNITS, DISTANCE_UNIT_KEYS } from '@/utils/units'; import DecimalInput from '@/components/DecimalInput.vue'; import TimeInput from '@/components/TimeInput.vue'; import useObjectModel from '@/composables/useObjectModel'; +interface Pace { + distanceValue: number, + distanceUnit: DISTANCE_UNIT_KEYS, + time: number, +}; + const props = defineProps({ /** * The prefix for each field's aria-label @@ -37,7 +45,7 @@ const props = defineProps({ * The component value */ modelValue: { - type: Object, + type: Object as PropType<Pace>, default: () => ({ distanceValue: 5, distanceUnit: 'kilometers', @@ -48,7 +56,7 @@ const props = defineProps({ // Generate internal ref tied to modelValue prop const emit = defineEmits(['update:modelValue']); -const model = useObjectModel(() => props.modelValue, emit, 'modelValue'); +const model = useObjectModel<Pace>(() => props.modelValue, (x) => emit('update:modelValue', x)); </script> <style scoped> diff --git a/src/components/RaceOptions.vue b/src/components/RaceOptions.vue @@ -17,16 +17,25 @@ </div> </template> -<script setup> +<script setup lang="ts"> +import type { PropType } from 'vue'; + +import { RacePredictionModel } from '@/utils/races'; + import DecimalInput from '@/components/DecimalInput.vue'; import useObjectModel from '@/composables/useObjectModel'; +interface RaceOptions { + model: RacePredictionModel, + riegelExponent: number, +} + const props = defineProps({ /** * The component value */ modelValue: { - type: Object, + type: Object as PropType<RaceOptions>, default: () => ({ model: 'AverageModel', riegelExponent: 1.06, @@ -36,5 +45,6 @@ const props = defineProps({ // Generate internal ref tied to modelValue prop const emit = defineEmits(['update:modelValue']); -const model = useObjectModel(() => props.modelValue, emit, 'modelValue'); +const model = useObjectModel<RaceOptions>(() => props.modelValue, + (x) => emit('update:modelValue', x)); </script> diff --git a/src/components/SplitOutputTable.vue b/src/components/SplitOutputTable.vue @@ -75,7 +75,7 @@ const props = defineProps({ // Generate internal ref tied to modelValue prop const emit = defineEmits(['update:modelValue']); -const model = useObjectModel(() => props.modelValue, emit, 'modelValue'); +const model = useObjectModel(() => props.modelValue, (x) => emit('update:modelValue', x)); /** * The target table results diff --git a/src/components/TargetEditor.vue b/src/components/TargetEditor.vue @@ -145,7 +145,7 @@ const props = defineProps({ const emit = defineEmits(['close', 'revert', 'update:modelValue']); // Generate internal ref tied to modelValue prop -const model = useObjectModel(() => props.modelValue, emit, 'modelValue'); +const model = useObjectModel(() => props.modelValue, (x) => emit('update:modelValue', x)); /** * Add a new distance based target diff --git a/src/components/TargetSetSelector.vue b/src/components/TargetSetSelector.vue @@ -74,7 +74,7 @@ const props = defineProps({ // Generate internal ref tied to modelValue prop const emit = defineEmits(['update:targetSets']); -const targetSets = useObjectModel(() => props.targetSets, emit, 'targetSets'); +const targetSets = useObjectModel(() => props.targetSets, (x) => emit('update:targetSets', x)); /** * The dialog element @@ -115,10 +115,13 @@ function editTargetSet() { * Create and select a new target */ function newTargetSet() { - let key = Date.now().toString(); - targetSets.value[key] = { - name: 'New target set', - targets: [], + const key = Date.now().toString(); + targetSets.value = { + ...targetSets.value, + [key]: { + name: 'New target set', + targets: [], + }, }; model.value = key; editTargetSet(); diff --git a/src/components/TimeInput.vue b/src/components/TimeInput.vue @@ -13,7 +13,7 @@ </div> </template> -<script setup> +<script setup lang="ts"> import { computed, ref, watch } from 'vue'; import IntegerInput from '@/components/IntegerInput.vue'; @@ -25,7 +25,7 @@ import DecimalInput from '@/components/DecimalInput.vue'; const model = defineModel({ type: Number, default: 0, - validator(value) { + validator(value: number) { return value >= 0 && value <= 359999.99; }, }); @@ -99,7 +99,7 @@ const seconds = computed({ /** * Update the internal value when the component value changes */ -watch(model, (newValue) => { +watch(model, (newValue: number) => { if (newValue !== internalValue.value) { internalValue.value = newValue; } @@ -108,7 +108,7 @@ watch(model, (newValue) => { /** * Update the component value when the internal value changes */ -watch(internalValue, (newValue) => { +watch(internalValue, (newValue: number) => { model.value = newValue; }); @@ -116,7 +116,7 @@ watch(internalValue, (newValue) => { * Process up and down arrow presses * @param {Object} e The keydown event args */ -function onkeydown(e, step = 1) { +function onkeydown(e: KeyboardEvent, step: number = 1) { if (e.key === 'ArrowUp') { if (Math.floor(internalValue.value) + step > max.value) { internalValue.value = max.value; diff --git a/src/composables/useObjectModel.ts b/src/composables/useObjectModel.ts @@ -6,32 +6,29 @@ import { deepCopy, deepEqual } from '@/utils/misc'; /* * Generate an internal ref that implements support for v-model with objects * @param {Function} prop A function returning the prop - * @param {Function} emit The emit function - * @param {string} name The name of the v-model prop + * @param {Function} emit A function for emitting update events * @returns {Ref<object>} The internal ref */ -export default function defineObjectModel(prop: () => Ref<object>, - emit: (x: string, y: object) => void, - name: string): Ref<object> { +export default function defineObjectModel<T>(prop: () => T, emit: (x: T) => void): Ref<T> { /** * The internal value */ - const internalValue = ref(deepCopy(prop())); + const internalValue: Ref<T> = ref<T>(prop()) as Ref<T>; /** * Update the internal value when the component value changes */ - watch(prop, (newValue: object) => { - if (!deepEqual(internalValue.value, newValue)) { - internalValue.value = deepCopy(newValue); + watch(prop, (newValue: T) => { + if (!deepEqual<T>(internalValue.value, newValue)) { + internalValue.value = deepCopy<T>(newValue); } }, { deep: true }); /** * Update the component value when the internal value changes */ - watch(internalValue, (newValue: object) => { - emit(`update:${name}`, deepCopy(newValue)); + watch(internalValue, (newValue: T) => { + emit(deepCopy<T>(newValue)); }, { deep: true }); return internalValue; diff --git a/src/utils/misc.ts b/src/utils/misc.ts @@ -1,18 +1,18 @@ /** * Create a deep copy of an object - * @param {object} value The object to copy - * @returns {object} The copied object + * @param {Type} value The object to copy + * @returns {Type} The copied object */ -export function deepCopy(value: object): object { +export function deepCopy<Type>(value: Type): Type { return JSON.parse(JSON.stringify(value)); } /** * Test whether two objects are deeply equal - * @param {object} value1 The first object - * @param {object} value2 The second object + * @param {Type} value1 The first object + * @param {Type} value2 The second object * @returns {boolean} Whether the two objects are equal */ -export function deepEqual(value1: object, value2: object): boolean { +export function deepEqual<Type>(value1: Type, value2: Type): boolean { return JSON.stringify(value1) === JSON.stringify(value2); } diff --git a/src/utils/units.ts b/src/utils/units.ts @@ -1,21 +1,21 @@ -export const enum TIME_UNIT_KEYS { +export enum TIME_UNIT_KEYS { seconds = 'seconds', minutes = 'minutes', hours = 'hours', } -export const enum DISTANCE_UNIT_KEYS { +export enum DISTANCE_UNIT_KEYS { meters = 'meters', yards = 'yards', kilometers = 'kilometers', miles = 'miles', marathons = 'marathons', } -export const enum SPEED_UNIT_KEYS { +export enum SPEED_UNIT_KEYS { meters_per_second = 'meters_per_second', kilometers_per_hour = 'kilometers_per_hour', miles_per_hour = 'miles_per_hour', } -export const enum PACE_UNIT_KEYS { +export enum PACE_UNIT_KEYS { seconds_per_meter = 'seconds_per_meter', time_per_kilometer = 'seconds_per_kilometer', time_per_mile = 'seconds_per_mile',