commit 870d74eef70abe41c1ff39407bf97afaeb112fb6
parent bd0cb27d9a7ec8f0397d19edda33088193540c56
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date: Thu, 11 Jul 2024 13:16:33 -0700
Merge branch 'dev'
Diffstat:
71 files changed, 6182 insertions(+), 3318 deletions(-)
diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml
@@ -23,5 +23,7 @@ jobs:
cache: 'npm'
- run: npm ci
+ - run: npx playwright install --with-deps
- run: npm run build --if-present
- run: npm run test:unit
+ - run: npm run test:e2e
diff --git a/.gitignore b/.gitignore
@@ -21,3 +21,7 @@ pnpm-debug.log*
*.njsproj
*.sln
*.sw?
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
diff --git a/404.html b/404.html
@@ -26,9 +26,14 @@
font-size: 2em;
font-weight: bold;
}
+ main {
+ margin: 1em;
+ }
h1 {
font-size: 1.5em;
- margin: 10px 10px 0px;
+ }
+ p {
+ margin-top: 0.5em;
}
</style>
</head>
@@ -37,7 +42,7 @@
<header>Running Tools</header>
<main>
<h1>404 Not Found</h1>
- <p><a href="%BASE_URL%">homepage</a></p>
+ <p><a href="%BASE_URL%">Return home</a></p>
</main>
</body>
</html>
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -1,5 +1,21 @@
# Changelog
+## 1.4.0 - 2024-07-11
+
+### Added
+- Batch Calculator
+- Workout Calculator
+
+### Changed
+- The edit target set dialog is opened automatically after a new target set is
+ created
+- Target sets can only be used by the calculator they were created in (and by
+ the Batch Calculator for pace, race, and workout target sets)
+
+### Fixed
+- Bug that prevented Split Calculator splits from being saved after a new target
+ set was created
+
## 1.3.0 - 2024-03-25
### Added
diff --git a/README.md b/README.md
@@ -3,6 +3,8 @@ A collection of tools for runners and their coaches.
Try it out [here](https://ashermorgan.github.io/running-tools/).
## Features
+- [Batch Calculator](https://ashermorgan.github.io/running-tools/#/calculate/batch):
+ Create tables of the results of the other calculators over a range of inputs
- [Pace Calculator](https://ashermorgan.github.io/running-tools/#/calculate/paces):
Calculate distances and times that are at the same pace
- [Race Calculator](https://ashermorgan.github.io/running-tools/#/calculate/races):
@@ -11,6 +13,8 @@ Try it out [here](https://ashermorgan.github.io/running-tools/).
Find splits, paces, and cumulative times for the segments of a race
- [Unit Calculator](https://ashermorgan.github.io/running-tools/#/calculate/units):
Convert between different distance, time, speed, and pace units
+- [Workout Calculator](https://ashermorgan.github.io/running-tools/#/calculate/workouts):
+ Estimate target workout splits using previous race results
## Setup
Install dependencies
@@ -23,10 +27,11 @@ Run development server
npm run dev
```
-Run linter and unit tests
+Run linter and tests
```
npm run lint
npm run test:unit
+npm run test:e2e
```
Build for production
diff --git a/index.html b/index.html
@@ -76,12 +76,12 @@
font-weight: bold;
}
p {
- margin: 10px;
+ margin: 1em;
font-weight: bold;
}
</style>
<header>Running Tools</header>
- <p>We're sorry but Running Tools doesn't work properly without JavaScript enabled. Please enable it to continue.</p>
+ <p>Running Tools requires JavaScript. Please enable it to continue.</p>
</noscript>
<!-- built files will be auto injected -->
diff --git a/package-lock.json b/package-lock.json
@@ -1,27 +1,29 @@
{
"name": "running-tools",
- "version": "1.3.0",
+ "version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "running-tools",
- "version": "1.3.0",
+ "version": "1.4.0",
"dependencies": {
- "feather-icons": "^4.29.0",
- "vue": "^3.3.4",
+ "feather-icons": "^4.29.2",
+ "vue": "^3.4.27",
"vue-feather": "^2.0.0",
- "vue-router": "^4.2.2"
+ "vue-router": "^4.3.2"
},
"devDependencies": {
- "@vitejs/plugin-vue": "^4.2.3",
- "@vue/test-utils": "^2.4.0",
- "eslint": "^8.39.0",
- "eslint-plugin-vue": "^9.11.0",
+ "@playwright/test": "^1.44.0",
+ "@types/node": "^20.12.12",
+ "@vitejs/plugin-vue": "^4.6.2",
+ "@vue/test-utils": "^2.4.6",
+ "eslint": "^8.57.0",
+ "eslint-plugin-vue": "^9.26.0",
"jsdom": "^22.1.0",
"pwa-asset-generator": "^6.3.1",
- "vite": "^4.3.9",
- "vite-plugin-pwa": "^0.16.4",
+ "vite": "^4.5.3",
+ "vite-plugin-pwa": "^0.16.7",
"vitest": "^0.32.4"
}
},
@@ -644,9 +646,9 @@
}
},
"node_modules/@babel/parser": {
- "version": "7.23.6",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz",
- "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==",
+ "version": "7.24.5",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.5.tgz",
+ "integrity": "sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==",
"bin": {
"parser": "bin/babel-parser.js"
},
@@ -2293,23 +2295,23 @@
}
},
"node_modules/@eslint-community/regexpp": {
- "version": "4.5.1",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.1.tgz",
- "integrity": "sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==",
+ "version": "4.10.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz",
+ "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==",
"dev": true,
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
},
"node_modules/@eslint/eslintrc": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.3.tgz",
- "integrity": "sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==",
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
"dev": true,
"dependencies": {
"ajv": "^6.12.4",
"debug": "^4.3.2",
- "espree": "^9.5.2",
+ "espree": "^9.6.0",
"globals": "^13.19.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
@@ -2325,22 +2327,22 @@
}
},
"node_modules/@eslint/js": {
- "version": "8.43.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.43.0.tgz",
- "integrity": "sha512-s2UHCoiXfxMvmfzqoN+vrQ84ahUSYde9qNO1MdxmoEhyHWsfmwOpFlwYV+ePJEVc7gFnATGUi376WowX1N7tFg==",
+ "version": "8.57.0",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
+ "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
"dev": true,
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@humanwhocodes/config-array": {
- "version": "0.11.10",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz",
- "integrity": "sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==",
+ "version": "0.11.14",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
+ "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
"dev": true,
"dependencies": {
- "@humanwhocodes/object-schema": "^1.2.1",
- "debug": "^4.1.1",
+ "@humanwhocodes/object-schema": "^2.0.2",
+ "debug": "^4.3.1",
"minimatch": "^3.0.5"
},
"engines": {
@@ -2361,11 +2363,55 @@
}
},
"node_modules/@humanwhocodes/object-schema": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
- "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
+ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
"dev": true
},
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+ "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+ "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
"node_modules/@jest/schemas": {
"version": "29.6.0",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.0.tgz",
@@ -2485,6 +2531,37 @@
"node": ">= 8"
}
},
+ "node_modules/@one-ini/wasm": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
+ "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
+ "dev": true
+ },
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.44.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.0.tgz",
+ "integrity": "sha512-rNX5lbNidamSUorBhB4XZ9SQTjAqfe5M+p37Z8ic0jPFBMo5iCtQz1kRWkEMg+rYOKSlVycpQmpqjSFq7LXOfg==",
+ "dev": true,
+ "dependencies": {
+ "playwright": "1.44.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@@ -2549,10 +2626,13 @@
"dev": true
},
"node_modules/@types/node": {
- "version": "20.4.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.0.tgz",
- "integrity": "sha512-jfT7iTf/4kOQ9S7CHV9BIyRaQqHu67mOjsIQBC3BKZvzvUB6zLxEwJ6sBE3ozcvP8kF6Uk5PXN0Q+c0dfhGX0g==",
- "dev": true
+ "version": "20.12.12",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
+ "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
+ "dev": true,
+ "dependencies": {
+ "undici-types": "~5.26.4"
+ }
},
"node_modules/@types/normalize-package-data": {
"version": "2.4.4",
@@ -2585,16 +2665,22 @@
"@types/node": "*"
}
},
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
+ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
+ "dev": true
+ },
"node_modules/@vitejs/plugin-vue": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.2.3.tgz",
- "integrity": "sha512-R6JDUfiZbJA9cMiguQ7jxALsgiprjBeHL5ikpXfJCH62pPHtI+JdJ5xWj6Ev73yXSlYl86+blXn1kZHQ7uElxw==",
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz",
+ "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==",
"dev": true,
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"peerDependencies": {
- "vite": "^4.0.0",
+ "vite": "^4.0.0 || ^5.0.0",
"vue": "^3.2.25"
}
},
@@ -2694,133 +2780,108 @@
}
},
"node_modules/@vue/compiler-core": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.3.4.tgz",
- "integrity": "sha512-cquyDNvZ6jTbf/+x+AgM2Arrp6G4Dzbb0R64jiG804HRMfRiFXWI6kqUVqZ6ZR0bQhIoQjB4+2bhNtVwndW15g==",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.27.tgz",
+ "integrity": "sha512-E+RyqY24KnyDXsCuQrI+mlcdW3ALND6U7Gqa/+bVwbcpcR3BRRIckFoz7Qyd4TTlnugtwuI7YgjbvsLmxb+yvg==",
"dependencies": {
- "@babel/parser": "^7.21.3",
- "@vue/shared": "3.3.4",
+ "@babel/parser": "^7.24.4",
+ "@vue/shared": "3.4.27",
+ "entities": "^4.5.0",
"estree-walker": "^2.0.2",
- "source-map-js": "^1.0.2"
+ "source-map-js": "^1.2.0"
}
},
"node_modules/@vue/compiler-dom": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.4.tgz",
- "integrity": "sha512-wyM+OjOVpuUukIq6p5+nwHYtj9cFroz9cwkfmP9O1nzH68BenTTv0u7/ndggT8cIQlnBeOo6sUT/gvHcIkLA5w==",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.27.tgz",
+ "integrity": "sha512-kUTvochG/oVgE1w5ViSr3KUBh9X7CWirebA3bezTbB5ZKBQZwR2Mwj9uoSKRMFcz4gSMzzLXBPD6KpCLb9nvWw==",
"dependencies": {
- "@vue/compiler-core": "3.3.4",
- "@vue/shared": "3.3.4"
+ "@vue/compiler-core": "3.4.27",
+ "@vue/shared": "3.4.27"
}
},
"node_modules/@vue/compiler-sfc": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.4.tgz",
- "integrity": "sha512-6y/d8uw+5TkCuzBkgLS0v3lSM3hJDntFEiUORM11pQ/hKvkhSKZrXW6i69UyXlJQisJxuUEJKAWEqWbWsLeNKQ==",
- "dependencies": {
- "@babel/parser": "^7.20.15",
- "@vue/compiler-core": "3.3.4",
- "@vue/compiler-dom": "3.3.4",
- "@vue/compiler-ssr": "3.3.4",
- "@vue/reactivity-transform": "3.3.4",
- "@vue/shared": "3.3.4",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.27.tgz",
+ "integrity": "sha512-nDwntUEADssW8e0rrmE0+OrONwmRlegDA1pD6QhVeXxjIytV03yDqTey9SBDiALsvAd5U4ZrEKbMyVXhX6mCGA==",
+ "dependencies": {
+ "@babel/parser": "^7.24.4",
+ "@vue/compiler-core": "3.4.27",
+ "@vue/compiler-dom": "3.4.27",
+ "@vue/compiler-ssr": "3.4.27",
+ "@vue/shared": "3.4.27",
"estree-walker": "^2.0.2",
- "magic-string": "^0.30.0",
- "postcss": "^8.1.10",
- "source-map-js": "^1.0.2"
+ "magic-string": "^0.30.10",
+ "postcss": "^8.4.38",
+ "source-map-js": "^1.2.0"
}
},
"node_modules/@vue/compiler-ssr": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.3.4.tgz",
- "integrity": "sha512-m0v6oKpup2nMSehwA6Uuu+j+wEwcy7QmwMkVNVfrV9P2qE5KshC6RwOCq8fjGS/Eak/uNb8AaWekfiXxbBB6gQ==",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.27.tgz",
+ "integrity": "sha512-CVRzSJIltzMG5FcidsW0jKNQnNRYC8bT21VegyMMtHmhW3UOI7knmUehzswXLrExDLE6lQCZdrhD4ogI7c+vuw==",
"dependencies": {
- "@vue/compiler-dom": "3.3.4",
- "@vue/shared": "3.3.4"
+ "@vue/compiler-dom": "3.4.27",
+ "@vue/shared": "3.4.27"
}
},
"node_modules/@vue/devtools-api": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.5.0.tgz",
- "integrity": "sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q=="
+ "version": "6.6.1",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.1.tgz",
+ "integrity": "sha512-LgPscpE3Vs0x96PzSSB4IGVSZXZBZHpfxs+ZA1d+VEPwHdOXowy/Y2CsvCAIFrf+ssVU1pD1jidj505EpUnfbA=="
},
"node_modules/@vue/reactivity": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.3.4.tgz",
- "integrity": "sha512-kLTDLwd0B1jG08NBF3R5rqULtv/f8x3rOFByTDz4J53ttIQEDmALqKqXY0J+XQeN0aV2FBxY8nJDf88yvOPAqQ==",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.27.tgz",
+ "integrity": "sha512-kK0g4NknW6JX2yySLpsm2jlunZJl2/RJGZ0H9ddHdfBVHcNzxmQ0sS0b09ipmBoQpY8JM2KmUw+a6sO8Zo+zIA==",
"dependencies": {
- "@vue/shared": "3.3.4"
- }
- },
- "node_modules/@vue/reactivity-transform": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.3.4.tgz",
- "integrity": "sha512-MXgwjako4nu5WFLAjpBnCj/ieqcjE2aJBINUNQzkZQfzIZA4xn+0fV1tIYBJvvva3N3OvKGofRLvQIwEQPpaXw==",
- "dependencies": {
- "@babel/parser": "^7.20.15",
- "@vue/compiler-core": "3.3.4",
- "@vue/shared": "3.3.4",
- "estree-walker": "^2.0.2",
- "magic-string": "^0.30.0"
+ "@vue/shared": "3.4.27"
}
},
"node_modules/@vue/runtime-core": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.3.4.tgz",
- "integrity": "sha512-R+bqxMN6pWO7zGI4OMlmvePOdP2c93GsHFM/siJI7O2nxFRzj55pLwkpCedEY+bTMgp5miZ8CxfIZo3S+gFqvA==",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.27.tgz",
+ "integrity": "sha512-7aYA9GEbOOdviqVvcuweTLe5Za4qBZkUY7SvET6vE8kyypxVgaT1ixHLg4urtOlrApdgcdgHoTZCUuTGap/5WA==",
"dependencies": {
- "@vue/reactivity": "3.3.4",
- "@vue/shared": "3.3.4"
+ "@vue/reactivity": "3.4.27",
+ "@vue/shared": "3.4.27"
}
},
"node_modules/@vue/runtime-dom": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.3.4.tgz",
- "integrity": "sha512-Aj5bTJ3u5sFsUckRghsNjVTtxZQ1OyMWCr5dZRAPijF/0Vy4xEoRCwLyHXcj4D0UFbJ4lbx3gPTgg06K/GnPnQ==",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.27.tgz",
+ "integrity": "sha512-ScOmP70/3NPM+TW9hvVAz6VWWtZJqkbdf7w6ySsws+EsqtHvkhxaWLecrTorFxsawelM5Ys9FnDEMt6BPBDS0Q==",
"dependencies": {
- "@vue/runtime-core": "3.3.4",
- "@vue/shared": "3.3.4",
- "csstype": "^3.1.1"
+ "@vue/runtime-core": "3.4.27",
+ "@vue/shared": "3.4.27",
+ "csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.3.4.tgz",
- "integrity": "sha512-Q6jDDzR23ViIb67v+vM1Dqntu+HUexQcsWKhhQa4ARVzxOY2HbC7QRW/ggkDBd5BU+uM1sV6XOAP0b216o34JQ==",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.27.tgz",
+ "integrity": "sha512-dlAMEuvmeA3rJsOMJ2J1kXU7o7pOxgsNHVr9K8hB3ImIkSuBrIdy0vF66h8gf8Tuinf1TK3mPAz2+2sqyf3KzA==",
"dependencies": {
- "@vue/compiler-ssr": "3.3.4",
- "@vue/shared": "3.3.4"
+ "@vue/compiler-ssr": "3.4.27",
+ "@vue/shared": "3.4.27"
},
"peerDependencies": {
- "vue": "3.3.4"
+ "vue": "3.4.27"
}
},
"node_modules/@vue/shared": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.3.4.tgz",
- "integrity": "sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ=="
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.27.tgz",
+ "integrity": "sha512-DL3NmY2OFlqmYYrzp39yi3LDkKxa5vZVwxWdQ3rG0ekuWscHraeIbnI8t+aZK7qhYqEqWKTUdijadunb9pnrgA=="
},
"node_modules/@vue/test-utils": {
- "version": "2.4.0",
- "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.0.tgz",
- "integrity": "sha512-BKB9aj1yky63/I3IwSr1FjUeHYsKXI7D6S9F378AHt7a5vC0dLkOBtSsFXoRGC/7BfHmiB9HRhT+i9xrUHoAKw==",
+ "version": "2.4.6",
+ "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz",
+ "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==",
"dev": true,
"dependencies": {
- "js-beautify": "1.14.6",
- "vue-component-type-helpers": "1.6.5"
- },
- "peerDependencies": {
- "@vue/compiler-dom": "^3.0.1",
- "@vue/server-renderer": "^3.0.1",
- "vue": "^3.0.1"
- },
- "peerDependenciesMeta": {
- "@vue/compiler-dom": {
- "optional": true
- },
- "@vue/server-renderer": {
- "optional": true
- }
+ "js-beautify": "^1.14.9",
+ "vue-component-type-helpers": "^2.0.0"
}
},
"node_modules/abab": {
@@ -2830,10 +2891,13 @@
"dev": true
},
"node_modules/abbrev": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
- "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
- "dev": true
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
+ "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
+ "dev": true,
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
},
"node_modules/acorn": {
"version": "8.9.0",
@@ -3080,12 +3144,12 @@
}
},
"node_modules/braces": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
- "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"dependencies": {
- "fill-range": "^7.0.1"
+ "fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
@@ -3562,9 +3626,9 @@
}
},
"node_modules/csstype": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
- "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/data-urls": {
"version": "4.0.0",
@@ -3783,50 +3847,67 @@
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "dev": true
+ },
"node_modules/editorconfig": {
- "version": "0.15.3",
- "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-0.15.3.tgz",
- "integrity": "sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==",
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
+ "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
"dev": true,
"dependencies": {
- "commander": "^2.19.0",
- "lru-cache": "^4.1.5",
- "semver": "^5.6.0",
- "sigmund": "^1.0.1"
+ "@one-ini/wasm": "0.1.1",
+ "commander": "^10.0.0",
+ "minimatch": "9.0.1",
+ "semver": "^7.5.3"
},
"bin": {
"editorconfig": "bin/editorconfig"
+ },
+ "engines": {
+ "node": ">=14"
}
},
- "node_modules/editorconfig/node_modules/lru-cache": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz",
- "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==",
+ "node_modules/editorconfig/node_modules/brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"dependencies": {
- "pseudomap": "^1.0.2",
- "yallist": "^2.1.2"
+ "balanced-match": "^1.0.0"
}
},
- "node_modules/editorconfig/node_modules/semver": {
- "version": "5.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
- "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
+ "node_modules/editorconfig/node_modules/commander": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
+ "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
"dev": true,
- "bin": {
- "semver": "bin/semver"
+ "engines": {
+ "node": ">=14"
}
},
- "node_modules/editorconfig/node_modules/yallist": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
- "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==",
- "dev": true
+ "node_modules/editorconfig/node_modules/minimatch": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
+ "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
},
"node_modules/ejs": {
- "version": "3.1.9",
- "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz",
- "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==",
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+ "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
"dev": true,
"dependencies": {
"jake": "^10.8.5"
@@ -3844,6 +3925,12 @@
"integrity": "sha512-kKiHnbrHME7z8E6AYaw0ehyxY5+hdaRmeUbjBO22LZMdqTYCO29EvF0T1cQ3pJ1RN5fyMcHl1Lmcsdt9WWJpJQ==",
"dev": true
},
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true
+ },
"node_modules/end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
@@ -3857,7 +3944,6 @@
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
- "dev": true,
"engines": {
"node": ">=0.12"
},
@@ -4013,27 +4099,28 @@
}
},
"node_modules/eslint": {
- "version": "8.43.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.43.0.tgz",
- "integrity": "sha512-aaCpf2JqqKesMFGgmRPessmVKjcGXqdlAYLLC3THM8t5nBRZRQ+st5WM/hoJXkdioEXLLbXgclUpM0TXo5HX5Q==",
+ "version": "8.57.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
+ "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
- "@eslint-community/regexpp": "^4.4.0",
- "@eslint/eslintrc": "^2.0.3",
- "@eslint/js": "8.43.0",
- "@humanwhocodes/config-array": "^0.11.10",
+ "@eslint-community/regexpp": "^4.6.1",
+ "@eslint/eslintrc": "^2.1.4",
+ "@eslint/js": "8.57.0",
+ "@humanwhocodes/config-array": "^0.11.14",
"@humanwhocodes/module-importer": "^1.0.1",
"@nodelib/fs.walk": "^1.2.8",
- "ajv": "^6.10.0",
+ "@ungap/structured-clone": "^1.2.0",
+ "ajv": "^6.12.4",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.2",
"debug": "^4.3.2",
"doctrine": "^3.0.0",
"escape-string-regexp": "^4.0.0",
- "eslint-scope": "^7.2.0",
- "eslint-visitor-keys": "^3.4.1",
- "espree": "^9.5.2",
+ "eslint-scope": "^7.2.2",
+ "eslint-visitor-keys": "^3.4.3",
+ "espree": "^9.6.1",
"esquery": "^1.4.2",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
@@ -4043,7 +4130,6 @@
"globals": "^13.19.0",
"graphemer": "^1.4.0",
"ignore": "^5.2.0",
- "import-fresh": "^3.0.0",
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"is-path-inside": "^3.0.3",
@@ -4053,9 +4139,8 @@
"lodash.merge": "^4.6.2",
"minimatch": "^3.1.2",
"natural-compare": "^1.4.0",
- "optionator": "^0.9.1",
+ "optionator": "^0.9.3",
"strip-ansi": "^6.0.1",
- "strip-json-comments": "^3.1.0",
"text-table": "^0.2.0"
},
"bin": {
@@ -4069,30 +4154,31 @@
}
},
"node_modules/eslint-plugin-vue": {
- "version": "9.15.1",
- "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.15.1.tgz",
- "integrity": "sha512-CJE/oZOslvmAR9hf8SClTdQ9JLweghT6JCBQNrT2Iel1uVw0W0OLJxzvPd6CxmABKCvLrtyDnqGV37O7KQv6+A==",
+ "version": "9.26.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.26.0.tgz",
+ "integrity": "sha512-eTvlxXgd4ijE1cdur850G6KalZqk65k1JKoOI2d1kT3hr8sPD07j1q98FRFdNnpxBELGPWxZmInxeHGF/GxtqQ==",
"dev": true,
"dependencies": {
- "@eslint-community/eslint-utils": "^4.3.0",
+ "@eslint-community/eslint-utils": "^4.4.0",
+ "globals": "^13.24.0",
"natural-compare": "^1.4.0",
- "nth-check": "^2.0.1",
- "postcss-selector-parser": "^6.0.9",
- "semver": "^7.3.5",
- "vue-eslint-parser": "^9.3.0",
+ "nth-check": "^2.1.1",
+ "postcss-selector-parser": "^6.0.15",
+ "semver": "^7.6.0",
+ "vue-eslint-parser": "^9.4.2",
"xml-name-validator": "^4.0.0"
},
"engines": {
"node": "^14.17.0 || >=16.0.0"
},
"peerDependencies": {
- "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0"
+ "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0"
}
},
"node_modules/eslint-scope": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz",
- "integrity": "sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==",
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
+ "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
"dev": true,
"dependencies": {
"esrecurse": "^4.3.0",
@@ -4106,9 +4192,9 @@
}
},
"node_modules/eslint-visitor-keys": {
- "version": "3.4.1",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz",
- "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==",
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true,
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -4118,12 +4204,12 @@
}
},
"node_modules/espree": {
- "version": "9.5.2",
- "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.2.tgz",
- "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==",
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
+ "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
"dev": true,
"dependencies": {
- "acorn": "^8.8.0",
+ "acorn": "^8.9.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^3.4.1"
},
@@ -4220,9 +4306,9 @@
"dev": true
},
"node_modules/fast-glob": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.0.tgz",
- "integrity": "sha512-ChDuvbOypPuNjO8yIDf36x7BlZX1smcUMTTcyoIjycexOxd6DFsKsg21qVBzEmr3G7fUKIRy2/psii+CIUt7FA==",
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
+ "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
"dev": true,
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@@ -4278,9 +4364,9 @@
}
},
"node_modules/feather-icons": {
- "version": "4.29.0",
- "resolved": "https://registry.npmjs.org/feather-icons/-/feather-icons-4.29.0.tgz",
- "integrity": "sha512-Y7VqN9FYb8KdaSF0qD1081HCkm0v4Eq/fpfQYQnubpqi0hXx14k+gF9iqtRys1SIcTEi97xDi/fmsPFZ8xo0GQ==",
+ "version": "4.29.2",
+ "resolved": "https://registry.npmjs.org/feather-icons/-/feather-icons-4.29.2.tgz",
+ "integrity": "sha512-0TaCFTnBTVCz6U+baY2UJNKne5ifGh7sMG4ZC2LoBWCZdIyPa+y6UiR4lEYGws1JOFWdee8KAsAIvu0VcXqiqA==",
"dependencies": {
"classnames": "^2.2.5",
"core-js": "^3.1.3"
@@ -4329,9 +4415,9 @@
}
},
"node_modules/fill-range": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
- "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -4407,6 +4493,22 @@
"is-callable": "^1.1.3"
}
},
+ "node_modules/foreground-child": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
+ "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==",
+ "dev": true,
+ "dependencies": {
+ "cross-spawn": "^7.0.0",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
@@ -4607,9 +4709,9 @@
}
},
"node_modules/globals": {
- "version": "13.20.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz",
- "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==",
+ "version": "13.24.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
+ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
"dev": true,
"dependencies": {
"type-fest": "^0.20.2"
@@ -4859,9 +4961,9 @@
]
},
"node_modules/ignore": {
- "version": "5.2.4",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
- "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
+ "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==",
"dev": true,
"engines": {
"node": ">= 4"
@@ -5063,6 +5165,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -5287,6 +5398,24 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
},
+ "node_modules/jackspeak": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
+ "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==",
+ "dev": true,
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
"node_modules/jake": {
"version": "10.8.7",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz",
@@ -5320,15 +5449,16 @@
}
},
"node_modules/js-beautify": {
- "version": "1.14.6",
- "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.14.6.tgz",
- "integrity": "sha512-GfofQY5zDp+cuHc+gsEXKPpNw2KbPddreEo35O6jT6i0RVK6LhsoYBhq5TvK4/n74wnA0QbK8gGd+jUZwTMKJw==",
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.1.tgz",
+ "integrity": "sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==",
"dev": true,
"dependencies": {
"config-chain": "^1.1.13",
- "editorconfig": "^0.15.3",
- "glob": "^8.0.3",
- "nopt": "^6.0.0"
+ "editorconfig": "^1.0.4",
+ "glob": "^10.3.3",
+ "js-cookie": "^3.0.5",
+ "nopt": "^7.2.0"
},
"bin": {
"css-beautify": "js/bin/css-beautify.js",
@@ -5336,7 +5466,7 @@
"js-beautify": "js/bin/js-beautify.js"
},
"engines": {
- "node": ">=10"
+ "node": ">=14"
}
},
"node_modules/js-beautify/node_modules/brace-expansion": {
@@ -5349,34 +5479,49 @@
}
},
"node_modules/js-beautify/node_modules/glob": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
- "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
+ "version": "10.3.15",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.15.tgz",
+ "integrity": "sha512-0c6RlJt1TICLyvJYIApxb8GsXoai0KUP7AxKKAtsYXdgJR1mGEUa7DgwShbdk1nly0PYoZj01xd4hzbq3fsjpw==",
"dev": true,
"dependencies": {
- "fs.realpath": "^1.0.0",
- "inflight": "^1.0.4",
- "inherits": "2",
- "minimatch": "^5.0.1",
- "once": "^1.3.0"
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^2.3.6",
+ "minimatch": "^9.0.1",
+ "minipass": "^7.0.4",
+ "path-scurry": "^1.11.0"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
},
"engines": {
- "node": ">=12"
+ "node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/js-beautify/node_modules/minimatch": {
- "version": "5.1.6",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
- "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
+ "version": "9.0.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+ "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
- "node": ">=10"
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/js-cookie": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
+ "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
+ "dev": true,
+ "engines": {
+ "node": ">=14"
}
},
"node_modules/js-tokens": {
@@ -5670,14 +5815,11 @@
}
},
"node_modules/magic-string": {
- "version": "0.30.0",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz",
- "integrity": "sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==",
+ "version": "0.30.10",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz",
+ "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==",
"dependencies": {
- "@jridgewell/sourcemap-codec": "^1.4.13"
- },
- "engines": {
- "node": ">=12"
+ "@jridgewell/sourcemap-codec": "^1.4.15"
}
},
"node_modules/map-obj": {
@@ -5820,6 +5962,15 @@
"node": ">= 6"
}
},
+ "node_modules/minipass": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.1.tgz",
+ "integrity": "sha512-UZ7eQ+h8ywIRAW1hIEl2AqdwzJucU/Kp59+8kkZeSvafXhZjul247BvIJjEVFVeON6d7lM46XX1HXCduKAS8VA==",
+ "dev": true,
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@@ -5916,18 +6067,18 @@
"dev": true
},
"node_modules/nopt": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz",
- "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==",
+ "version": "7.2.1",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
+ "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
"dev": true,
"dependencies": {
- "abbrev": "^1.0.0"
+ "abbrev": "^2.0.0"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
- "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/normalize-package-data": {
@@ -6152,6 +6303,31 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true
},
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/path-scurry/node_modules/lru-cache": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz",
+ "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==",
+ "dev": true,
+ "engines": {
+ "node": "14 || >=16.14"
+ }
+ },
"node_modules/pathe": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.1.tgz",
@@ -6265,10 +6441,40 @@
"pathe": "^1.1.0"
}
},
+ "node_modules/playwright": {
+ "version": "1.44.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.0.tgz",
+ "integrity": "sha512-F9b3GUCLQ3Nffrfb6dunPOkE5Mh68tR7zN32L4jCk4FjQamgesGay7/dAAe1WaMEGV04DkdJfcJzjoCKygUaRQ==",
+ "dev": true,
+ "dependencies": {
+ "playwright-core": "1.44.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.44.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.0.tgz",
+ "integrity": "sha512-ZTbkNpFfYcGWohvTTl+xewITm7EOuqIqex0c7dNZ+aXsbrLj0qI8XlGKfPpipjm0Wny/4Lt4CJsWJk1stVS5qQ==",
+ "dev": true,
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/postcss": {
- "version": "8.4.32",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz",
- "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==",
+ "version": "8.4.38",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
+ "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
"funding": [
{
"type": "opencollective",
@@ -6286,16 +6492,16 @@
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
- "source-map-js": "^1.0.2"
+ "source-map-js": "^1.2.0"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-selector-parser": {
- "version": "6.0.13",
- "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz",
- "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==",
+ "version": "6.0.16",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz",
+ "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==",
"dev": true,
"dependencies": {
"cssesc": "^3.0.0",
@@ -6329,9 +6535,9 @@
}
},
"node_modules/pretty-bytes": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.0.tgz",
- "integrity": "sha512-Rk753HI8f4uivXi4ZCIYdhmG1V+WKzvRMg/X+M42a6t7D07RcmopXJMDNk6N++7Bl75URRGsb40ruvg7Hcp2wQ==",
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
+ "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==",
"dev": true,
"engines": {
"node": "^14.13.1 || >=16.0.0"
@@ -6387,12 +6593,6 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true
},
- "node_modules/pseudomap": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
- "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==",
- "dev": true
- },
"node_modules/psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
@@ -6950,13 +7150,10 @@
}
},
"node_modules/semver": {
- "version": "7.5.3",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz",
- "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==",
+ "version": "7.6.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
+ "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
"dev": true,
- "dependencies": {
- "lru-cache": "^6.0.0"
- },
"bin": {
"semver": "bin/semver.js"
},
@@ -7014,11 +7211,17 @@
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true
},
- "node_modules/sigmund": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz",
- "integrity": "sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g==",
- "dev": true
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
},
"node_modules/slash": {
"version": "3.0.0",
@@ -7042,9 +7245,9 @@
}
},
"node_modules/source-map-js": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
- "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
+ "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"engines": {
"node": ">=0.10.0"
}
@@ -7154,6 +7357,71 @@
"safe-buffer": "~5.2.0"
}
},
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true
+ },
+ "node_modules/string-width/node_modules/ansi-regex": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+ "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/string-width/node_modules/strip-ansi": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+ "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
"node_modules/string.prototype.matchall": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz",
@@ -7244,6 +7512,19 @@
"node": ">=8"
}
},
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz",
@@ -7594,6 +7875,12 @@
"through": "^2.3.8"
}
},
+ "node_modules/undici-types": {
+ "version": "5.26.5",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+ "dev": true
+ },
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz",
@@ -7731,9 +8018,9 @@
}
},
"node_modules/vite": {
- "version": "4.5.2",
- "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.2.tgz",
- "integrity": "sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w==",
+ "version": "4.5.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz",
+ "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==",
"dev": true,
"dependencies": {
"esbuild": "^0.18.10",
@@ -7809,14 +8096,14 @@
}
},
"node_modules/vite-plugin-pwa": {
- "version": "0.16.4",
- "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.16.4.tgz",
- "integrity": "sha512-lmwHFIs9zI2H9bXJld/zVTbCqCQHZ9WrpyDMqosICDV0FVnCJwniX1NMDB79HGTIZzOQkY4gSZaVTJTw6maz/Q==",
+ "version": "0.16.7",
+ "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.16.7.tgz",
+ "integrity": "sha512-4WMA5unuKlHs+koNoykeuCfTcqEGbiTRr8sVYUQMhc6tWxZpSRnv9Ojk4LKmqVhoPGHfBVCdGaMo8t9Qidkc1Q==",
"dev": true,
"dependencies": {
"debug": "^4.3.4",
- "fast-glob": "^3.2.12",
- "pretty-bytes": "^6.0.0",
+ "fast-glob": "^3.3.1",
+ "pretty-bytes": "^6.1.1",
"workbox-build": "^7.0.0",
"workbox-window": "^7.0.0"
},
@@ -7827,7 +8114,7 @@
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
- "vite": "^3.1.0 || ^4.0.0",
+ "vite": "^3.1.0 || ^4.0.0 || ^5.0.0-0",
"workbox-build": "^7.0.0",
"workbox-window": "^7.0.0"
}
@@ -7910,27 +8197,35 @@
}
},
"node_modules/vue": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/vue/-/vue-3.3.4.tgz",
- "integrity": "sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.27.tgz",
+ "integrity": "sha512-8s/56uK6r01r1icG/aEOHqyMVxd1bkYcSe9j8HcKtr/xTOFWvnzIVTehNW+5Yt89f+DLBe4A569pnZLS5HzAMA==",
"dependencies": {
- "@vue/compiler-dom": "3.3.4",
- "@vue/compiler-sfc": "3.3.4",
- "@vue/runtime-dom": "3.3.4",
- "@vue/server-renderer": "3.3.4",
- "@vue/shared": "3.3.4"
+ "@vue/compiler-dom": "3.4.27",
+ "@vue/compiler-sfc": "3.4.27",
+ "@vue/runtime-dom": "3.4.27",
+ "@vue/server-renderer": "3.4.27",
+ "@vue/shared": "3.4.27"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
}
},
"node_modules/vue-component-type-helpers": {
- "version": "1.6.5",
- "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-1.6.5.tgz",
- "integrity": "sha512-iGdlqtajmiqed8ptURKPJ/Olz0/mwripVZszg6tygfZSIL9kYFPJTNY6+Q6OjWGznl2L06vxG5HvNvAnWrnzbg==",
+ "version": "2.0.19",
+ "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.0.19.tgz",
+ "integrity": "sha512-cN3f1aTxxKo4lzNeQAkVopswuImUrb5Iurll9Gaw5cqpnbTAxtEMM1mgi6ou4X79OCyqYv1U1mzBHJkzmiK82w==",
"dev": true
},
"node_modules/vue-eslint-parser": {
- "version": "9.3.1",
- "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.3.1.tgz",
- "integrity": "sha512-Clr85iD2XFZ3lJ52/ppmUDG/spxQu6+MAeHXjjyI4I1NUYZ9xmenQp4N0oaHJhrA8OOxltCVxMRfANGa70vU0g==",
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.2.tgz",
+ "integrity": "sha512-Ry9oiGmCAK91HrKMtCrKFWmSFWvYkpGglCeFAIqDdr9zdXmMMpJOmUJS7WWsW7fX81h6mwHmUZCQQ1E0PkSwYQ==",
"dev": true,
"dependencies": {
"debug": "^4.3.4",
@@ -7961,11 +8256,11 @@
}
},
"node_modules/vue-router": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.2.2.tgz",
- "integrity": "sha512-cChBPPmAflgBGmy3tBsjeoe3f3VOSG6naKyY5pjtrqLGbNEXdzCigFUHgBvp9e3ysAtFtEx7OLqcSDh/1Cq2TQ==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.3.2.tgz",
+ "integrity": "sha512-hKQJ1vDAZ5LVkKEnHhmm1f9pMiWIBNGF5AwU67PdH7TyXCj/a4hTccuUuYCAMgJK6rO/NVYtQIEN3yL8CECa7Q==",
"dependencies": {
- "@vue/devtools-api": "^6.5.0"
+ "@vue/devtools-api": "^6.5.1"
},
"funding": {
"url": "https://github.com/sponsors/posva"
@@ -8457,6 +8752,100 @@
"workbox-core": "7.0.0"
}
},
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-regex": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+ "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+ "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/strip-ansi": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+ "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -8464,9 +8853,9 @@
"dev": true
},
"node_modules/ws": {
- "version": "8.13.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
- "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
+ "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"dev": true,
"engines": {
"node": ">=10.0.0"
diff --git a/package.json b/package.json
@@ -1,30 +1,34 @@
{
"name": "running-tools",
- "version": "1.3.0",
+ "version": "1.4.0",
"description": "A collection of tools for runners and their coaches that calculate splits, predict race times, convert units, and more",
"private": true,
+ "type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
- "test:unit": "vitest"
+ "test:unit": "vitest",
+ "test:e2e": "npx playwright test"
},
"dependencies": {
- "feather-icons": "^4.29.0",
- "vue": "^3.3.4",
+ "feather-icons": "^4.29.2",
+ "vue": "^3.4.27",
"vue-feather": "^2.0.0",
- "vue-router": "^4.2.2"
+ "vue-router": "^4.3.2"
},
"devDependencies": {
- "@vitejs/plugin-vue": "^4.2.3",
- "@vue/test-utils": "^2.4.0",
- "eslint": "^8.39.0",
- "eslint-plugin-vue": "^9.11.0",
+ "@playwright/test": "^1.44.0",
+ "@types/node": "^20.12.12",
+ "@vitejs/plugin-vue": "^4.6.2",
+ "@vue/test-utils": "^2.4.6",
+ "eslint": "^8.57.0",
+ "eslint-plugin-vue": "^9.26.0",
"jsdom": "^22.1.0",
"pwa-asset-generator": "^6.3.1",
- "vite": "^4.3.9",
- "vite-plugin-pwa": "^0.16.4",
+ "vite": "^4.5.3",
+ "vite-plugin-pwa": "^0.16.7",
"vitest": "^0.32.4"
}
}
diff --git a/playwright.config.js b/playwright.config.js
@@ -0,0 +1,54 @@
+// @ts-check
+import { defineConfig, devices } from '@playwright/test';
+
+/**
+ * Read environment variables from file.
+ * https://github.com/motdotla/dotenv
+ */
+// require('dotenv').config();
+
+/**
+ * @see https://playwright.dev/docs/test-configuration
+ */
+export default defineConfig({
+ testDir: './tests/e2e',
+ /* Run tests in files in parallel */
+ fullyParallel: true,
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
+ forbidOnly: !!process.env.CI,
+ /* Retry on CI only */
+ retries: process.env.CI ? 2 : 0,
+ /* Opt out of parallel tests on CI. */
+ workers: process.env.CI ? 1 : undefined,
+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+ reporter: 'html',
+ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+ use: {
+ /* Base URL to use in actions like `await page.goto('/')`. */
+ baseURL: 'http://localhost:5173',
+
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+ trace: 'on-first-retry',
+ },
+
+ /* Configure projects for major browsers */
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+
+ {
+ name: 'firefox',
+ use: { ...devices['Desktop Firefox'] },
+ },
+ ],
+
+ /* Run your local dev server before starting the tests */
+ webServer: {
+ command: 'npm run dev',
+ url: 'http://localhost:5173',
+ reuseExistingServer: !process.env.CI,
+ },
+});
+
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
diff --git a/src/App.vue b/src/App.vue
@@ -55,10 +55,10 @@ h1 {
#route-content {
margin: 1em;
}
-@media only screen and (max-width: 320px) {
+@media only screen and (max-width: 450px) {
/* adjust title size to fit small devices */
h1 {
- font-size: 8vw;
+ font-size: 7vw;
}
}
</style>
diff --git a/src/assets/global.css b/src/assets/global.css
@@ -15,18 +15,14 @@ input, select, button {
button {
cursor: pointer;
}
-.link, .link:focus, .link:active, .link:hover {
- border: none;
- background: none;
-}
-a, .link {
+a {
text-decoration: none;
}
-a:focus, .link:focus {
+a:focus {
text-decoration: underline;
}
@media (hover: hover) {
- a:hover, .link:hover {
+ a:hover {
text-decoration: underline;
}
}
@@ -119,7 +115,7 @@ button, input, select, tr {
dialog {
border: 2px solid var(--background5);
}
-a, .link {
+a {
color: var(--link);
}
input:invalid:not(:focus) {
diff --git a/src/assets/target-calculator.css b/src/assets/target-calculator.css
@@ -10,7 +10,7 @@ h2 {
font-size: 1.3em;
margin-bottom: 0.2em;
}
-* + h2, summary h2 {
+h2:not(:first-child), summary h2 {
margin-top: 0.5em;
}
@@ -18,9 +18,6 @@ h2 {
.input>* {
margin-bottom: 5px; /* adds space between wrapped lines */
}
-.input select {
- margin-left: 5px;
-}
/* collapsable sections */
summary {
@@ -39,6 +36,8 @@ details > * {
/* calculator output */
.output {
min-width: 300px;
+ max-width: 100%;
+ overflow: auto;
}
@media only screen and (max-width: 500px) {
.output {
diff --git a/src/components/DecimalInput.vue b/src/components/DecimalInput.vue
@@ -1,110 +1,101 @@
<template>
- <input ref="input" type="number" step="any" required @blur="onblur" v-model="stringValue">
+ <input ref="inputElement" type="number" step="any" required @blur="onblur" v-model="stringValue">
</template>
-<script>
-import formatUtils from '@/utils/format';
+<script setup>
+import { ref, watch } from 'vue';
+import { formatNumber } from '@/utils/format';
-export default {
- name: 'DecimalInput',
+/**
+ * The component value
+ */
+const model = defineModel({
+ type: Number,
+ default: 0,
+});
- props: {
- /**
- * The input value
- */
- modelValue: {
- type: Number,
- default: 0,
- },
-
- /**
- * The number of digits to show before the decimal point
- */
- padding: {
- type: Number,
- default: 0,
- validator(value) {
- return value >= 0;
- },
+const props = defineProps({
+ /**
+ * The number of digits to show before the decimal point
+ */
+ padding: {
+ type: Number,
+ default: 0,
+ validator(value) {
+ return value >= 0;
},
+ },
- /**
- * The number of digits to show after the decimal point
- */
- digits: {
- type: Number,
- default: 1,
- validator(value) {
- return value > 0;
- },
+ /**
+ * The number of digits to show after the decimal point
+ */
+ digits: {
+ type: Number,
+ default: 1,
+ validator(value) {
+ return value > 0;
},
},
+});
- data() {
- return {
- /**
- * The internal float value
- */
- internalValue: this.modelValue,
+/**
+ * The internal float value
+ */
+const internalValue = ref(model.value);
- /**
- * The raw string value (empty if input is currently invalid)
- */
- stringValue: this.format(this.modelValue),
- };
- },
+/**
+ * The raw string value (empty if input is currently invalid)
+ */
+const stringValue = ref(format(model.value));
- watch: {
- /**
- * Update the component value when the modelValue prop changes
- * @param {Number} newValue The new prop value
- */
- modelValue(newValue) {
- if (newValue !== this.internalValue) {
- this.internalValue = newValue;
- this.stringValue = this.format(this.internalValue);
- }
- },
+/**
+ * The input element
+ */
+const inputElement = ref(null);
- /**
- * Emit the input event when the internal value changes
- * @param {Number} newValue The new internal float value
- */
- internalValue(newValue) {
- this.$emit('update:modelValue', newValue);
- },
+/*
+ * Update the internal value when the component value changes
+ */
+watch(model, (newValue) => {
+ if (newValue !== internalValue.value) {
+ internalValue.value = newValue;
+ stringValue.value = format(internalValue.value);
+ }
+});
- /**
- * Update the float value when the raw string value changes
- * @param {Number} newValue The new raw string value
- */
- stringValue(newValue) {
- if (this.$refs.input.validity.valid) {
- this.internalValue = Number(newValue);
- }
- },
- },
+/**
+ * Update the component value when the internal value changes
+ */
+watch(internalValue, (newValue) => {
+ model.value = newValue;
+});
- methods: {
- /**
- * Reformat display value if not invalid
- */
- onblur() {
- if (this.$refs.input.validity.valid) {
- this.stringValue = this.format(this.internalValue);
- }
- },
+/**
+ * Update the internal value when the raw string value changes
+ */
+watch(stringValue, (newValue) => {
+ if (inputElement.value.validity.valid) {
+ internalValue.value = Number(newValue);
+ }
+});
- /**
- * Format a decimal as a string
- * @param {Number} value The decimal
- * @returns {String} The formated string
- */
- format(value) {
- return formatUtils.formatNumber(value, this.padding, this.digits, true);
- },
- },
-};
+/**
+ * Reformat display value if not invalid
+ */
+function onblur() {
+ if (inputElement.value.validity.valid) {
+ stringValue.value = format(internalValue.value);
+ }
+}
+
+/**
+ * Format a decimal as a string
+ * @param {Number} value The decimal
+ * @returns {String} The formated string
+ */
+function format(value) {
+ return formatNumber(value, props.padding, props.digits, true);
+}
</script>
<style scoped>
diff --git a/src/components/DoubleOutputTable.vue b/src/components/DoubleOutputTable.vue
@@ -0,0 +1,104 @@
+<template>
+ <div class="double-target-table">
+ <table class="results">
+ <thead>
+ <tr>
+ <th v-for="(col, x) in results[0]" :key="x">
+ {{ col }}
+ </th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <tr v-for="(row, y) in results.slice(1)" :key="y">
+ <td v-for="(col, x) in row" :key="x">
+ {{ col }}
+ </td>
+ </tr>
+
+ <tr v-if="results.length === 1" class="empty-message">
+ <td colspan="4">
+ No inputs were specified.
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+</template>
+
+<script setup>
+import { computed } from 'vue';
+import { formatDuration, formatNumber } from '@/utils/format';
+import { DISTANCE_UNITS } from '@/utils/units';
+
+const props = defineProps({
+ /**
+ * The method that generates the target table rows
+ */
+ calculateResult: {
+ type: Function,
+ required: true,
+ },
+
+ /**
+ * The target set
+ */
+ targets: {
+ type: Array,
+ default: () => [],
+ },
+
+ /**
+ * The set of input times
+ */
+ inputTimes: {
+ type: Array,
+ default: () => [],
+ },
+
+ /**
+ * The input distance
+ */
+ inputDistance: {
+ type: Object,
+ default: () => ({
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ }),
+ }
+});
+
+/**
+ * The target table results
+ */
+const results = computed(() => {
+ // Calculate results
+ const results = [[
+ formatNumber(props.inputDistance.distanceValue, 0, 2, false) + ' '
+ + DISTANCE_UNITS[props.inputDistance.distanceUnit].symbol
+ ]];
+
+ props.inputTimes.forEach((input, y) => {
+ let row = [formatDuration(input, 3, 2, false)];
+
+ props.targets.forEach(target => {
+ let result = props.calculateResult({ ...props.inputDistance, time: input }, target);
+
+ if (y === 0) {
+ results[0].push(result[result.result === 'key' ? 'value' : 'key']);
+ }
+
+ row.push(result[result.result]);
+ });
+ results.push(row);
+ });
+ return results;
+});
+</script>
+
+<style scoped>
+table th, table td {
+ /* Add more space between table cells */
+ padding: 0.2em 0.5em;
+}
+</style>
diff --git a/src/components/IntegerInput.vue b/src/components/IntegerInput.vue
@@ -1,97 +1,89 @@
<template>
- <input ref="input" type="number" step="1" required @blur="onblur" v-model="stringValue">
+ <input ref="inputElement" type="number" step="1" required @blur="onblur" v-model="stringValue">
</template>
-<script>
-export default {
- name: 'IntegerInput',
+<script setup>
+import { ref, watch } from 'vue';
- props: {
- /**
- * The input value
- */
- modelValue: {
- type: Number,
- default: 0,
- },
+/**
+ * The component value
+ */
+const model = defineModel({
+ type: Number,
+ default: 0,
+});
- /**
- * The number of digits to show before the decimal point
- */
- padding: {
- type: Number,
- default: 0,
- validator(value) {
- return value >= 0;
- },
+const props = defineProps({
+ /**
+ * The number of digits to show before the decimal point
+ */
+ padding: {
+ type: Number,
+ default: 0,
+ validator(value) {
+ return value >= 0;
},
},
+});
- data() {
- return {
- /**
- * The internal integer value
- */
- internalValue: this.modelValue,
+/**
+ * The internal integer value
+ */
+const internalValue = ref(model.value);
- /**
- * The raw string value (empty if input is currently invalid)
- */
- stringValue: this.format(this.modelValue),
- };
- },
+/**
+ * The raw string value (empty if input is currently invalid)
+ */
+const stringValue = ref(format(model.value));
- watch: {
- /**
- * Update the component value when the modelValue prop changes
- * @param {Number} newValue The new prop value
- */
- modelValue(newValue) {
- if (newValue !== this.internalValue) {
- this.internalValue = newValue;
- this.stringValue = this.format(this.internalValue);
- }
- },
+/**
+ * The input element
+ */
+const inputElement = ref(null);
- /**
- * Emit the input event when the internal value changes
- * @param {Number} newValue The new internal integer value
- */
- internalValue(newValue) {
- this.$emit('update:modelValue', newValue);
- },
+/**
+ * Update the internal value when the component value changes
+ */
+watch(model, (newValue) => {
+ if (newValue !== internalValue.value) {
+ internalValue.value = newValue;
+ stringValue.value = format(internalValue.value);
+ }
+});
- /**
- * Update the integer value when the raw string value changes
- * @param {Number} newValue The new raw string value
- */
- stringValue(newValue) {
- if (this.$refs.input.validity.valid) {
- this.internalValue = Number(newValue);
- }
- },
- },
+/**
+ * Update the component value when the internal value changes
+ */
+watch(internalValue, (newValue) => {
+ model.value = newValue;
+});
- methods: {
- /**
- * Reformat display value if not invalid
- */
- onblur() {
- if (this.$refs.input.validity.valid) {
- this.stringValue = this.format(this.internalValue);
- }
- },
+/**
+ * Update the internal value when the raw string value changes
+ */
+watch(stringValue, (newValue) => {
+ if (inputElement.value.validity.valid) {
+ internalValue.value = Number(newValue);
+ }
+});
- /**
- * Format an integer as a string
- * @param {Number} value The integer
- * @returns {String} The formated string
- */
- format(value) {
- return value.toString().padStart(this.padding, '0');
- },
- },
-};
+/**
+ * Reformat display value if not invalid
+ */
+function onblur() {
+ if (inputElement.value.validity.valid) {
+ stringValue.value = format(internalValue.value);
+ }
+}
+
+/**
+ * Format an integer as a string
+ * @param {Number} value The integer
+ * @returns {String} The formated string
+ */
+function format(value) {
+ return value.toString().padStart(props.padding, '0');
+}
</script>
<style scoped>
diff --git a/src/components/PaceInput.vue b/src/components/PaceInput.vue
@@ -0,0 +1,57 @@
+<template>
+ <div class="pace-input">
+ <div>
+ Distance:
+ <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>
+ </select>
+ </div>
+ <div>
+ Time:
+ <time-input v-model="model.time" :label="label + ' duration'"/>
+ </div>
+ </div>
+</template>
+
+<script setup>
+import DecimalInput from '@/components/DecimalInput.vue';
+import TimeInput from '@/components/TimeInput.vue';
+
+import { DISTANCE_UNITS } from '@/utils/units';
+
+/**
+ * The component value
+ */
+const model = defineModel({
+ type: Object,
+ default: {
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ },
+});
+
+defineProps({
+ /**
+ * The prefix for each field's aria-label
+ */
+ label: {
+ type: String,
+ default: 'Input',
+ },
+});
+
+</script>
+
+<style scoped>
+.pace-input div + div {
+ margin-top: 5px;
+}
+.pace-input select {
+ margin-left: 5px;
+}
+</style>
diff --git a/src/components/RaceOptions.vue b/src/components/RaceOptions.vue
@@ -0,0 +1,30 @@
+<template>
+ <div>
+ Prediction Model:
+ <select v-model="model.model" aria-label="Prediction model">
+ <option value="AverageModel">Average</option>
+ <option value="PurdyPointsModel">Purdy Points Model</option>
+ <option value="VO2MaxModel">V̇O₂ Max Model</option>
+ <option value="CameronModel">Cameron's Model</option>
+ <option value="RiegelModel">Riegel's Model</option>
+ </select>
+ </div>
+ <div>
+ Riegel Exponent:
+ <decimal-input v-model="model.riegelExponent" aria-label="Riegel exponent" :min="1" :max="1.3"
+ :digits="2" :step="0.01"/>
+ (default: 1.06)
+ </div>
+</template>
+
+<script setup>
+import DecimalInput from '@/components/DecimalInput.vue';
+
+const model = defineModel({
+ type: Object,
+ default: {
+ model: 'AverageModel',
+ riegelExponent: 1.06,
+ },
+});
+</script>
diff --git a/src/components/SimpleTargetTable.vue b/src/components/SimpleTargetTable.vue
@@ -1,144 +0,0 @@
-<template>
- <div class="simple-target-table">
- <table class="results">
- <thead>
- <tr>
- <th>Distance</th>
-
- <th>Time</th>
-
- <th v-if="showPace">Pace</th>
- </tr>
- </thead>
-
- <tbody>
- <tr v-for="(item, index) in results" :key="index">
- <td :class="item.result === 'distance' ? 'result' : ''">
- {{ formatNumber(item.distanceValue, 0, 2, item.result === 'distance') }}
- {{ distanceUnits[item.distanceUnit].symbol }}
- </td>
-
- <td :class="item.result === 'time' ? 'result' : ''">
- {{ formatDuration(item.time, 3, 2, item.result === 'time') }}
- </td>
-
- <td v-if="showPace">
- {{ formatDuration(getPace(item), 3, 0, true) }}
- / {{ distanceUnits[getDefaultDistanceUnit(defaultUnitSystem)].symbol }}
- </td>
- </tr>
-
- <tr v-if="results.length === 0" class="empty-message">
- <td colspan="4">
- There aren't any targets in this set yet.
- </td>
- </tr>
- </tbody>
- </table>
- </div>
-</template>
-
-<script>
-import formatUtils from '@/utils/format';
-import unitUtils from '@/utils/units';
-
-export default {
- name: 'SimpleTargetTable',
-
- props: {
- /**
- * The method that generates the target table rows
- */
- calculateResult: {
- type: Function,
- required: true,
- },
-
- /**
- * The target set
- */
- targets: {
- type: Array,
- default: () => [],
- },
-
- /**
- * Whether to show result paces
- */
- showPace: {
- type: Boolean,
- default: false,
- },
-
- /**
- * The unit system to use when showing result paces
- */
- defaultUnitSystem: {
- type: String,
- default: 'metric',
- },
- },
-
- data() {
- return {
- /**
- * The distance units
- */
- distanceUnits: unitUtils.DISTANCE_UNITS,
-
- /**
- * The formatDuration method
- */
- formatDuration: formatUtils.formatDuration,
-
- /**
- * The formatNumber method
- */
- formatNumber: formatUtils.formatNumber,
-
- /**
- * The getDefaultDistanceUnit method
- */
- getDefaultDistanceUnit: unitUtils.getDefaultDistanceUnit,
- };
- },
-
- computed: {
- /**
- * The target table results
- */
- results() {
- // Calculate results
- const result = [];
- this.targets.forEach((row) => {
- // Add result
- result.push(this.calculateResult(row));
- });
-
- // Sort results by time
- result.sort((a, b) => a.time - b.time);
-
- // Return results
- return result;
- },
- },
-
- methods: {
- /**
- * Get the pace of a result
- * @param {Object} result The result
- */
- getPace(result) {
- return result.time / unitUtils.convertDistance(result.distanceValue, result.distanceUnit,
- unitUtils.getDefaultDistanceUnit(this.defaultUnitSystem));
- },
- },
-};
-</script>
-
-<style scoped>
-/* target table */
-.results .result {
- font-weight: bold;
-}
-</style>
diff --git a/src/components/SingleOutputTable.vue b/src/components/SingleOutputTable.vue
@@ -0,0 +1,92 @@
+<template>
+ <div class="simple-target-table">
+ <table class="results">
+ <thead>
+ <tr>
+ <th>Distance</th>
+
+ <th>Time</th>
+
+ <th v-if="showPace">Pace</th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <tr v-for="(item, index) in results" :key="index">
+ <td :class="item.result === 'key' ? 'result' : ''">
+ {{ item.key }}
+ </td>
+
+ <td :class="item.result === 'value' ? 'result' : ''">
+ {{ item.value }}
+ </td>
+
+ <td v-if="showPace">
+ {{ item.pace }}
+ </td>
+ </tr>
+
+ <tr v-if="results.length === 0" class="empty-message">
+ <td colspan="4">
+ There aren't any targets in this set yet.
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+</template>
+
+<script setup>
+import { computed } from 'vue';
+
+const props = defineProps({
+ /**
+ * The method that generates the target table rows
+ */
+ calculateResult: {
+ type: Function,
+ required: true,
+ },
+
+ /**
+ * The target set
+ */
+ targets: {
+ type: Array,
+ default: () => [],
+ },
+
+ /**
+ * Whether to show result paces
+ */
+ showPace: {
+ type: Boolean,
+ default: false,
+ },
+});
+
+/**
+ * The target table results
+ */
+const results = computed(() => {
+ // Calculate results
+ const result = [];
+ props.targets.forEach((row) => {
+ // Add result
+ result.push(props.calculateResult(row));
+ });
+
+ // Sort results
+ result.sort((a, b) => a.sort - b.sort);
+
+ // Return results
+ return result;
+});
+</script>
+
+<style scoped>
+/* target table */
+.results .result {
+ font-weight: bold;
+}
+</style>
diff --git a/src/components/SplitOutputTable.vue b/src/components/SplitOutputTable.vue
@@ -0,0 +1,127 @@
+<template>
+ <table class="split-output-table">
+ <thead>
+ <tr>
+ <th>
+ <span>Distance</span>
+ <span class="mobile-abbreviation">Dist.</span>
+ </th>
+
+ <th>Time</th>
+
+ <th>Split</th>
+
+ <th>Pace</th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <tr v-for="(item, index) in results" :key="index">
+ <td>
+ {{ formatNumber(item.distanceValue, 0, 2, false) }}
+ {{ DISTANCE_UNITS[item.distanceUnit].symbol }}
+ </td>
+
+ <td>
+ {{ formatDuration(item.time, 3, 2, true) }}
+ </td>
+
+ <td>
+ <time-input v-model="targets[index].splitTime" label="Split duration" :showHours="false"/>
+ </td>
+
+ <td>
+ {{ formatDuration(item.pace, 3, 0, true) }}
+ / {{ DISTANCE_UNITS[getDefaultDistanceUnit(defaultUnitSystem)]
+ .symbol }}
+ </td>
+ </tr>
+
+ <tr v-if="results.length === 0" class="empty-message">
+ <td colspan="5">
+ There aren't any targets in this set yet.
+ </td>
+ </tr>
+ </tbody>
+ </table>
+</template>
+
+<script setup>
+import { computed } from 'vue';
+
+import { formatDuration, formatNumber } from '@/utils/format';
+import { DISTANCE_UNITS, convertDistance, getDefaultDistanceUnit } from '@/utils/units';
+
+import TimeInput from '@/components/TimeInput.vue';
+
+/**
+ * The split targets
+ */
+const targets = defineModel({
+ type: Array,
+ default: () => [],
+})
+
+const props = defineProps({
+ /**
+ * The unit system to use when showing result paces
+ */
+ defaultUnitSystem: {
+ type: String,
+ default: 'metric',
+ },
+});
+
+/**
+ * The target table results
+ */
+const results = computed(() => {
+ // Initialize results array
+ const results = [];
+
+ for (let i = 0; i < targets.value.length; i += 1) {
+ // Calculate split and total times
+ const splitTime = targets.value[i].splitTime || 0;
+ const totalTime = i === 0 ? splitTime : results[i - 1].time + splitTime;
+
+ // Calculate split and total distances
+ const totalDistance = convertDistance(
+ targets.value[i].distanceValue,
+ targets.value[i].distanceUnit, 'meters',
+ );
+ const splitDistance = i === 0 ? totalDistance : totalDistance - results[i - 1].distance;
+
+ // Calculate pace
+ const pace = splitTime / convertDistance(splitDistance, 'meters',
+ getDefaultDistanceUnit(props.defaultUnitSystem));
+
+ // Add row to results array
+ results.push({
+ distance: totalDistance,
+ distanceValue: targets.value[i].distanceValue,
+ distanceUnit: targets.value[i].distanceUnit,
+ time: totalTime,
+ splitTime,
+ pace,
+ });
+ }
+
+ // Return results array
+ return results;
+});
+</script>
+
+<style scoped>
+/* Show/hide mobile abbreviations */
+.split-output-table th:first-child span.mobile-abbreviation {
+ display: none;
+}
+@media only screen and (max-width: 500px) {
+ .split-output-table th:first-child span:not(.mobile-abbreviation) {
+ display: none;
+ }
+ .split-output-table th:first-child span.mobile-abbreviation {
+ display: inherit;
+ }
+}
+</style>
diff --git a/src/components/TargetEditor.vue b/src/components/TargetEditor.vue
@@ -7,13 +7,13 @@
<input v-model="internalValue.name" placeholder="Target set label"
aria-label="Target set label"/>
<button class="icon" :title="isCustomSet ? 'Delete target set' : 'Revert target set'"
- @click="revert">
+ @click="emit('revert')">
<vue-feather :type="isCustomSet ? 'trash-2' : 'rotate-ccw'" aria-hidden="true"/>
</button>
</th>
<th>
- <button class="icon" title="Close" @click="close">
+ <button class="icon" title="Close" @click="emit('close')">
<vue-feather type="x" aria-hidden="true"/>
</button>
</th>
@@ -22,18 +22,34 @@
<tbody>
<tr v-for="(item, index) in internalValue.targets" :key="index">
- <td v-if="item.result === 'time'">
- <decimal-input v-model="item.distanceValue" aria-label="Target distance value"
- :min="0" :digits="2"/>
- <select v-model="item.distanceUnit" aria-label="Target distance unit">
- <option v-for="(value, key) in distanceUnits" :key="key" :value="key">
- {{ value.name }}
- </option>
- </select>
- </td>
-
- <td v-else>
- <time-input v-model="item.time" label="Target duration"/>
+ <td>
+ <span v-if="setType === 'workout'">
+ <decimal-input v-model="item.splitValue" aria-label="Split distance value"
+ :min="0" :digits="2"/>
+ <select v-model="item.splitUnit" aria-label="Split distance unit">
+ <option v-for="(value, key) in DISTANCE_UNITS" :key="key" :value="key">
+ {{ value.name }}
+ </option>
+ </select>
+ </span>
+
+ <span v-if="setType === 'workout'">
+ @
+ </span>
+
+ <span v-if="item.type === 'distance'">
+ <decimal-input v-model="item.distanceValue" aria-label="Target distance value"
+ :min="0" :digits="2"/>
+ <select v-model="item.distanceUnit" aria-label="Target distance unit">
+ <option v-for="(value, key) in DISTANCE_UNITS" :key="key" :value="key">
+ {{ value.name }}
+ </option>
+ </select>
+ </span>
+
+ <span v-else>
+ <time-input v-model="item.time" label="Target duration"/>
+ </span>
</td>
<td>
@@ -45,7 +61,7 @@
<tr v-if="internalValue.targets.length === 0" class="empty-message">
<td colspan="2">
- There aren't any targets in this set yet
+ There aren't any targets in this set yet.
</td>
</tr>
</tbody>
@@ -56,146 +72,131 @@
<button title="Add distance target" @click="addDistanceTarget">
Add distance target
</button>
- <button title="Add time target" @click="addTimeTarget">
+ <button title="Add time target" @click="addTimeTarget" v-if="setType !== 'split'">
Add time target
</button>
- <br/>
- <p>Note: time targets are ignored by the Split Calculator</p>
</td>
</tr>
</tfoot>
</table>
</template>
-<script>
+<script setup>
+import { watch, ref } from 'vue';
+
import VueFeather from 'vue-feather';
-import targetUtils from '@/utils/targets';
-import unitUtils from '@/utils/units';
+import { DISTANCE_UNITS, getDefaultDistanceUnit } from '@/utils/units';
import DecimalInput from '@/components/DecimalInput.vue';
import TimeInput from '@/components/TimeInput.vue';
-export default {
- name: 'TargetEditor',
-
- components: {
- DecimalInput,
- TimeInput,
- VueFeather,
+/**
+ * The component value
+ */
+const model = defineModel({
+ type: Object,
+ default: {
+ name: 'New target set',
+ targets: [],
+ }
+});
+
+const props = defineProps({
+ /**
+ * Whether the target set is a custom or default set
+ */
+ isCustomSet: {
+ type: Boolean,
+ default: false,
},
- props: {
- /**
- * The targets
- */
- modelValue: {
- type: Object,
- default: JSON.parse(JSON.stringify(targetUtils.defaultTargetSet)),
- },
-
- /**
- * Whether the target set is a custom or default set
- */
- isCustomSet: {
- type: Boolean,
- default: false,
- },
-
- /**
- * The unit system to use when creating distance targets
- */
- defaultUnitSystem: {
- type: String,
- default: 'metric',
- },
+ /**
+ * The unit system to use when creating distance targets
+ */
+ defaultUnitSystem: {
+ type: String,
+ default: 'metric',
},
- data() {
- return {
- /**
- * The internal value
- */
- internalValue: this.modelValue,
-
- /**
- * The distance units
- */
- distanceUnits: unitUtils.DISTANCE_UNITS,
- };
+ /**
+ * The target set type ('standard', 'split', or 'workout')
+ */
+ setType: {
+ type: String,
+ default: 'standard'
},
+});
+
+// Declare emitted events
+const emit = defineEmits(['revert', 'close']);
+
+/**
+ * The internal value
+ */
+const internalValue = ref(model.value);
+
+/**
+ * Update the internal value when the component value changes
+ */
+watch(model, (newValue) => {
+ internalValue.value = newValue;
+}, { deep: true });
+
+/**
+ * Update the component value when the internal value changes
+ */
+watch(internalValue, (newValue) => {
+ model.value = newValue;
+}, { deep: true });
+
+/**
+ * Add a new distance based target
+ */
+function addDistanceTarget() {
+ if (props.setType === 'workout') {
+ internalValue.value.targets.push({
+ type: 'distance',
+ distanceValue: 1,
+ distanceUnit: getDefaultDistanceUnit(props.defaultUnitSystem),
+ splitValue: 1,
+ splitUnit: getDefaultDistanceUnit(props.defaultUnitSystem),
+ });
+ } else {
+ internalValue.value.targets.push({
+ type: 'distance',
+ distanceValue: 1,
+ distanceUnit: getDefaultDistanceUnit(props.defaultUnitSystem),
+ });
+ }
+}
- watch: {
- /**
- * Update the component value when the modelValue prop changes
- * @param {Number} newValue The new prop value
- */
- modelValue: {
- deep: true,
- handler(newValue) {
- this.internalValue = newValue;
- },
- },
-
- /**
- * Emit the input event when the component value changes
- * @param {Number} newValue The new component value
- */
- internalValue: {
- deep: true,
- handler(newValue) {
- this.$emit('update:modelValue', newValue);
- },
- },
- },
+/**
+ * Add a new time based target
+ */
+function addTimeTarget() {
+ if (props.setType === 'workout') {
+ internalValue.value.targets.push({
+ type: 'time',
+ time: 600,
+ splitValue: 1,
+ splitUnit: getDefaultDistanceUnit(props.defaultUnitSystem),
+ });
+ } else {
+ internalValue.value.targets.push({
+ type: 'time',
+ time: 600,
+ });
+ }
+}
- methods: {
- /**
- * Revert the target set
- */
- revert() {
- // Emit revert event
- this.$emit('revert');
- },
-
- /**
- * Close the target editor
- */
- close() {
- // Emit close event
- this.$emit('close');
- },
-
- /**
- * Add a new distance based target
- */
- addDistanceTarget() {
- this.internalValue.targets.push({
- result: 'time',
- distanceValue: 1,
- distanceUnit: unitUtils.getDefaultDistanceUnit(this.defaultUnitSystem),
- });
- },
-
- /**
- * Add a new time based target
- */
- addTimeTarget() {
- this.internalValue.targets.push({
- result: 'distance',
- time: 600,
- });
- },
-
- /**
- * Remove a target
- * @param {Number} index The index of the target
- */
- removeTarget(index) {
- this.internalValue.targets.splice(index, 1);
- },
- },
-};
+/**
+ * Remove a target
+ * @param {Number} index The index of the target
+ */
+function removeTarget(index) {
+ internalValue.value.targets.splice(index, 1);
+}
</script>
<style scoped>
@@ -203,6 +204,12 @@ export default {
.target-editor th .icon {
margin-left: 0.3em;
}
+.target-editor tbody td:first-child::not(.empty-message) {
+ display: flex;
+ gap: 0.2em;
+ flex-wrap: wrap;
+ align-items: center;
+}
.target-editor th:last-child, .target-editor td:last-child {
text-align: right;
}
@@ -217,9 +224,6 @@ export default {
.target-editor tfoot button {
margin: 0.5em;
}
-.target-editor tfoot p {
- margin-top: 0.5em;
-}
@media only screen and (max-width: 800px) {
/* leave space for revert button on mobile devices */
.target-editor th input {
diff --git a/src/components/TargetSetSelector.vue b/src/components/TargetSetSelector.vue
@@ -7,146 +7,133 @@
<option value="_new">[ Create New Target Set ]</option>
</select>
- <button class="icon" title="Edit target set"
- @click="reloadTargetSets(); sortTargetSet(); $refs.dialog.showModal()">
+ <button class="icon" title="Edit target set" @click="editTargetSet()">
<vue-feather type="edit" aria-hidden="true"/>
</button>
- <dialog ref="dialog" class="target-set-editor-dialog" aria-label="Edit target set">
- <target-editor @close="$refs.dialog.close()" v-model="targetSets[internalValue]"
- @revert="revertTargetSet" :default-unit-system="defaultUnitSystem"
- :isCustomSet="!internalValue.startsWith('_')"/>
+ <dialog ref="dialogElement" class="target-set-editor-dialog" aria-label="Edit target set">
+ <target-editor @close="sortTargetSet(); dialogElement.close()"
+ @revert="revertTargetSet" :default-unit-system="defaultUnitSystem" :setType="setType"
+ v-model="targetSets[internalValue]" :isCustomSet="!internalValue.startsWith('_')"/>
</dialog>
</span>
</template>
-<script>
+<script setup>
+import { computed, nextTick, ref } from 'vue';
+
import VueFeather from 'vue-feather';
-import storage from '@/utils/localStorage';
-import targetUtils from '@/utils/targets';
+import { sort, defaultTargetSets } from '@/utils/targets';
import TargetEditor from '@/components/TargetEditor.vue';
-export default {
- name: 'TargetSetSelector',
-
- components: {
- TargetEditor,
- VueFeather,
+/**
+ * The selected target set
+ */
+const model = defineModel('selectedTargetSet', {
+ type: String,
+ default: '_new',
+});
+
+/**
+ * The target sets
+ */
+const targetSets = defineModel('targetSets', {
+ type: Object,
+ default: {},
+});
+
+defineProps({
+ /**
+ * The unit system to use when creating distance targets
+ */
+ defaultUnitSystem: {
+ type: String,
+ default: 'metric',
},
- props: {
- /**
- * The selected target set
- */
- modelValue: {
- type: String,
- default: '_new',
- },
-
- /**
- * The unit system to use when creating distance targets
- */
- defaultUnitSystem: {
- type: String,
- default: 'metric',
- },
+ /**
+ * The target set type ('standard', 'split', or 'workout')
+ */
+ setType: {
+ type: String,
+ default: 'standard'
},
-
- data() {
- return {
- /**
- * The internal value
- */
- internalValue: this.modelValue,
-
- /**
- * The target sets
- */
- targetSets: storage.get('target-sets', targetUtils.defaultTargetSets),
- };
+});
+
+/**
+ * The dialog element
+ */
+const dialogElement = ref(null);
+
+/**
+ * The internal value
+ */
+const internalValue = computed({
+ get: () => {
+ if (model.value == '_new') {
+ newTargetSet();
+ }
+ return model.value;
},
-
- watch: {
- /**
- * Update the component value when the modelValue prop changes
- */
- modelValue(newValue) {
- if (newValue !== this.internalValue) {
- this.internalValue = newValue;
- }
- },
-
- /**
- * Emit the input event when the component value changes and create a new set if necessary
- */
- internalValue: {
- immediate: true,
- handler(newValue) {
- if (newValue == '_new') {
- let key = Date.now().toString();
- this.targetSets[key] = {
- name: 'New target set',
- targets: [],
- };
- this.internalValue = key;
- } else {
- this.$emit('update:modelValue', newValue);
- }
- },
- },
-
- /**
- * Save target sets
- */
- targetSets: {
- deep: true,
- handler(newValue) {
- storage.set('target-sets', newValue);
- this.$emit('targets-updated');
- },
- },
+ set: async (newValue) => {
+ if (newValue == '_new') {
+ await nextTick(); // <select> won't update if value changed immediately
+ newTargetSet();
+ } else {
+ model.value = newValue;
+ }
},
+});
+
+/**
+ * Open TargetEditor for the current target set
+ */
+function editTargetSet() {
+ if (dialogElement.value && dialogElement.value.showModal) {
+ // Missing in test environments, but is difficult to mock because it may be referenced on mount
+ dialogElement.value.showModal();
+ }
+}
- methods: {
- /**
- * Revert or remove the current target set
- */
- revertTargetSet() {
- if (this.internalValue.startsWith('_')) {
- // Revert default set
- this.targetSets[this.internalValue] =
- JSON.parse(JSON.stringify(targetUtils.defaultTargetSets[this.internalValue]));
- this.sortTargetSet();
- } else {
- // Remove custom set
- delete this.targetSets[this.internalValue];
- this.internalValue = [...Object.keys(this.targetSets), '_new'][0];
- if (this.$refs.dialog.close) this.$refs.dialog.close();
- }
- },
-
- /**
- * Sort the current target set
- */
- sortTargetSet() {
- this.targetSets[this.internalValue].targets =
- targetUtils.sort(this.targetSets[this.internalValue].targets);
- },
-
- /**
- * Reload the target sets
- */
- reloadTargetSets() {
- this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets);
- },
- },
+/**
+ * Create and select a new target
+ */
+function newTargetSet() {
+ let key = Date.now().toString();
+ targetSets.value[key] = {
+ name: 'New target set',
+ targets: [],
+ };
+ model.value = key;
+ editTargetSet();
+}
- activated() {
- this.reloadTargetSets();
- },
-};
+/**
+ * Revert or remove the current target set
+ */
+function revertTargetSet() {
+ if (internalValue.value.startsWith('_')) {
+ // Revert default set
+ targetSets.value[internalValue.value] =
+ JSON.parse(JSON.stringify(defaultTargetSets[internalValue.value]));
+ sortTargetSet();
+ } else {
+ // Remove custom set
+ delete targetSets.value[internalValue.value];
+ internalValue.value = [...Object.keys(targetSets.value), '_new'][0];
+ if (dialogElement.value.close) dialogElement.value.close();
+ }
+}
+
+/**
+ * Sort the current target set
+ */
+function sortTargetSet() {
+ targetSets.value[internalValue.value].targets =
+ sort(targetSets.value[internalValue.value].targets);
+}
</script>
<style scoped>
@@ -155,7 +142,7 @@ export default {
}
.target-set-editor-dialog {
- width: min(100% - 2em, 400px);
+ width: min(100% - 2em, 450px);
max-height: min(100% - 2em, 815px);
margin-top: 100px;
}
diff --git a/src/components/TimeInput.vue b/src/components/TimeInput.vue
@@ -13,145 +13,126 @@
</div>
</template>
-<script>
+<script setup>
+import { computed, ref, watch } from 'vue';
+
import IntegerInput from '@/components/IntegerInput.vue';
import DecimalInput from '@/components/DecimalInput.vue';
-export default {
- name: 'TimeInput',
-
- components: {
- IntegerInput,
- DecimalInput,
+/**
+ * The component value
+ */
+const model = defineModel({
+ type: Number,
+ default: 0,
+ validator(value) {
+ return value >= 0 && value <= 359999.99;
},
+});
- props: {
- /**
- * The input value
- */
- modelValue: {
- type: Number,
- default: 0,
- validator(value) {
- return value >= 0 && value <= 359999.99;
- },
- },
-
- /**
- * Whether to show the hour field
- */
- showHours: {
- type: Boolean,
- default: true,
- },
-
- /**
- * The prefix for each field's aria-label
- */
- label: {
- type: String,
- default: '',
- },
+const props = defineProps({
+ /**
+ * Whether to show the hour field
+ */
+ showHours: {
+ type: Boolean,
+ default: true,
},
- data() {
- return {
- /**
- * The internal value
- */
- internalValue: this.modelValue,
- };
+ /**
+ * The prefix for each field's aria-label
+ */
+ label: {
+ type: String,
+ default: '',
},
+});
- computed: {
- /**
- * The maximum value
- */
- max() {
- return this.showHours ? 359999.99 : 3599.99;
- },
+/**
+ * The internal value
+ */
+const internalValue = ref(model.value);
- /**
- * The value of the hours field
- */
- hours: {
- get() {
- return Math.floor(this.modelValue / 3600);
- },
- set(newValue) {
- this.internalValue = (newValue * 3600) + (this.minutes * 60) + this.seconds;
- },
- },
+/**
+ * The maximum value
+ */
+const max = computed(() => {
+ return props.showHours ? 359999.99 : 3599.99;
+});
- /**
- * The value of the minutes field
- */
- minutes: {
- get() {
- return Math.floor((this.modelValue % 3600) / 60);
- },
- set(newValue) {
- this.internalValue = (this.hours * 3600) + (newValue * 60) + this.seconds;
- },
- },
-
- /**
- * The value of the seconds field
- */
- seconds: {
- get() {
- return this.modelValue % 60;
- },
- set(newValue) {
- this.internalValue = (this.hours * 3600) + (this.minutes * 60) + newValue;
- },
- },
+/**
+ * The value of the hours field
+ */
+const hours = computed({
+ get() {
+ return Math.floor(model.value / 3600);
},
+ set(newValue) {
+ internalValue.value = (newValue * 3600) + (minutes.value * 60) + seconds.value;
+ },
+});
- watch: {
- /**
- * Update the component value when the modelValue prop changes
- * @param {Number} newValue The new prop value
- */
- modelValue(newValue) {
- if (newValue !== this.internalValue) {
- this.internalValue = newValue;
- }
- },
-
- /**
- * Emit the input event when the component value changes
- * @param {Number} newValue The new component value
- */
- internalValue(newValue) {
- this.$emit('update:modelValue', newValue);
- },
+/**
+ * The value of the minutes field
+ */
+const minutes = computed({
+ get() {
+ return Math.floor((model.value % 3600) / 60);
},
+ set(newValue) {
+ internalValue.value = (hours.value * 3600) + (newValue * 60) + seconds.value;
+ },
+});
- methods: {
- /**
- * Process up and down arrow presses
- * @param {Object} e The keydown event args
- */
- onkeydown(e, step = 1) {
- if (e.key === 'ArrowUp') {
- if (Math.floor(this.internalValue) + step > this.max) {
- this.internalValue = this.max;
- } else {
- this.internalValue = Math.floor(this.internalValue) + step;
- }
- e.preventDefault();
- } else if (e.key === 'ArrowDown') {
- if (Math.ceil(this.internalValue) - step < 0) {
- this.internalValue = 0;
- } else {
- this.internalValue = Math.ceil(this.internalValue) - step;
- }
- e.preventDefault();
- }
- },
+/**
+ * The value of the seconds field
+ */
+const seconds = computed({
+ get() {
+ return model.value % 60;
+ },
+ set(newValue) {
+ internalValue.value = (hours.value * 3600) + (minutes.value * 60) + newValue;
},
-};
+});
+
+/**
+ * Update the internal value when the component value changes
+ */
+watch(model, (newValue) => {
+ if (newValue !== internalValue.value) {
+ internalValue.value = newValue;
+ }
+});
+
+/**
+ * Update the component value when the internal value changes
+ */
+watch(internalValue, (newValue) => {
+ model.value = newValue;
+});
+
+/**
+ * Process up and down arrow presses
+ * @param {Object} e The keydown event args
+ */
+function onkeydown(e, step = 1) {
+ if (e.key === 'ArrowUp') {
+ if (Math.floor(internalValue.value) + step > max.value) {
+ internalValue.value = max.value;
+ } else {
+ internalValue.value = Math.floor(internalValue.value) + step;
+ }
+ e.preventDefault();
+ } else if (e.key === 'ArrowDown') {
+ if (Math.ceil(internalValue.value) - step < 0) {
+ internalValue.value = 0;
+ } else {
+ internalValue.value = Math.ceil(internalValue.value) - step;
+ }
+ e.preventDefault();
+ }
+}
</script>
<style scoped>
diff --git a/src/composables/useStorage.js b/src/composables/useStorage.js
@@ -0,0 +1,36 @@
+import { ref, onActivated, watchEffect } from 'vue';
+
+// The global localStorage prefix
+const prefix = 'running-tools';
+
+/*
+ * Create a reactive value that is synced with a localStorage item
+ * @param {String} key The localStorage item's key
+ * @defaultValue {Object} defaultValue The default value
+ */
+export default function useStorage(key, defaultValue) {
+ const clonedDefault = JSON.parse(JSON.stringify(defaultValue));
+ const value = ref(clonedDefault);
+
+ // (Re)load value from localStorage
+ function updateValue() {
+ let parsedValue;
+ try {
+ parsedValue = JSON.parse(localStorage.getItem(`${prefix}.${key}`));
+ } catch {
+ parsedValue = null;
+ }
+ if (parsedValue !== null) value.value = parsedValue;
+ }
+ updateValue();
+ onActivated(updateValue);
+
+ // Save value to localStorage when modified
+ watchEffect(() => {
+ if (typeof localStorage !== 'undefined') {
+ localStorage.setItem(`${prefix}.${key}`, JSON.stringify(value.value));
+ }
+ })
+
+ return value
+}
diff --git a/src/router/index.js b/src/router/index.js
@@ -1,9 +1,11 @@
import { createRouter, createWebHashHistory } from 'vue-router';
import HomePage from '@/views/HomePage.vue';
import AboutPage from '@/views/AboutPage.vue';
+import BatchCalculator from '@/views/BatchCalculator.vue';
import PaceCalculator from '@/views/PaceCalculator.vue';
import RaceCalculator from '@/views/RaceCalculator.vue';
import SplitCalculator from '@/views/SplitCalculator.vue';
+import WorkoutCalculator from '@/views/WorkoutCalculator.vue';
import UnitCalculator from '@/views/UnitCalculator.vue';
import NotFoundPage from '@/views/NotFoundPage.vue';
@@ -37,6 +39,15 @@ const router = createRouter({
redirect: '/home',
},
{
+ path: '/calculate/batch',
+ name: 'calculate-batch',
+ component: BatchCalculator,
+ meta: {
+ title: 'Batch Calculator',
+ back: 'home',
+ },
+ },
+ {
path: '/calculate/paces',
name: 'calculate-paces',
component: PaceCalculator,
@@ -73,6 +84,15 @@ const router = createRouter({
},
},
{
+ path: '/calculate/workouts',
+ name: 'calculate-workouts',
+ component: WorkoutCalculator,
+ meta: {
+ title: 'Workout Calculator',
+ back: 'home',
+ },
+ },
+ {
path: '/:pathMatch(.*)*',
component: NotFoundPage,
},
diff --git a/src/utils/calculators.js b/src/utils/calculators.js
@@ -0,0 +1,173 @@
+import { formatDuration, formatNumber } from '@/utils/format';
+import * as paceUtils from '@/utils/paces';
+import * as raceUtils from '@/utils/races';
+import { DISTANCE_UNITS, convertDistance, getDefaultDistanceUnit } from '@/utils/units';
+
+/**
+ * Format a distance/time result as a key/value result
+ * @param {Object} result The distance/time result
+ * @param {String} defaultUnitSystem The default unit system (imperial or metric)
+ * @returns {Object} The key/value result
+ */
+export function formatDistTimeResult(result, defaultUnitSystem) {
+ // Calculate numerical pace
+ const pace = result.time / convertDistance(result.distanceValue, result.distanceUnit,
+ getDefaultDistanceUnit(defaultUnitSystem));
+
+ return {
+ // Convert distance to key string
+ key: formatNumber(result.distanceValue, 0, 2, result.result === 'distance') + ' '
+ + DISTANCE_UNITS[result.distanceUnit].symbol,
+
+ // Convert time to time string
+ value: formatDuration(result.time, 3, 2, result.result === 'time'),
+
+ // Convert pace to pace string
+ pace: formatDuration(pace, 3, 0, true) + ' / '
+ + DISTANCE_UNITS[getDefaultDistanceUnit(defaultUnitSystem)].symbol,
+
+ // Convert dist/time result to key/value
+ result: result.result === 'time' ? 'value' : 'key',
+
+ // Use time (in seconds) as sort key
+ sort: result.time,
+ };
+}
+
+/**
+ * Calculate paces from a target
+ * @param {Object} input The input pace
+ * @param {Object} target The pace target
+ * @param {String} defaultUnitSystem The default unit system (imperial or metric)
+ * @returns {Object} The result
+ */
+export function calculatePaceResults(input, target, defaultUnitSystem) {
+ const result = {
+ distanceValue: target.distanceValue,
+ distanceUnit: target.distanceUnit,
+ time: target.time,
+ result: target.type === 'distance' ? 'time' : 'distance',
+ };
+
+ const d1 = convertDistance(input.distanceValue, input.distanceUnit, 'meters');
+
+ // Add missing value to result
+ if (target.type === 'distance') {
+ // Convert target distance into meters
+ const d2 = convertDistance(target.distanceValue, target.distanceUnit, 'meters');
+
+ // Calculate time to travel distance at input pace
+ result.time = paceUtils.calculateTime(d1, input.time, d2);
+ } else {
+ // Calculate distance traveled in time at input pace
+ const d2 = paceUtils.calculateDistance(input.time, d1, target.time);
+
+ // Convert output distance into default distance unit
+ const units = getDefaultDistanceUnit(defaultUnitSystem);
+ result.distanceValue = convertDistance(d2, 'meters', units);
+ result.distanceUnit = units;
+ }
+
+ // Return result
+ return formatDistTimeResult(result, defaultUnitSystem);
+}
+
+/**
+ * Predict race results from a target
+ * @param {Object} input The input race
+ * @param {Object} target The race target
+ * @param {Object} options The race prediction options
+ * @param {String} defaultUnitSystem The default unit system (imperial or metric)
+ * @returns {Object} The result
+ */
+export function calculateRaceResults(input, target, options, defaultUnitSystem) {
+ const result = {
+ distanceValue: target.distanceValue,
+ distanceUnit: target.distanceUnit,
+ time: target.time,
+ result: target.type === 'distance' ? 'time' : 'distance',
+ };
+
+ const d1 = convertDistance(input.distanceValue, input.distanceUnit, 'meters');
+
+ // Add missing value to result
+ if (target.type === 'distance') {
+ // Convert target distance into meters
+ const d2 = convertDistance(target.distanceValue, target.distanceUnit, 'meters');
+
+ // Get prediction
+ result.time = raceUtils.predictTime(d1, input.time, d2, options.model, options.riegelExponent);
+ } else {
+ // Get prediction
+ let distance = raceUtils.predictDistance(input.time, d1, target.time, options.model,
+ options.riegelExponent);
+
+ // Convert output distance into default distance unit
+ distance = convertDistance(distance, 'meters',
+ getDefaultDistanceUnit(defaultUnitSystem));
+
+ // Update result
+ result.distanceValue = distance;
+ result.distanceUnit = getDefaultDistanceUnit(defaultUnitSystem);
+ }
+
+ // Return result
+ return formatDistTimeResult(result, defaultUnitSystem);
+}
+
+/**
+ * Calculate race statistics from an input race
+ * @param {Object} input The input race
+ * @returns {Object} The race statistics
+ */
+export function calculateRaceStats(input) {
+ const d1 = convertDistance(input.distanceValue, input.distanceUnit, '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 {Object} input The input race
+ * @param {Object} target The workout target
+ * @param {Object} options The race prediction options
+ * @returns {Object} The result
+ */
+export function calculateWorkoutResults(input, target, options) {
+ const d1 = convertDistance(input.distanceValue, input.distanceUnit, 'meters');
+ const t1 = input.time;
+ const d3 = convertDistance(target.splitValue, target.splitUnit, 'meters');
+ let d2, t2, t3;
+
+ // Calculate pace
+ let key = formatNumber(target.splitValue, 0, 2, false) + ' '
+ + DISTANCE_UNITS[target.splitUnit].symbol + ' @ ';
+ if (target.type === 'distance') {
+ // Convert target distance into meters
+ d2 = convertDistance(target.distanceValue, target.distanceUnit, 'meters');
+ t2 = raceUtils.predictTime(d1, input.time, d2, options.model, options.riegelExponent);
+ key += formatNumber(target.distanceValue, 0, 2, false) + ' '
+ + DISTANCE_UNITS[target.distanceUnit].symbol;
+ } else {
+ t2 = target.time;
+ d2 = raceUtils.predictDistance(t1, d1, t2, options.model,
+ options.riegelExponent);
+ key += formatDuration(target.time, 3, 2, false);
+ }
+
+ t3 = paceUtils.calculateTime(d2, t2, d3);
+
+ // Calculate time
+ return {
+ key: key,
+ value: formatDuration(t3, 3, 2, true),
+ pace: '', // Pace not used in workout calculator
+ result: 'value',
+ sort: t3,
+ }
+}
diff --git a/src/utils/format.js b/src/utils/format.js
@@ -6,7 +6,7 @@
* @param {Boolean} extraDigits Whether to show extra zeros after the decimal point
* @returns {String} The formatted value
*/
-function formatNumber(value, minPadding = 0, maxDigits = 2, extraDigits = true) {
+export function formatNumber(value, minPadding = 0, maxDigits = 2, extraDigits = true) {
// Initialize result
let result = '';
@@ -51,7 +51,7 @@ function formatNumber(value, minPadding = 0, maxDigits = 2, extraDigits = true)
* @param {Boolean} extraDigits Whether to show extra zeros after the decimal point
* @returns {String} The formatted value
*/
-function formatDuration(value, minPadding = 6, maxDigits = 2, extraDigits = true) {
+export function formatDuration(value, minPadding = 6, maxDigits = 2, extraDigits = true) {
// Check if value is NaN
if (Number.isNaN(value)) {
return 'NaN';
@@ -97,8 +97,3 @@ function formatDuration(value, minPadding = 6, maxDigits = 2, extraDigits = true
// Return result
return result;
}
-
-export default {
- formatNumber,
- formatDuration,
-};
diff --git a/src/utils/localStorage.js b/src/utils/localStorage.js
@@ -1,40 +0,0 @@
-// The global localStorage prefix
-const prefix = 'running-tools';
-
-/**
- * Get the value of a key from localStorage
- * @param {String} key The key
- * @param {Object} defaultValue The default value
- * @returns {Object} The value
- */
-function get(key, defaultValue) {
- // Clone defaultValue
- const clonedDefault = JSON.parse(JSON.stringify(defaultValue));
-
- if (key === null) {
- return clonedDefault;
- }
- let value;
- try {
- value = JSON.parse(localStorage.getItem(`${prefix}.${key}`));
- } catch {
- return clonedDefault;
- }
- return value === null ? clonedDefault : value;
-}
-
-/**
- * Set the value of a key in localStorage
- * @param {String} key The key
- * @param {Object} value The value
- * */
-function set(key, value) {
- if (typeof localStorage !== 'undefined') {
- localStorage.setItem(`${prefix}.${key}`, JSON.stringify(value));
- }
-}
-
-export default {
- get,
- set,
-};
diff --git a/src/utils/paces.js b/src/utils/paces.js
@@ -1,35 +1,21 @@
/**
- * Calculate pace from distance and time
- * @param {Number} distance The distance (in meters)
- * @param {Number} time The time (in seconds)
- * @returns {Number} The pace (in seconds per meter)
+ * Calculate time from a distance and input pace
+ * @param {Number} d1 The input pace distance (in any unit)
+ * @param {Number} t1 The input pace time (in seconds)
+ * @param {Number} d2 The output distance (in the same unit as d1)
+ * @returns {Number} The output time (in seconds)
*/
-function getPace(distance, time) {
- return time / distance;
+export function calculateTime(d1, t1, d2) {
+ return (t1 / d1) * d2
}
/**
- * Calculate time from pace and distance
- * @param {Number} pace The pace (in seconds per meter)
- * @param {Number} distance The distance (in meters)
- * @returns {Number} The time (in seconds)
+ * Calculate distance from a time and input pace
+ * @param {Number} t1 The input pace time (in seconds)
+ * @param {Number} d1 The input pace distance (in any unit)
+ * @param {Number} t2 The output time (in seconds)
+ * @returns {Number} The output distance (in the same unit as d1)
*/
-function getTime(pace, distance) {
- return pace * distance;
+export function calculateDistance(t1, d1, t2) {
+ return (d1 / t1) * t2
}
-
-/**
- * Calculate distance from pace and time
- * @param {Number} pace The pace (in seconds per meter)
- * @param {Number} time The time (in seconds)
- * @return {Number} The distance (in meters)
- */
-function getDistance(pace, time) {
- return time / pace;
-}
-
-export default {
- getPace,
- getTime,
- getDistance,
-};
diff --git a/src/utils/races.js b/src/utils/races.js
@@ -84,7 +84,7 @@ const PurdyPointsModel = {
*/
getPurdyPoints(d, t) {
// Get variables
- const variables = this.getVariables(d);
+ const variables = PurdyPointsModel.getVariables(d);
// Calculate Purdy Points
const points = variables.a * ((variables.twsec / t) - variables.b);
@@ -102,10 +102,10 @@ const PurdyPointsModel = {
*/
predictTime(d1, t1, d2) {
// Calculate Purdy Points for distance 1
- const points = this.getPurdyPoints(d1, t1);
+ const points = PurdyPointsModel.getPurdyPoints(d1, t1);
// Calculate time for distance 2
- const variables = this.getVariables(d2);
+ const variables = PurdyPointsModel.getVariables(d2);
const seconds = (variables.a * variables.twsec) / (points + (variables.a * variables.b));
// Return predicted time
@@ -161,9 +161,9 @@ const PurdyPointsModel = {
// Initialize estimate
let estimate = (d1 * t2) / t1;
- // Refine estimate
- const method = (x) => this.predictTime(d1, t1, x);
- const derivative = (x) => this.derivative(d1, t1, x) / 500; // Derivative on its own is too slow
+ // Refine estimate (derivative on its own is too slow)
+ const method = (x) => PurdyPointsModel.predictTime(d1, t1, x);
+ const derivative = (x) => PurdyPointsModel.derivative(d1, t1, x) / 500;
estimate = NewtonsMethod(estimate, t2, method, derivative, 0.01);
// Return estimate
@@ -208,7 +208,7 @@ const VO2MaxModel = {
* @returns {Number} The runner's VO2 max
*/
getVO2Max(d, t) {
- const result = this.getVO2(d, t) / this.getVO2Percentage(t);
+ const result = VO2MaxModel.getVO2(d, t) / VO2MaxModel.getVO2Percentage(t);
return result;
},
@@ -237,14 +237,14 @@ const VO2MaxModel = {
*/
predictTime(d1, t1, d2) {
// Calculate input VO2 max
- const inputVO2 = this.getVO2Max(d1, t1);
+ const inputVO2 = VO2MaxModel.getVO2Max(d1, t1);
// Initialize estimate
let estimate = (t1 * d2) / d1;
// Refine estimate
- const method = (x) => this.getVO2Max(d2, x);
- const derivative = (x) => this.VO2MaxTimeDerivative(d2, x);
+ const method = (x) => VO2MaxModel.getVO2Max(d2, x);
+ const derivative = (x) => VO2MaxModel.VO2MaxTimeDerivative(d2, x);
estimate = NewtonsMethod(estimate, inputVO2, method, derivative, 0.0001);
// Return estimate
@@ -272,14 +272,14 @@ const VO2MaxModel = {
*/
predictDistance(t1, d1, t2) {
// Calculate input VO2 max
- const inputVO2 = this.getVO2Max(d1, t1);
+ const inputVO2 = VO2MaxModel.getVO2Max(d1, t1);
// Initialize estimate
let estimate = (d1 * t2) / t1;
// Refine estimate
- const method = (x) => this.getVO2Max(x, t2);
- const derivative = (x) => this.VO2MaxDistanceDerivative(x, t2);
+ const method = (x) => VO2MaxModel.getVO2Max(x, t2);
+ const derivative = (x) => VO2MaxModel.VO2MaxDistanceDerivative(x, t2);
estimate = NewtonsMethod(estimate, inputVO2, method, derivative, 0.0001);
// Return estimate
@@ -332,8 +332,8 @@ const CameronModel = {
let estimate = (d1 * t2) / t1;
// Refine estimate
- const method = (x) => this.predictTime(d1, t1, x);
- const derivative = (x) => this.derivative(d1, t1, x);
+ const method = (x) => CameronModel.predictTime(d1, t1, x);
+ const derivative = (x) => CameronModel.derivative(d1, t1, x);
estimate = NewtonsMethod(estimate, t2, method, derivative, 0.01);
// Return estimate
@@ -408,10 +408,54 @@ const AverageModel = {
},
};
-export default {
- PurdyPointsModel,
- VO2MaxModel,
- CameronModel,
- RiegelModel,
- AverageModel,
-};
+/**
+ * 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, t1, d2, model='AverageModel', c=1.06) {
+ switch (model) {
+ case 'AverageModel':
+ return AverageModel.predictTime(d1, t1, d2, c);
+ case 'PurdyPointsModel':
+ return PurdyPointsModel.predictTime(d1, t1, d2);
+ case 'VO2MaxModel':
+ return VO2MaxModel.predictTime(d1, t1, d2);
+ case 'RiegelModel':
+ return RiegelModel.predictTime(d1, t1, d2, c);
+ case '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, d1, t2, model='AverageModel', c=1.06) {
+ switch (model) {
+ default:
+ case 'AverageModel':
+ return AverageModel.predictDistance(t1, d1, t2, c);
+ case 'PurdyPointsModel':
+ return PurdyPointsModel.predictDistance(t1, d1, t2);
+ case 'VO2MaxModel':
+ return VO2MaxModel.predictDistance(t1, d1, t2);
+ case 'RiegelModel':
+ return RiegelModel.predictDistance(t1, d1, t2, c);
+ case '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/targets.js b/src/utils/targets.js
@@ -1,101 +1,111 @@
-import unitUtils from '@/utils/units';
+import { convertDistance } from '@/utils/units';
/**
* Sort an array of targets
* @param {Array} targets The array of targets
* @returns {Array} The sorted targets
*/
-function sort(targets) {
+export function sort(targets) {
return [
- ...targets.filter((item) => item.result === 'time')
- .sort((a, b) => unitUtils.convertDistance(a.distanceValue, a.distanceUnit, 'meters')
- - unitUtils.convertDistance(b.distanceValue, b.distanceUnit, 'meters')),
+ ...targets.filter((item) => item.type === 'distance')
+ .sort((a, b) => convertDistance(a.distanceValue, a.distanceUnit, 'meters')
+ - convertDistance(b.distanceValue, b.distanceUnit, 'meters')),
- ...targets.filter((item) => item.result === 'distance')
+ ...targets.filter((item) => item.type === 'time')
.sort((a, b) => a.time - b.time),
];
}
-const defaultTargetSets = {
+export const defaultTargetSets = {
'_pace_targets': {
name: 'Common Pace Targets',
- targets: [
- { result: 'time', distanceValue: 100, distanceUnit: 'meters' },
- { result: 'time', distanceValue: 200, distanceUnit: 'meters' },
- { result: 'time', distanceValue: 300, distanceUnit: 'meters' },
- { result: 'time', distanceValue: 400, distanceUnit: 'meters' },
- { result: 'time', distanceValue: 600, distanceUnit: 'meters' },
- { result: 'time', distanceValue: 800, distanceUnit: 'meters' },
- { result: 'time', distanceValue: 1000, distanceUnit: 'meters' },
- { result: 'time', distanceValue: 1200, distanceUnit: 'meters' },
- { result: 'time', distanceValue: 1500, distanceUnit: 'meters' },
- { result: 'time', distanceValue: 1600, distanceUnit: 'meters' },
- { result: 'time', distanceValue: 3200, distanceUnit: 'meters' },
+ targets: sort([
+ { type: 'distance', distanceValue: 100, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 200, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 300, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 400, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 600, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 800, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 1000, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 1200, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 1500, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 1600, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 3200, distanceUnit: 'meters' },
- { result: 'time', distanceValue: 2, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 3, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 4, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 6, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 8, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 3, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 4, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 6, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 8, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 10, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 3, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 5, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 6, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 8, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 10, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 1, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 3, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 5, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 6, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 8, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 10, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 0.5, distanceUnit: 'marathons' },
- { result: 'time', distanceValue: 1, distanceUnit: 'marathons' },
+ { type: 'distance', distanceValue: 0.5, distanceUnit: 'marathons' },
+ { type: 'distance', distanceValue: 1, distanceUnit: 'marathons' },
- { result: 'distance', time: 600 },
- { result: 'distance', time: 1800 },
- { result: 'distance', time: 3600 },
- ],
+ { type: 'time', time: 600 },
+ { type: 'time', time: 1800 },
+ { type: 'time', time: 3600 },
+ ]),
},
'_race_targets': {
name: 'Common Race Targets',
- targets: [
- { result: 'time', distanceValue: 400, distanceUnit: 'meters' },
- { result: 'time', distanceValue: 800, distanceUnit: 'meters' },
- { result: 'time', distanceValue: 1500, distanceUnit: 'meters' },
- { result: 'time', distanceValue: 1600, distanceUnit: 'meters' },
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 3000, distanceUnit: 'meters' },
- { result: 'time', distanceValue: 3200, distanceUnit: 'meters' },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
+ targets: sort([
+ { type: 'distance', distanceValue: 400, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 800, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 1500, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 1600, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 1, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 3000, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 3200, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 3, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 6, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 8, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 15, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 3, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 6, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 8, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 10, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 15, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 0.5, distanceUnit: 'marathons' },
- { result: 'time', distanceValue: 1, distanceUnit: 'marathons' },
- ],
+ { type: 'distance', distanceValue: 0.5, distanceUnit: 'marathons' },
+ { type: 'distance', distanceValue: 1, distanceUnit: 'marathons' },
+ ]),
},
'_split_targets': {
name: '5K Mile Splits',
targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 1, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' },
+ ],
+ },
+ '_workout_targets': {
+ name: 'Common Workout Targets',
+ targets: [
+ {
+ splitValue: 400, splitUnit: 'meters',
+ type: 'distance', distanceValue: 1, distanceUnit: 'miles',
+ },
+ {
+ splitValue: 800, splitUnit: 'meters',
+ type: 'distance', distanceValue: 5, distanceUnit: 'kilometers',
+ },
+ {
+ splitValue: 1600, splitUnit: 'meters',
+ type: 'time', time: 3600,
+ },
+ {
+ splitValue: 1, splitUnit: 'miles',
+ type: 'time', time: 7200,
+ },
],
},
-};
-
-const defaultTargetSet = {
- name: 'New target set',
- targets: [],
-};
-
-export default {
- sort,
- defaultTargetSets,
- defaultTargetSet,
};
diff --git a/src/utils/units.js b/src/utils/units.js
@@ -1,7 +1,7 @@
/**
* The time units
*/
-const TIME_UNITS = {
+export const TIME_UNITS = {
seconds: {
name: 'Seconds',
symbol: 's',
@@ -22,7 +22,7 @@ const TIME_UNITS = {
/**
* The distance units
*/
-const DISTANCE_UNITS = {
+export const DISTANCE_UNITS = {
meters: {
name: 'Meters',
symbol: 'm',
@@ -53,7 +53,7 @@ const DISTANCE_UNITS = {
/**
* The speed units
*/
-const SPEED_UNITS = {
+export const SPEED_UNITS = {
meters_per_second: {
name: 'Meters per Second',
symbol: 'm/s',
@@ -74,7 +74,7 @@ const SPEED_UNITS = {
/**
* The value of each pace unit in seconds per meter
*/
-const PACE_UNITS = {
+export const PACE_UNITS = {
seconds_per_meter: {
name: 'Seconds per Meter',
symbol: 's/m',
@@ -99,7 +99,7 @@ const PACE_UNITS = {
* @param {String} outputUnit The unit of the output
* @returns {Number} The output
*/
-function convertTime(inputValue, inputUnit, outputUnit) {
+export function convertTime(inputValue, inputUnit, outputUnit) {
return (inputValue * TIME_UNITS[inputUnit].value) / TIME_UNITS[outputUnit].value;
}
@@ -110,7 +110,7 @@ function convertTime(inputValue, inputUnit, outputUnit) {
* @param {String} outputUnit The unit of the output
* @returns {Number} The output
*/
-function convertDistance(inputValue, inputUnit, outputUnit) {
+export function convertDistance(inputValue, inputUnit, outputUnit) {
return (inputValue * DISTANCE_UNITS[inputUnit].value) / DISTANCE_UNITS[outputUnit].value;
}
@@ -121,7 +121,7 @@ function convertDistance(inputValue, inputUnit, outputUnit) {
* @param {String} outputUnit The unit of the output
* @returns {Number} The output
*/
-function convertSpeed(inputValue, inputUnit, outputUnit) {
+export function convertSpeed(inputValue, inputUnit, outputUnit) {
return (inputValue * SPEED_UNITS[inputUnit].value) / SPEED_UNITS[outputUnit].value;
}
@@ -132,7 +132,7 @@ function convertSpeed(inputValue, inputUnit, outputUnit) {
* @param {String} outputUnit The unit of the output
* @returns {Number} The output
*/
-function convertPace(inputValue, inputUnit, outputUnit) {
+export function convertPace(inputValue, inputUnit, outputUnit) {
return (inputValue * PACE_UNITS[inputUnit].value) / PACE_UNITS[outputUnit].value;
}
@@ -143,7 +143,7 @@ function convertPace(inputValue, inputUnit, outputUnit) {
* @param {String} outputUnit The unit of the output
* @returns {Number} The output
*/
-function convertSpeedPace(inputValue, inputUnit, outputUnit) {
+export function convertSpeedPace(inputValue, inputUnit, outputUnit) {
// Calculate input speed
let speed;
if (inputUnit in PACE_UNITS) {
@@ -163,7 +163,7 @@ function convertSpeedPace(inputValue, inputUnit, outputUnit) {
* Detect the user's default unit system
* @returns {String} The default unit system
*/
-function detectDefaultUnitSystem() {
+export function detectDefaultUnitSystem() {
const language = (navigator.language || navigator.userLanguage).toLowerCase();
if (language.endsWith('-us') || language.endsWith('-mm')) {
return 'imperial';
@@ -176,7 +176,7 @@ function detectDefaultUnitSystem() {
* @param {String} unitSystem The unit system
* @returns {String} The default distance unit
*/
-function getDefaultDistanceUnit(unitSystem) {
+export function getDefaultDistanceUnit(unitSystem) {
return unitSystem === 'metric' ? 'kilometers' : 'miles';
}
@@ -185,7 +185,7 @@ function getDefaultDistanceUnit(unitSystem) {
* @param {String} unitSystem The unit system
* @returns {String} The default speed unit
*/
-function getDefaultSpeedUnit(unitSystem) {
+export function getDefaultSpeedUnit(unitSystem) {
return unitSystem === 'metric' ? 'kilometers_per_hour' : 'miles_per_hour';
}
@@ -194,24 +194,6 @@ function getDefaultSpeedUnit(unitSystem) {
* @param {String} unitSystem The unit system
* @returns {String} The default pace unit
*/
-function getDefaultPaceUnit(unitSystem) {
+export function getDefaultPaceUnit(unitSystem) {
return unitSystem === 'metric' ? 'seconds_per_kilometer' : 'seconds_per_mile';
}
-
-export default {
- TIME_UNITS,
- DISTANCE_UNITS,
- SPEED_UNITS,
- PACE_UNITS,
-
- convertTime,
- convertDistance,
- convertSpeed,
- convertPace,
- convertSpeedPace,
-
- detectDefaultUnitSystem,
- getDefaultDistanceUnit,
- getDefaultSpeedUnit,
- getDefaultPaceUnit,
-};
diff --git a/src/views/AboutPage.vue b/src/views/AboutPage.vue
@@ -19,7 +19,23 @@
</p>
<h2>The Calculators</h2>
- <p>Running Tools contains four calculators:</p>
+ <p>Running Tools contains six calculators:</p>
+
+ <h3>Batch Calculator</h3>
+ <p>
+ The <router-link :to="{ name: 'calculate-batch' }">Batch Calculator</router-link> calculates
+ results for a range of input times using the Pace, Race, or Workout Calculators.
+ Options such as the default unit system, selected target set, and race prediction model are
+ synced from the active calculator.
+ </p>
+ <p>
+ The Batch Calculator is useful for tasks such as:
+ </p>
+ <ul class="questions">
+ <li>Generating a table of mile splits and the corresponding marathon finish times.</li>
+ <li>Generating a table of equivalent race results for many distances and speeds.</li>
+ <li>Generating a table of workout split times for an entire team.</li>
+ </ul>
<h3>Pace Calculator</h3>
<p>
@@ -36,7 +52,6 @@
<li>What do I have to run per mile to finish a marathon in three hours? (6:52 per mile)</li>
</ul>
-
<h3>Race Calculator</h3>
<p>
The <router-link :to="{ name: 'calculate-races' }">Race Calculator</router-link> takes a
@@ -44,10 +59,12 @@
equivalent race results.
The selected target set controls which distances and/or times the calculator predicts race
results for.
+ Extra output statistics for the input race result are also available under the Race Statistics
+ section.
</p>
<p>
- The Advanced section of the Race Calculator includes extra output statistics for the input
- race result and the option to switch between the five supported race prediction models:
+ The Advanced Options section includes the option to switch between the five supported race
+ prediction models:
</p>
<ul>
<li>The Purdy Points Model</li>
@@ -85,12 +102,8 @@
<ul class="questions">
<li>How fast would I finish a 1600m if I ran the 400m laps in 90s, 85s, 80s, and 75s? (5:30)</li>
<li>If I finished a 5K in 20:00 and ran the first 2 miles in 13:00, how fast was the last ~1.1
- miles? (6:19 per mile pace)</li>
+ miles? (6:19 / mi pace)</li>
</ul>
- <p>
- <strong>Note:</strong> The split calculator only works with distance targets and ignores all
- time targets.
- </p>
<h3>Unit Calculator</h3>
<p>
@@ -102,46 +115,57 @@
</p>
<ul class="questions">
<li>How many miles is a 5K? (3.107 miles)</li>
- <li>What is 10 mph in time per mile? (6:00 per mile)</li>
+ <li>What is 10 mph in time per mile? (6:00 / mi)</li>
<li>What is 123.4 minutes in hh:mm:ss? (02:03:24)</li>
</ul>
+ <h3>Workout Calculator</h3>
+ <p>
+ The <router-link :to="{ name: 'calculate-workouts' }">Workout Calculator</router-link> takes a
+ distance and duration as input and shows intermediate splits for other equivalent race
+ results.
+ The selected target set controls which race distances and/or times the calculator calculates
+ outputs for and the distances of the splits that are shown for these races.
+ The Advanced Options section includes the option to switch between the same five prediction
+ models that are available in the Race Calculator.
+ </p>
+ <p>
+ The Workout Calculator is useful for answering questions like:
+ </p>
+ <ul class="questions">
+ <li>If I raced a 5K in 20:00, how fast should I run 400m intervals at mile pace? (about 1:27)</li>
+ <li>If I raced a mile in 5:00, what is my "threshold" (~1 hr race) pace? (about 5:50 / mi)</li>
+ </ul>
+ <p>
+ <strong>Note:</strong> Results are just estimated race splits that are helpful for estimating
+ target workout splits.
+ As with the Race Calculator, splits are most accurate for similar distances and assume equal
+ fitness.
+ </p>
+
<h2>Target Sets</h2>
<p>
- A target set is a collection of distances and times that the Pace, Race, and Split Calculators
- will calculate results for.
+ A target set is a collection of distances and/or times that the Pace, Race, Split, or Workout
+ Calculators will calculate results for.
These calculators will output a duration for each distance target and a distance for each time
target.
- Running Tools comes with three default target sets.
- You can switch between these sets, modify the targets they contain, and add new targets sets
- from within each supporting calculator.
+ Each of these calculators comes with a default target set and allows you to add new target
+ sets, modify existing target sets, and switch between sets that belong to the same
+ calculator.
</p>
<p>
- <strong>Note:</strong> The split calculator only works with distance targets and ignores all
- time targets.
+ <strong>Note:</strong> The split calculator only supports distance targets. The workout
+ calculator also includes a split distance field for each target.
</p>
</div>
</template>
-<script>
+<script setup>
import { version } from '/package.json';
import VueFeather from 'vue-feather';
-export default {
- name: 'AboutPage',
-
- components: {
- VueFeather
- },
-
- data() {
- return {
- version,
- development: process.env.NODE_ENV === 'development',
- };
- },
-};
+const development = process.env.NODE_ENV === 'development';
</script>
<style scoped>
@@ -161,7 +185,7 @@ p, blockquote, ul {
}
li {
margin-bottom: 0.2em;
- margin-left: 3em;
+ margin-left: 1.5em;
}
p {
line-height: 1.3;
@@ -181,9 +205,4 @@ p {
filter: invert(1);
}
}
-@media only screen and (max-width: 800px) {
- li {
- margin-left: 1.5em;
- }
-}
</style>
diff --git a/src/views/BatchCalculator.vue b/src/views/BatchCalculator.vue
@@ -0,0 +1,208 @@
+<template>
+ <div class="calculator">
+ <h2>Batch Input</h2>
+ <div class="input">
+ <pace-input v-model="input" aria-label="Input"/>
+ </div>
+
+ <h2>Batch Options</h2>
+ <div class="input">
+ <div>
+ Increment:
+ <time-input v-model="options.increment" label="Duration increment" :show-hours="false"/>
+ ×
+ <integer-input v-model="options.rows" min="1" aria-label="Number of rows"/>
+ </div>
+ <div>
+ Calculator:
+ <select aria-label="Calculator" v-model="options.calculator">
+ <option value="pace">Pace Calculator</option>
+ <option value="race">Race Calculator</option>
+ <option value="workout">Workout Calculator</option>
+ </select>
+ </div>
+ </div>
+
+ <details>
+ <summary>
+ <h2>Advanced Options</h2>
+ </summary>
+ <div>
+ Default units:
+ <select v-model="defaultUnitSystem" aria-label="Default units">
+ <option value="imperial">Miles</option>
+ <option value="metric">Kilometers</option>
+ </select>
+ </div>
+ <div>
+ Target Set:
+ <target-set-selector v-model:selectedTargetSet="selectedTargetSet"
+ v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/>
+ </div>
+ <race-options v-if="options.calculator !== 'pace'" v-model="advancedOptions"/>
+ </details>
+
+ <h2>Batch Results</h2>
+ <double-output-table class="output" :input-times="inputTimes" :input-distance="inputDistance"
+ :calculate-result="calculateResult"
+ :targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []"/>
+ </div>
+</template>
+
+<script setup>
+import { computed } from 'vue';
+
+import * as calcUtils from '@/utils/calculators';
+import { defaultTargetSets } from '@/utils/targets';
+import { detectDefaultUnitSystem } from '@/utils/units';
+
+import DoubleOutputTable from '@/components/DoubleOutputTable.vue';
+import IntegerInput from '@/components/IntegerInput.vue';
+import PaceInput from '@/components/PaceInput.vue';
+import RaceOptions from '@/components/RaceOptions.vue';
+import TargetSetSelector from '@/components/TargetSetSelector.vue';
+import TimeInput from '@/components/TimeInput.vue';
+
+import useStorage from '@/composables/useStorage';
+
+/**
+ * The input pace
+ */
+const input = useStorage('batch-calculator-input', {
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+});
+
+/**
+ * The batch input options
+ */
+const options = useStorage('batch-calculator-options', {
+ calculator: 'workout',
+ increment: 15,
+ rows: 20,
+});
+
+/**
+ * The default unit system
+ */
+const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSystem());
+
+/**
+ * The current selected target sets for each calculator
+ */
+const selectedPaceTargetSet = useStorage('pace-calculator-target-set', '_pace_targets');
+const selectedRaceTargetSet = useStorage('race-calculator-target-set', '_race_targets');
+const selectedWorkoutTargetSet = useStorage('workout-calculator-target-set', '_workout_targets');
+
+/**
+ * The target sets for each calculator
+ */
+const paceTargetSets = useStorage('pace-calculator-target-sets', {
+ _pace_targets: defaultTargetSets._pace_targets
+});
+const raceTargetSets = useStorage('race-calculator-target-sets', {
+ _race_targets: defaultTargetSets._race_targets
+});
+const workoutTargetSets = useStorage('workout-calculator-target-sets', {
+ _workout_targets: defaultTargetSets._workout_targets
+});
+
+/**
+ * The advanced options for each calculator
+ */
+const raceOptions = useStorage('race-calculator-options', {
+ model: 'AverageModel',
+ riegelExponent: 1.06,
+});
+const workoutOptions = useStorage('workout-calculator-options', {
+ model: 'AverageModel',
+ riegelExponent: 1.06,
+});
+
+/**
+ * The input distance
+ */
+const inputDistance = computed(() => ({
+ distanceValue: input.value.distanceValue,
+ distanceUnit: input.value.distanceUnit,
+}));
+
+/**
+ * The set of input times
+ */
+const inputTimes = computed(() => {
+ let results = [];
+ for (let i = 0; i < options.value.rows; i++) {
+ results.push(input.value.time + options.value.increment * i);
+ }
+ return results;
+});
+
+/**
+ * The selected target set for the current calculator
+ */
+const selectedTargetSet = computed({
+ get: () => {
+ if (options.value.calculator === 'pace') {
+ return selectedPaceTargetSet.value;
+ } else if (options.value.calculator === 'race') {
+ return selectedRaceTargetSet.value;
+ } else {
+ return selectedWorkoutTargetSet.value;
+ }
+ },
+ set: (newValue) => {
+ if (options.value.calculator === 'pace') {
+ selectedPaceTargetSet.value = newValue;
+ } else if (options.value.calculator === 'race') {
+ selectedRaceTargetSet.value = newValue;
+ } else {
+ selectedWorkoutTargetSet.value = newValue;
+ }
+ },
+});
+
+/**
+ * The target sets for the current calculator
+ */
+const targetSets = computed(() => {
+ if (options.value.calculator === 'pace') {
+ return paceTargetSets.value;
+ } else if (options.value.calculator === 'race') {
+ return raceTargetSets.value;
+ } else {
+ return workoutTargetSets.value;
+ }
+});
+
+/**
+ * The advanced options for the current calculator
+ */
+const advancedOptions = computed(() => {
+ if (options.value.calculator === 'pace') {
+ return {};
+ } else if (options.value.calculator === 'race') {
+ return raceOptions.value;
+ } else {
+ return workoutOptions.value;
+ }
+});
+
+/**
+ * The appropriate calculate_results function for the current calculator
+ */
+const calculateResult = computed(() => {
+ if (options.value.calculator === 'pace') {
+ return (x,y) => calcUtils.calculatePaceResults(x, y, defaultUnitSystem.value);
+ } else if (options.value.calculator === 'race') {
+ return (x,y) => calcUtils.calculateRaceResults(x, y, raceOptions.value, defaultUnitSystem.value);
+ } else {
+ return (x,y) => calcUtils.calculateWorkoutResults(x, y, workoutOptions.value);
+ }
+});
+</script>
+
+<style scoped>
+@import '@/assets/target-calculator.css';
+</style>
diff --git a/src/views/HomePage.vue b/src/views/HomePage.vue
@@ -4,6 +4,11 @@
A collection of tools for runners and their coaches
</p>
<div class="calculators">
+ <router-link :to="{ name: 'calculate-batch' }" v-slot="{ navigate }" custom>
+ <button @click="navigate">
+ Batch Calculator
+ </button>
+ </router-link>
<router-link :to="{ name: 'calculate-paces' }" v-slot="{ navigate }" custom>
<button @click="navigate">
Pace Calculator
@@ -24,6 +29,11 @@
Unit Calculator
</button>
</router-link>
+ <router-link :to="{ name: 'calculate-workouts' }" v-slot="{ navigate }" custom>
+ <button @click="navigate">
+ Workout Calculator
+ </button>
+ </router-link>
</div>
<p class="about-link">
<router-link :to="{ name: 'about' }">
@@ -33,12 +43,6 @@
</div>
</template>
-<script>
-export default {
- name: 'HomePage',
-};
-</script>
-
<style scoped>
.home-page {
text-align: center;
@@ -47,28 +51,28 @@ export default {
}
.description {
font-size: 1.5em;
- margin-bottom: 1em;
}
.calculators {
display: flex;
- flex-direction: row;
+ flex-wrap: wrap;
+ gap: 0.5em;
+ justify-content: center;
+
+ max-width: 39em;
+ margin: 1em auto;
}
.calculators button {
- flex-grow: 1;
+ width: 12em;
font-size: 1em;
padding: 0.5em;
- margin: 0em 0.3em;
-}
-.about-link {
- margin-top: 1em;
}
-@media only screen and (max-width: 600px) {
+@media only screen and (max-width: 500px) {
.calculators {
- flex-direction: column;
+ gap: 0.75em;
}
.calculators button {
- margin: 0.3em 0em;
padding: 0.75em 0.5em;
+ width: 100%;
}
}
</style>
diff --git a/src/views/NotFoundPage.vue b/src/views/NotFoundPage.vue
@@ -1,21 +1,18 @@
<template>
<div class="not-found-page">
<h1>404 Not Found</h1>
- <p><router-link to="/home">homepage</router-link></p>
+ <p><router-link to="/home">Return home</router-link></p>
</div>
</template>
-<script>
-export default {
- name: 'NotFoundPage',
-};
-</script>
-
<style scoped>
-h1 {
- font-size: 1.5em;
-}
.not-found-page {
text-align: center;
}
+.not-found-page h1 {
+ font-size: 1.5em;
+}
+.not-found-page p {
+ margin-top: 0.5em;
+}
</style>
diff --git a/src/views/PaceCalculator.vue b/src/views/PaceCalculator.vue
@@ -2,20 +2,7 @@
<div class="calculator">
<h2>Input Pace</h2>
<div class="input">
- <div>
- Distance:
- <decimal-input v-model="inputDistance" aria-label="Input distance value"
- :min="0" :digits="2"/>
- <select v-model="inputUnit" aria-label="Input distance unit">
- <option v-for="(value, key) in distanceUnits" :key="key" :value="key">
- {{ value.name }}
- </option>
- </select>
- </div>
- <div>
- Time:
- <time-input v-model="inputTime" label="Input duration"/>
- </div>
+ <pace-input v-model="input"/>
</div>
<details>
@@ -31,186 +18,54 @@
</div>
<div>
Target Set:
- <target-set-selector v-model="selectedTargetSet" @targets-updated="reloadTargets"
- :default-unit-system="defaultUnitSystem"/>
+ <target-set-selector v-model:selectedTargetSet="selectedTargetSet"
+ v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/>
</div>
</details>
<h2>Equivalent Paces</h2>
- <simple-target-table class="output" :calculate-result="calculatePace"
+ <single-output-table class="output" :calculate-result="x =>
+ calculatePaceResults(input, x, defaultUnitSystem)"
:targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []"/>
</div>
</template>
-<script>
-import paceUtils from '@/utils/paces';
-import storage from '@/utils/localStorage';
-import targetUtils from '@/utils/targets';
-import unitUtils from '@/utils/units';
+<script setup>
+import { calculatePaceResults } from '@/utils/calculators';
+import { defaultTargetSets } from '@/utils/targets';
+import { detectDefaultUnitSystem } from '@/utils/units';
-import DecimalInput from '@/components/DecimalInput.vue';
-import SimpleTargetTable from '@/components/SimpleTargetTable.vue';
+import PaceInput from '@/components/PaceInput.vue';
+import SingleOutputTable from '@/components/SingleOutputTable.vue';
import TargetSetSelector from '@/components/TargetSetSelector.vue';
-import TimeInput from '@/components/TimeInput.vue';
-
-export default {
- name: 'PaceCalculator',
-
- components: {
- DecimalInput,
- SimpleTargetTable,
- TargetSetSelector,
- TimeInput,
- },
-
- data() {
- return {
- /**
- * The input distance value
- */
- inputDistance: storage.get('pace-calculator-input-distance', 5),
-
- /**
- * The input distance unit
- */
- inputUnit: storage.get('pace-calculator-input-unit', 'kilometers'),
-
- /**
- * The input time value
- */
- inputTime: storage.get('pace-calculator-input-time', 20 * 60),
-
- /**
- * The default unit system
- *
- * Loaded in activate() method
- */
- defaultUnitSystem: null,
-
- /**
- * The names of the distance units
- */
- distanceUnits: unitUtils.DISTANCE_UNITS,
-
- /**
- * The current selected target set
- */
- selectedTargetSet: storage.get('pace-calculator-target-set', '_pace_targets'),
-
- /**
- * The target sets
- *
- * Loaded in activate() method
- */
- targetSets: {},
- };
- },
-
- watch: {
- /**
- * Save input distance value
- */
- inputDistance(newValue) {
- storage.set('pace-calculator-input-distance', newValue);
- },
-
- /**
- * Save input distance unit
- */
- inputUnit(newValue) {
- storage.set('pace-calculator-input-unit', newValue);
- },
-
- /**
- * Save input time value
- */
- inputTime(newValue) {
- storage.set('pace-calculator-input-time', newValue);
- },
-
- /**
- * Save default unit system
- */
- defaultUnitSystem(newValue) {
- storage.set('default-unit-system', newValue);
- },
-
- /**
- * Save the current selected target set
- */
- selectedTargetSet(newValue) {
- storage.set('pace-calculator-target-set', newValue);
- },
- },
-
- computed: {
- /**
- * The input pace (in seconds per meter)
- */
- pace() {
- const distance = unitUtils.convertDistance(this.inputDistance, this.inputUnit, 'meters');
- return paceUtils.getPace(distance, this.inputTime);
- },
- },
-
- methods: {
- /**
- * Reload the target sets
- */
- reloadTargets() {
- this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets);
- },
-
- /**
- * Calculate paces from a target
- * @param {Object} target The target
- * @returns {Object} The result
- */
- calculatePace(target) {
- // Initialize result
- const result = {
- distanceValue: target.distanceValue,
- distanceUnit: target.distanceUnit,
- time: target.time,
- result: target.result,
- };
-
- // Add missing value to result
- if (target.result === 'time') {
- // Convert target distance into meters
- const d2 = unitUtils.convertDistance(target.distanceValue, target.distanceUnit, 'meters');
-
- // Calculate time to travel distance at input pace
- const time = paceUtils.getTime(this.pace, d2);
-
- // Update result
- result.time = time;
- } else {
- // Calculate distance traveled in time at input pace
- let distance = paceUtils.getDistance(this.pace, target.time);
-
- // Convert output distance into default distance unit
- distance = unitUtils.convertDistance(distance, 'meters',
- unitUtils.getDefaultDistanceUnit(this.defaultUnitSystem));
-
- // Update result
- result.distanceValue = distance;
- result.distanceUnit = unitUtils.getDefaultDistanceUnit(this.defaultUnitSystem);
- }
-
- // Return result
- return result;
- },
- },
- /**
- * (Re)load settings used in multiple calculators
- */
- activated() {
- this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets);
- this.defaultUnitSystem = storage.get('default-unit-system', unitUtils.detectDefaultUnitSystem());
- },
-};
+import useStorage from '@/composables/useStorage';
+
+/**
+ * The input pace
+ */
+const input = useStorage('pace-calculator-input', {
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+});
+
+/**
+ * The default unit system
+ */
+const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSystem());
+
+/**
+ * The current selected target set
+ */
+const selectedTargetSet = useStorage('pace-calculator-target-set', '_pace_targets');
+
+/**
+ * The target sets
+ */
+const targetSets = useStorage('pace-calculator-target-sets', {
+ _pace_targets: defaultTargetSets._pace_targets
+});
</script>
<style scoped>
diff --git a/src/views/RaceCalculator.vue b/src/views/RaceCalculator.vue
@@ -2,19 +2,7 @@
<div class="calculator">
<h2>Input Race Result</h2>
<div class="input">
- <div>
- Distance:
- <decimal-input v-model="inputDistance" aria-label="Input distance value" :min="0" :digits="2"/>
- <select v-model="inputUnit" aria-label="Input distance unit">
- <option v-for="(value, key) in distanceUnits" :key="key" :value="key">
- {{ value.name }}
- </option>
- </select>
- </div>
- <div>
- Time:
- <time-input v-model="inputTime" label="Input race duration"/>
- </div>
+ <pace-input v-model="input" label="Input race"/>
</div>
<details>
@@ -22,14 +10,15 @@
<h2>Race Statistics</h2>
</summary>
<div>
- Purdy Points: <b>{{ formatNumber(purdyPoints, 0, 1, true) }}</b>
+ Purdy Points: <b>{{ formatNumber(raceStats.purdyPoints, 0, 1, true) }}</b>
</div>
<div>
- V̇O₂: <b>{{ formatNumber(vo2, 0, 1, true) }}</b> ml/kg/min
- (<b>{{ formatNumber(vo2Percentage, 0, 1, true) }}%</b> of max)
+ V̇O₂: <b>{{ formatNumber(raceStats.vo2, 0, 1, true) }}</b> ml/kg/min
+ (<b>{{ formatNumber(raceStats.vo2MaxPercentage, 0, 1, true) }}%</b> of max)
</div>
<div>
- V̇O₂ Max: <b>{{ formatNumber(vo2Max, 0, 1, true) }}</b> ml/kg/min
+ V̇O₂ Max: <b>{{ formatNumber(raceStats.vo2Max, 0, 1, true) }}</b>
+ ml/kg/min
</div>
</details>
@@ -46,304 +35,72 @@
</div>
<div>
Target Set:
- <target-set-selector v-model="selectedTargetSet" @targets-updated="reloadTargets"
- :default-unit-system="defaultUnitSystem"/>
- </div>
- <div>
- Prediction Model:
- <select v-model="model" aria-label="Prediction model">
- <option value="AverageModel">Average</option>
- <option value="PurdyPointsModel">Purdy Points Model</option>
- <option value="VO2MaxModel">V̇O₂ Max Model</option>
- <option value="CameronModel">Cameron's Model</option>
- <option value="RiegelModel">Riegel's Model</option>
- </select>
- </div>
- <div>
- Riegel Exponent:
- <decimal-input v-model="riegelExponent" aria-label="Riegel exponent" :min="1" :max="1.3"
- :digits="2" :step="0.01"/>
- (default: 1.06)
+ <target-set-selector v-model:selectedTargetSet="selectedTargetSet"
+ v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/>
</div>
+ <race-options v-model="options"/>
</details>
<h2>Equivalent Race Results</h2>
- <simple-target-table class="output" :calculate-result="predictResult" :default-unit-system="defaultUnitSystem"
- :targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []" show-pace/>
+ <single-output-table class="output" show-pace
+ :calculate-result="x => calculateRaceResults(input, x, options, defaultUnitSystem)"
+ :targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []"/>
</div>
</template>
-<script>
-import formatUtils from '@/utils/format';
-import raceUtils from '@/utils/races';
-import storage from '@/utils/localStorage';
-import targetUtils from '@/utils/targets';
-import unitUtils from '@/utils/units';
-
-import DecimalInput from '@/components/DecimalInput.vue';
-import SimpleTargetTable from '@/components/SimpleTargetTable.vue';
-import TargetSetSelector from '@/components/TargetSetSelector.vue';
-import TimeInput from '@/components/TimeInput.vue';
-
-export default {
- name: 'RaceCalculator',
-
- components: {
- DecimalInput,
- SimpleTargetTable,
- TargetSetSelector,
- TimeInput,
- },
-
- data() {
- return {
- /**
- * The input distance value
- */
- inputDistance: storage.get('race-calculator-input-distance', 5),
-
- /**
- * The input distance unit
- */
- inputUnit: storage.get('race-calculator-input-unit', 'kilometers'),
-
- /**
- * The input time value
- */
- inputTime: storage.get('race-calculator-input-time', 20 * 60),
-
- /**
- * The default unit system
- *
- * Loaded in activate() method
- */
- defaultUnitSystem: null,
-
- /**
- * The race prediction model
- */
- model: storage.get('race-calculator-model', 'AverageModel'),
-
- /**
- * The value of the exponent in Riegel's Model
- */
- riegelExponent: storage.get('race-calculator-riegel-exponent', 1.06),
-
- /**
- * The names of the distance units
- */
- distanceUnits: unitUtils.DISTANCE_UNITS,
-
- /**
- * The formatNumber method
- */
- formatNumber: formatUtils.formatNumber,
+<script setup>
+import { computed } from 'vue';
- /**
- * The current selected target set
- */
- selectedTargetSet: storage.get('race-calculator-target-set', '_race_targets'),
+import { calculateRaceResults, calculateRaceStats } from '@/utils/calculators';
+import { formatNumber } from '@/utils/format';
+import { defaultTargetSets } from '@/utils/targets';
+import { detectDefaultUnitSystem } from '@/utils/units';
- /**
- * The target sets
- *
- * Loaded in activate() method
- */
- targetSets: {},
- };
- },
-
- methods: {
- /**
- * Reload the target sets
- */
- reloadTargets() {
- this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets);
- },
-
- /**
- * Predict race results from a target
- * @param {Object} target The target
- * @returns {Object} The result
- */
- predictResult(target) {
- // Initialize result
- const result = {
- distanceValue: target.distanceValue,
- distanceUnit: target.distanceUnit,
- time: target.time,
- result: target.result,
- };
-
- // Add missing value to result
- if (target.result === 'time') {
- // Convert target distance into meters
- const d2 = unitUtils.convertDistance(target.distanceValue, target.distanceUnit, 'meters');
-
- // Get prediction
- let time;
- switch (this.model) {
- default:
- case 'AverageModel':
- time = raceUtils.AverageModel.predictTime(this.d1, this.inputTime, d2,
- this.riegelExponent);
- break;
- case 'PurdyPointsModel':
- time = raceUtils.PurdyPointsModel.predictTime(this.d1, this.inputTime, d2);
- break;
- case 'VO2MaxModel':
- time = raceUtils.VO2MaxModel.predictTime(this.d1, this.inputTime, d2);
- break;
- case 'RiegelModel':
- time = raceUtils.RiegelModel.predictTime(this.d1, this.inputTime, d2,
- this.riegelExponent);
- break;
- case 'CameronModel':
- time = raceUtils.CameronModel.predictTime(this.d1, this.inputTime, d2);
- break;
- }
-
- // Update result
- result.time = time;
- } else {
- // Get prediction
- let distance;
- switch (this.model) {
- default:
- case 'AverageModel':
- distance = raceUtils.AverageModel.predictDistance(this.inputTime, this.d1, target.time,
- this.riegelExponent);
- break;
- case 'PurdyPointsModel':
- distance = raceUtils.PurdyPointsModel.predictDistance(this.inputTime, this.d1,
- target.time);
- break;
- case 'VO2MaxModel':
- distance = raceUtils.VO2MaxModel.predictDistance(this.inputTime, this.d1, target.time);
- break;
- case 'RiegelModel':
- distance = raceUtils.RiegelModel.predictDistance(this.inputTime, this.d1, target.time,
- this.riegelExponent);
- break;
- case 'CameronModel':
- distance = raceUtils.CameronModel.predictDistance(this.inputTime, this.d1, target.time);
- break;
- }
-
- // Convert output distance into default distance unit
- distance = unitUtils.convertDistance(distance, 'meters',
- unitUtils.getDefaultDistanceUnit(this.defaultUnitSystem));
-
- // Update result
- result.distanceValue = distance;
- result.distanceUnit = unitUtils.getDefaultDistanceUnit(this.defaultUnitSystem);
- }
-
- // Return result
- return result;
- },
- },
-
- computed: {
- /**
- * The input distance in meters
- */
- d1() {
- return unitUtils.convertDistance(this.inputDistance, this.inputUnit, 'meters');
- },
-
- /**
- * The Purdy Points for the input race
- */
- purdyPoints() {
- const result = raceUtils.PurdyPointsModel.getPurdyPoints(this.d1, this.inputTime);
- return result;
- },
-
- /**
- * The VO2 Max calculated from the input race
- */
- vo2Max() {
- const result = raceUtils.VO2MaxModel.getVO2Max(this.d1, this.inputTime);
- return result;
- },
-
- /**
- * The VO2 calculated from the input race
- */
- vo2() {
- const result = raceUtils.VO2MaxModel.getVO2(this.d1, this.inputTime);
- return result;
- },
-
- /**
- * The percentage of VO2 Max calculated from the input race
- */
- vo2Percentage() {
- const result = raceUtils.VO2MaxModel.getVO2Percentage(this.inputTime) * 100;
- return result;
- },
- },
-
- watch: {
- /**
- * Save input distance value
- */
- inputDistance(newValue) {
- storage.set('race-calculator-input-distance', newValue);
- },
-
- /**
- * Save input distance unit
- */
- inputUnit(newValue) {
- storage.set('race-calculator-input-unit', newValue);
- },
-
- /**
- * Save input time value
- */
- inputTime(newValue) {
- storage.set('race-calculator-input-time', newValue);
- },
-
- /**
- * Save default unit system
- */
- defaultUnitSystem(newValue) {
- storage.set('default-unit-system', newValue);
- },
-
- /**
- * Save prediction model
- */
- model(newValue) {
- storage.set('race-calculator-model', newValue);
- },
-
- /**
- * Save Riegel Model exponent
- */
- riegelExponent(newValue) {
- storage.set('race-calculator-riegel-exponent', newValue);
- },
-
- /**
- * Save the current selected target set
- */
- selectedTargetSet(newValue) {
- storage.set('race-calculator-target-set', newValue);
- },
- },
+import PaceInput from '@/components/PaceInput.vue';
+import RaceOptions from '@/components/RaceOptions.vue';
+import SingleOutputTable from '@/components/SingleOutputTable.vue';
+import TargetSetSelector from '@/components/TargetSetSelector.vue';
- /**
- * (Re)load settings used in multiple calculators
- */
- activated() {
- this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets);
- this.defaultUnitSystem = storage.get('default-unit-system', unitUtils.detectDefaultUnitSystem());
- },
-};
+import useStorage from '@/composables/useStorage';
+
+/**
+ * The input race
+ */
+const input = useStorage('race-calculator-input', {
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+});
+
+/**
+ * The default unit system
+ */
+const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSystem());
+
+/**
+* The race prediction options
+*/
+const options = useStorage('race-calculator-options', {
+ model: 'AverageModel',
+ riegelExponent: 1.06,
+});
+
+/**
+ * The current selected target set
+ */
+const selectedTargetSet = useStorage('race-calculator-target-set', '_race_targets');
+
+/**
+ * The target sets
+ */
+let targetSets = useStorage('race-calculator-target-sets', {
+ _race_targets: defaultTargetSets._race_targets
+});
+
+/**
+ * The statistics for the current input race
+ */
+const raceStats = computed(() => calculateRaceStats(input.value));
</script>
<style scoped>
diff --git a/src/views/SplitCalculator.vue b/src/views/SplitCalculator.vue
@@ -1,244 +1,81 @@
<template>
<div class="calculator">
- <div class="default-units">
- Default units:
- <select v-model="defaultUnitSystem" aria-label="Default units">
- <option value="imperial">Miles</option>
- <option value="metric">Kilometers</option>
- </select>
- </div>
-
- <div class="target-set">
- Target Set:
- <target-set-selector v-model="selectedTargetSet" @targets-updated="reloadTargets"
- :default-unit-system="defaultUnitSystem"/>
+ <div class="input">
+ <div class="default-units">
+ Default units:
+ <select v-model="defaultUnitSystem" aria-label="Default units">
+ <option value="imperial">Miles</option>
+ <option value="metric">Kilometers</option>
+ </select>
+ </div>
+
+ <div class="target-set">
+ Target Set:
+ <target-set-selector v-model:selectedTargetSet="selectedTargetSet" setType="split"
+ v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/>
+ </div>
</div>
<div class="output">
- <table class="results">
- <thead>
- <tr>
- <th>
- <span>Distance</span>
- <span class="mobile-abbreviation">Dist.</span>
- </th>
-
- <th>Time</th>
-
- <th>Split</th>
-
- <th>Pace</th>
- </tr>
- </thead>
-
- <tbody>
- <tr v-for="(item, index) in results" :key="index">
- <td>
- {{ formatNumber(item.distanceValue, 0, 2, false) }}
- {{ distanceUnits[item.distanceUnit].symbol }}
- </td>
-
- <td>
- {{ formatDuration(item.totalTime, 3, 2, true) }}
- </td>
-
- <td v-if="targetSets[selectedTargetSet]">
- <time-input v-model="targetSets[selectedTargetSet].targets[index].split"
- label="Split duration" :showHours="false"/>
- </td>
-
- <td>
- {{ formatDuration(item.pace, 3, 0, true) }}
- / {{ distanceUnits[getDefaultDistanceUnit(defaultUnitSystem)].symbol }}
- </td>
- </tr>
-
- <tr v-if="!targetSets[selectedTargetSet] || targetSets[selectedTargetSet].targets.length === 0" class="empty-message">
- <td colspan="5">
- There aren't any targets in this set yet.
- </td>
- </tr>
- </tbody>
- </table>
+ <split-output-table :default-unit-system="defaultUnitSystem" v-model="targetSet"/>
</div>
</div>
</template>
-<script>
-import formatUtils from '@/utils/format';
-import storage from '@/utils/localStorage';
-import targetUtils from '@/utils/targets';
-import unitUtils from '@/utils/units';
-
-import TargetSetSelector from '@/components/TargetSetSelector.vue';
-import TimeInput from '@/components/TimeInput.vue';
-
-export default {
- name: 'SplitCalculator',
-
- components: {
- TargetSetSelector,
- TimeInput,
- },
-
- data() {
- return {
- /**
- * The default unit system
- *
- * Loaded in activate() method
- */
- defaultUnitSystem: null,
-
- /**
- * The distance units
- */
- distanceUnits: unitUtils.DISTANCE_UNITS,
+<script setup>
+import { computed } from 'vue';
- /**
- * The formatDuration method
- */
- formatDuration: formatUtils.formatDuration,
-
- /**
- * The formatNumber method
- */
- formatNumber: formatUtils.formatNumber,
-
- /**
- * The getDefaultDistanceUnit method
- */
- getDefaultDistanceUnit: unitUtils.getDefaultDistanceUnit,
-
- /**
- * The current selected target set
- */
- selectedTargetSet: storage.get('split-calculator-target-set', '_split_targets'),
-
- /**
- * The default output targets
- *
- * Loaded in activate() method
- */
- targetSets: {},
- };
- },
+import { defaultTargetSets } from '@/utils/targets';
+import { detectDefaultUnitSystem } from '@/utils/units';
- watch: {
- /**
- * Save default unit system
- */
- defaultUnitSystem(newValue) {
- storage.set('default-unit-system', newValue);
- },
-
- /**
- * Save the current selected target set
- */
- selectedTargetSet(newValue) {
- storage.set('split-calculator-target-set', newValue);
- },
-
- /**
- * Save target sets
- */
- targetSets: {
- deep: true,
- handler(newValue) {
- storage.set('target-sets', newValue);
- },
- },
- },
-
- computed: {
- /**
- * The target table results
- */
- results() {
- // Initialize results array
- const results = [];
-
- // Check for missing target set
- if (!this.targetSets[this.selectedTargetSet]) return [];
-
- let targets = targetUtils.sort(this.targetSets[this.selectedTargetSet].targets.filter(x =>
- x.result === 'time'));
-
- for (let i = 0; i < targets.length; i += 1) {
- // Calculate split and total times
- const splitTime = targets[i].split || 0;
- const totalTime = i === 0 ? splitTime : results[i - 1].totalTime + splitTime;
-
- // Calculate split and total distances
- const totalDistance = unitUtils.convertDistance(
- targets[i].distanceValue,
- targets[i].distanceUnit, 'meters',
- );
- const splitDistance = i === 0 ? totalDistance : totalDistance - results[i - 1].distance;
-
- // Calculate pace
- const pace = splitTime / unitUtils.convertDistance(splitDistance, 'meters',
- unitUtils.getDefaultDistanceUnit(this.defaultUnitSystem));
-
- // Add row to results array
- results.push({
- distance: totalDistance,
- distanceValue: targets[i].distanceValue,
- distanceUnit: targets[i].distanceUnit,
- totalTime,
- splitTime,
- pace,
- });
- }
-
- // Return results array
- return results;
- },
- },
+import SplitOutputTable from '@/components/SplitOutputTable.vue';
+import TargetSetSelector from '@/components/TargetSetSelector.vue';
- methods: {
- /**
- * Reload the target sets
- */
- reloadTargets() {
- this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets);
- },
+import useStorage from '@/composables/useStorage';
+
+/**
+ * The default unit system
+ */
+const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSystem());
+
+/**
+ * The current selected target set
+ */
+const selectedTargetSet = useStorage('split-calculator-target-set', '_split_targets');
+
+/**
+ * The default output targets
+ */
+const targetSets = useStorage('split-calculator-target-sets', {
+ _split_targets: defaultTargetSets._split_targets
+});
+
+/**
+ * The active target set
+ */
+const targetSet = computed({
+ get: () => {
+ if (targetSets.value[selectedTargetSet.value]) {
+ return targetSets.value[selectedTargetSet.value].targets
+ } else {
+ return []
+ }
},
-
- /**
- * (Re)load settings used in multiple calculators
- */
- activated() {
- this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets);
- this.defaultUnitSystem = storage.get('default-unit-system', unitUtils.detectDefaultUnitSystem());
+ set: (newValue) => {
+ if (targetSets.value[selectedTargetSet.value]) {
+ targetSets.value[selectedTargetSet.value].targets = newValue;
+ }
},
-};
+});
</script>
<style scoped>
@import '@/assets/target-calculator.css';
-.target-set, .default-units {
- margin-bottom: 5px;
-}
-
/* Widen default calculator output */
@media only screen and (min-width: 501px) {
.output {
min-width: 400px;
}
}
-
-/* Show/hide mobile abbreviations */
-.results th:first-child span.mobile-abbreviation {
- display: none;
-}
-@media only screen and (max-width: 500px) {
- .results th:first-child span:not(.mobile-abbreviation) {
- display: none;
- }
- .results th:first-child span.mobile-abbreviation {
- display: inherit;
- }
-}
</style>
diff --git a/src/views/UnitCalculator.vue b/src/views/UnitCalculator.vue
@@ -6,12 +6,12 @@
<option value="speed_and_pace">Speed & Pace</option>
</select>
- <time-input v-if="getUnitType(inputUnit) === 'time'" class="input-value"
- label="Input time" v-model="inputValue"/>
+ <time-input v-if="getUnitType(input.inputUnit) === 'time'" class="input-value"
+ label="Input time" v-model="input.inputValue"/>
<decimal-input v-else class="input-value" aria-label="Input value"
- v-model="inputValue" :min="0" :digits="2"/>
+ v-model="input.inputValue" :min="0" :digits="2"/>
- <select v-model="inputUnit" class="input-units" aria-label="Input units">
+ <select v-model="input.inputUnit" class="input-units" aria-label="Input units">
<option v-for="(value, key) in units" :key="key" :value="key">
{{ value.name }}
</option>
@@ -19,14 +19,14 @@
<span class="equals"> = </span>
- <span v-if="getUnitType(outputUnit) === 'time'" class="output-value" aria-label="Output value">
+ <span v-if="getUnitType(input.outputUnit) === 'time'" class="output-value" aria-label="Output value">
{{ formatDuration(outputValue, 6, 3, true) }}
</span>
<span v-else class="output-value" aria-label="Output value">
{{ formatNumber(outputValue, 0, 3, true) }}
</span>
- <select v-model="outputUnit" class="output-units" aria-label="Output units">
+ <select v-model="input.outputUnit" class="output-units" aria-label="Output units">
<option v-for="(value, key) in units" :key="key" :value="key">
{{ value.name }}
</option>
@@ -34,235 +34,130 @@
</div>
</template>
-<script>
-import formatUtils from '@/utils/format';
-import storage from '@/utils/localStorage';
-import unitUtils from '@/utils/units';
+<script setup>
+import { computed, ref } from 'vue';
+
+import { formatDuration, formatNumber } from '@/utils/format';
+import { DISTANCE_UNITS, TIME_UNITS, SPEED_UNITS, PACE_UNITS, convertDistance, convertTime,
+convertSpeedPace } from '@/utils/units';
import DecimalInput from '@/components/DecimalInput.vue';
import TimeInput from '@/components/TimeInput.vue';
-export default {
- name: 'UnitCalculator',
+import useStorage from '@/composables/useStorage';
- components: {
- DecimalInput,
- TimeInput,
+/**
+ * The calculator inputs
+ */
+const inputs = useStorage('unit-calculator-inputs', {
+ distance: {
+ inputValue: 1,
+ inputUnit: 'miles',
+ outputUnit: 'kilometers',
},
-
- data() {
- return {
- /**
- * The input value
- */
- inputValue: storage.get('unit-calculator-distance-input-value', 1.0),
-
- /**
- * The unit of the input
- */
- inputUnit: storage.get('unit-calculator-distance-input-unit', 'miles'),
-
- /**
- * The unit of the output
- */
- outputUnit: storage.get('unit-calculator-distance-output-unit', 'kilometers'),
-
- /**
- * The unit category
- */
- category: 'distance',
-
- /**
- * The formatDuration method
- */
- formatDuration: formatUtils.formatDuration,
-
- /**
- * The formatNumber method
- */
- formatNumber: formatUtils.formatNumber,
- };
+ time: {
+ inputValue: 1,
+ inputUnit: 'seconds',
+ outputUnit: 'hh:mm:ss',
},
-
- computed: {
- /**
- * The names of the units in the current category
- */
- units() {
- switch (this.category) {
- case 'distance': {
- return unitUtils.DISTANCE_UNITS;
- }
- case 'time': {
- return {
- ...unitUtils.TIME_UNITS,
- 'hh:mm:ss': {
- name: 'hh:mm:ss',
- symbol: '',
- value: null,
- },
- };
- }
- case 'speed_and_pace': {
- return { ...unitUtils.PACE_UNITS, ...unitUtils.SPEED_UNITS };
- }
- default: {
- return {};
- }
- }
- },
-
- /**
- * The output value
- */
- outputValue() {
- switch (this.category) {
- case 'distance': {
- return unitUtils.convertDistance(this.inputValue, this.inputUnit, this.outputUnit);
- }
- case 'time': {
- // Correct input and output units for 'hh:mm:ss' unit
- const realInput = this.inputUnit === 'hh:mm:ss' ? 'seconds' : this.inputUnit;
- const realOutput = this.outputUnit === 'hh:mm:ss' ? 'seconds' : this.outputUnit;
-
- // Calculate conversion
- return unitUtils.convertTime(this.inputValue, realInput, realOutput);
- }
- case 'speed_and_pace': {
- return unitUtils.convertSpeedPace(this.inputValue, this.inputUnit, this.outputUnit);
- }
- default: {
- return null;
- }
- }
- },
+ speed_and_pace: {
+ inputValue: 600,
+ inputUnit: 'seconds_per_mile',
+ outputUnit: 'miles_per_hour',
},
-
- watch: {
- /**
- * Reset inputValue, inputUnit, and outputUnit
- */
- category(newValue) {
- switch (newValue) {
- case 'distance': {
- this.inputValue = storage.get('unit-calculator-distance-input-value', 1);
- this.inputUnit = storage.get('unit-calculator-distance-input-unit', 'miles');
- this.outputUnit = storage.get('unit-calculator-distance-output-unit', 'kilometers');
- break;
- }
- case 'time': {
- this.inputValue = storage.get('unit-calculator-time-input-value', 1);
- this.inputUnit = storage.get('unit-calculator-time-input-unit', 'seconds');
- this.outputUnit = storage.get('unit-calculator-time-output-unit', 'hh:mm:ss');
- break;
- }
- case 'speed_and_pace': {
- this.inputValue = storage.get('unit-calculator-speed-input-value', 600);
- this.inputUnit = storage.get('unit-calculator-speed-input-unit',
- 'seconds_per_mile');
- this.outputUnit = storage.get('unit-calculator-speed-output-unit',
- 'miles_per_hour');
- break;
- }
- default: {
- break;
- }
- }
- },
-
- /**
- * Save input value
- */
- inputValue(newValue) {
- switch (this.category) {
- case 'distance': {
- storage.set('unit-calculator-distance-input-value', newValue);
- break;
- }
- case 'time': {
- storage.set('unit-calculator-time-input-value', newValue);
- break;
- }
- case 'speed_and_pace': {
- storage.set('unit-calculator-speed-input-value', newValue);
- break;
- }
- default: {
- break;
- }
- }
- },
-
- /**
- * Save input unit
- */
- inputUnit(newValue) {
- switch (this.category) {
- case 'distance': {
- storage.set('unit-calculator-distance-input-unit', newValue);
- break;
- }
- case 'time': {
- storage.set('unit-calculator-time-input-unit', newValue);
- break;
- }
- case 'speed_and_pace': {
- storage.set('unit-calculator-speed-input-unit', newValue);
- break;
- }
- default: {
- break;
- }
- }
- },
-
- /**
- * Save output unit
- */
- outputUnit(newValue) {
- switch (this.category) {
- case 'distance': {
- storage.set('unit-calculator-distance-output-unit', newValue);
- break;
- }
- case 'time': {
- storage.set('unit-calculator-time-output-unit', newValue);
- break;
- }
- case 'speed_and_pace': {
- storage.set('unit-calculator-speed-output-unit', newValue);
- break;
- }
- default: {
- break;
- }
- }
- },
+});
+
+/**
+ * The unit category
+ */
+const category = ref('distance');
+
+/**
+ * The inputs for the current category
+ */
+const input = computed({
+ get() {
+ return inputs.value[category.value];
},
-
- methods: {
- /**
- * Get the type of a unit
- * @param {String} unit The unit
- * @returns {String} The type ('decimal' or 'time')
- */
- getUnitType(unit) {
- if (unit in unitUtils.DISTANCE_UNITS) {
- return 'decimal';
- }
- if (unit in unitUtils.TIME_UNITS) {
- return 'decimal';
- }
- if (unit === 'hh:mm:ss') {
- return 'time';
- }
- if (['seconds_per_kilometer', 'seconds_per_mile'].includes(unit)) {
- return 'time';
- }
- return 'decimal';
- },
+ set(newValue) {
+ inputs.value[category.value] = newValue;
},
-};
+});
+
+/**
+ * The names of the units in the current category
+ */
+const units = computed(() => {
+ switch (category.value) {
+ case 'distance': {
+ return DISTANCE_UNITS;
+ }
+ case 'time': {
+ return {
+ ...TIME_UNITS,
+ 'hh:mm:ss': {
+ name: 'hh:mm:ss',
+ symbol: '',
+ value: null,
+ },
+ };
+ }
+ case 'speed_and_pace': {
+ return { ...PACE_UNITS, ...SPEED_UNITS };
+ }
+ default: {
+ return {};
+ }
+ }
+});
+
+/**
+ * The output value
+ */
+const outputValue = computed(() => {
+ switch (category.value) {
+ case 'distance': {
+ return convertDistance(input.value.inputValue, input.value.inputUnit,
+ input.value.outputUnit);
+ }
+ case 'time': {
+ // Correct input and output units for 'hh:mm:ss' unit
+ const realInput = input.value.inputUnit === 'hh:mm:ss' ? 'seconds' : input.value.inputUnit;
+ const realOutput = input.value.outputUnit === 'hh:mm:ss' ? 'seconds' : input.value.outputUnit;
+
+ // Calculate conversion
+ return convertTime(input.value.inputValue, realInput, realOutput);
+ }
+ case 'speed_and_pace': {
+ return convertSpeedPace(input.value.inputValue, input.value.inputUnit,
+ input.value.outputUnit);
+ }
+ default: {
+ return null;
+ }
+ }
+});
+
+/**
+ * Get the type of a unit
+ * @param {String} unit The unit
+ * @returns {String} The type ('decimal' or 'time')
+ */
+function getUnitType(unit) {
+ if (unit in DISTANCE_UNITS) {
+ return 'decimal';
+ }
+ if (unit in TIME_UNITS) {
+ return 'decimal';
+ }
+ if (unit === 'hh:mm:ss') {
+ return 'time';
+ }
+ if (['seconds_per_kilometer', 'seconds_per_mile'].includes(unit)) {
+ return 'time';
+ }
+ return 'decimal';
+}
</script>
<style scoped>
diff --git a/src/views/WorkoutCalculator.vue b/src/views/WorkoutCalculator.vue
@@ -0,0 +1,83 @@
+<template>
+ <div class="calculator">
+ <h2>Input Race Result</h2>
+ <div class="input">
+ <pace-input v-model="input" label="Input race"/>
+ </div>
+
+ <details>
+ <summary>
+ <h2>Advanced Options</h2>
+ </summary>
+ <div>
+ Default units:
+ <select v-model="defaultUnitSystem" aria-label="Default units">
+ <option value="imperial">Miles</option>
+ <option value="metric">Kilometers</option>
+ </select>
+ </div>
+ <div>
+ Target Set:
+ <target-set-selector v-model:selectedTargetSet="selectedTargetSet" setType="workout"
+ v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/>
+ </div>
+ <race-options v-model="options"/>
+ </details>
+
+ <h2>Workout Splits</h2>
+ <single-output-table class="output"
+ :calculate-result="x => calculateWorkoutResults(input, x, options)"
+ :targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []"/>
+ </div>
+</template>
+
+<script setup>
+import { calculateWorkoutResults } from '@/utils/calculators';
+import { defaultTargetSets } from '@/utils/targets';
+import { detectDefaultUnitSystem } from '@/utils/units';
+
+import PaceInput from '@/components/PaceInput.vue';
+import RaceOptions from '@/components/RaceOptions.vue';
+import SingleOutputTable from '@/components/SingleOutputTable.vue';
+import TargetSetSelector from '@/components/TargetSetSelector.vue';
+
+import useStorage from '@/composables/useStorage';
+
+/**
+ * The input race
+ */
+const input = useStorage('workout-calculator-input', {
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+});
+
+/**
+ * The default unit system
+ */
+const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSystem());
+
+/**
+ * The race prediction options
+ */
+const options = useStorage('workout-calculator-options', {
+ model: 'AverageModel',
+ riegelExponent: 1.06,
+});
+
+/**
+ * The current selected target set
+ */
+const selectedTargetSet = useStorage('workout-calculator-target-set', '_workout_targets');
+
+/**
+ * The target sets
+ */
+let targetSets = useStorage('workout-calculator-target-sets', {
+ _workout_targets: defaultTargetSets._workout_targets
+});
+</script>
+
+<style scoped>
+@import '@/assets/target-calculator.css';
+</style>
diff --git a/tests/e2e/batch-calculator.spec.js b/tests/e2e/batch-calculator.spec.js
@@ -0,0 +1,173 @@
+import { test, expect } from '@playwright/test';
+
+test('Batch calculator', async ({ page }) => {
+ // Structure:
+ // - Test workout batch results, including modified prediction model
+ // - Test pace batch results, including modified default units
+ // - Test race batch results, including modified Riegel exponent
+ // - Reload page
+ // - Assert race batch results are still the same
+ // - Assert pace batch results are still the same
+ // - Assert workout batch results are still the same
+
+ await page.goto('/');
+
+ // Go to batch calculator
+ await page.getByRole('button', { name: 'Batch Calculator' }).click();
+ await expect(page).toHaveTitle('Batch Calculator - Running Tools');
+
+ // Enter input pace (2 mi in 10:30)
+ await page.getByLabel('Input distance value').fill('2');
+ await page.getByLabel('Input distance unit').selectOption('Miles');
+ await page.getByLabel('Input duration hours').fill('0');
+ await page.getByLabel('Input duration minutes').fill('10');
+ await page.getByLabel('Input duration seconds').fill('30');
+
+ // Enter batch options (15 x 10s increments)
+ await page.getByLabel('Duration increment minutes').fill('0');
+ await page.getByLabel('Duration increment seconds').fill('10');
+ await page.getByLabel('Number of rows').fill('15');
+
+ // Assert workout results are correct
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m @ 5 km');
+ await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(5);
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:41.21');
+ await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(5);
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50');
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('3:16.91');
+ await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(5);
+ await expect(page.getByRole('row')).toHaveCount(16);
+
+ // Change prediction model
+ await page.getByText('Advanced Options').click();
+ await page.getByLabel('Prediction model').selectOption('Riegel\'s Model');
+
+ // Assert workout results are correct
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m @ 5 km');
+ await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(5);
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:40.78');
+ await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(5);
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50');
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('3:16.51');
+ await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(5);
+ await expect(page.getByRole('row')).toHaveCount(16);
+
+ // Change calculator
+ await expect(page.getByLabel('Calculator')).toHaveValue('workout');
+ await page.getByLabel('Calculator').selectOption('Pace Calculator');
+
+ // Assert pace results are correct
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(6)).toHaveText('800 m');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(28)).toHaveText('10:00');
+ await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(31);
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(6)).toHaveText('2:36.58');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(28)).toHaveText('1.90 mi');
+ await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(31);
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50');
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(6)).toHaveText('3:11.38');
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(28)).toHaveText('1.56 mi');
+ await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(31);
+ await expect(page.getByRole('row')).toHaveCount(16);
+
+ // Assert prediction options are hidden
+ await expect(page.getByLabel('Prediction model')).toHaveCount(0);
+
+ // Change default units
+ await page.getByLabel('Default units').selectOption('Kilometers');
+
+ // Assert pace results are correct
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(6)).toHaveText('800 m');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(28)).toHaveText('10:00');
+ await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(31);
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(6)).toHaveText('2:36.58');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(28)).toHaveText('3.07 km');
+ await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(31);
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50');
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(6)).toHaveText('3:11.38');
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(28)).toHaveText('2.51 km');
+ await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(31);
+ await expect(page.getByRole('row')).toHaveCount(16);
+
+ // Change calculator
+ await page.getByLabel('Calculator').selectOption('Race Calculator');
+
+ // Assert race results are correct
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m');
+ await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(17);
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:14.60');
+ await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(17);
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50');
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('2:43.61');
+ await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(17);
+ await expect(page.getByRole('row')).toHaveCount(16);
+
+ // Change Riegel exponent
+ await expect(page.getByLabel('Prediction model')).toHaveValue('AverageModel');
+ await page.getByLabel('Riegel Exponent').fill('1.12');
+
+ // Assert race results are correct
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m');
+ await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(17);
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:11.72');
+ await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(17);
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50');
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('2:40.09');
+ await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(17);
+ await expect(page.getByRole('row')).toHaveCount(16);
+
+ // Reload page
+ await page.reload();
+
+ // Assert race results are correct (inputs and options not reset)
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m');
+ await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(17);
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:11.72');
+ await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(17);
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50');
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('2:40.09');
+ await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(17);
+ await expect(page.getByRole('row')).toHaveCount(16);
+
+ // Assert pace results are correct (inputs and options not reset)
+ await page.getByLabel('Calculator').selectOption('Pace Calculator');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(6)).toHaveText('800 m');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(28)).toHaveText('10:00');
+ await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(31);
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(6)).toHaveText('2:36.58');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(28)).toHaveText('3.07 km');
+ await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(31);
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50');
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(6)).toHaveText('3:11.38');
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(28)).toHaveText('2.51 km');
+ await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(31);
+ await expect(page.getByRole('row')).toHaveCount(16);
+
+ // Assert workout results are correct (inputs and options not reset)
+ await page.getByLabel('Calculator').selectOption('Workout Calculator');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m @ 5 km');
+ await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(5);
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:40.78');
+ await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(5);
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50');
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('3:16.51');
+ await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(5);
+ await expect(page.getByRole('row')).toHaveCount(16);
+});
diff --git a/tests/e2e/cross-calculator.spec.js b/tests/e2e/cross-calculator.spec.js
@@ -0,0 +1,206 @@
+import { test, expect } from '@playwright/test';
+
+test('Cross-calculator', async ({ page }) => {
+ // Go to batch calculator
+ await page.goto('/');
+ await page.getByRole('button', { name: 'Batch Calculator' }).click();
+
+ // Enter input pace (2 mi in 10:30)
+ await page.getByLabel('Input distance value').fill('2');
+ await page.getByLabel('Input distance unit').selectOption('Miles');
+ await page.getByLabel('Input duration hours').fill('0');
+ await page.getByLabel('Input duration minutes').fill('10');
+ await page.getByLabel('Input duration seconds').fill('30');
+
+ // Enter batch options (15 x 10s increments)
+ await page.getByLabel('Duration increment minutes').fill('0');
+ await page.getByLabel('Duration increment seconds').fill('10');
+ await page.getByLabel('Number of rows').fill('15');
+
+ // Change calculator
+ await page.getByLabel('Calculator').selectOption('Race Calculator');
+
+ // Change prediction model
+ await page.getByText('Advanced Options').click();
+ await page.getByLabel('Prediction model').selectOption('Riegel\'s Model');
+
+ // Go to pace calculator
+ await page.goto('/');
+ await page.getByRole('button', { name: 'Pace Calculator' }).click();
+
+ // Enter input pace (2 mi in 15:30)
+ await page.getByLabel('Input distance value').fill('2');
+ await page.getByLabel('Input distance unit').selectOption('Miles');
+ await page.getByLabel('Input duration hours').fill('0');
+ await page.getByLabel('Input duration minutes').fill('15');
+ await page.getByLabel('Input duration seconds').fill('30');
+
+ // Create custom target set
+ await page.getByText('Advanced Options').click();
+ await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]');
+ await expect(page.getByRole('row').nth(4)).toHaveText('There aren\'t any targets in this set yet.');
+ await expect(page.getByRole('row')).toHaveCount(5);
+
+ // Edit new target set
+ await expect(page.getByRole('row').nth(1)).toHaveText('There aren\'t any targets in this set yet.');
+ await expect(page.getByLabel('Target set label')).toHaveValue('New target set');
+ await page.getByLabel('Target set label').fill('800m Splits');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Target distance value').nth(0).fill('0.4');
+ await page.getByLabel('Target distance unit').nth(0).selectOption('Kilometers');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Target distance value').nth(1).fill('800');
+ await page.getByLabel('Target distance unit').nth(1).selectOption('Meters');
+ await page.getByRole('button', { name: 'Add time target' }).click();
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // Go to race calculator
+ await page.getByRole('link', { name: 'Back' }).click();
+ await page.getByRole('button', { name: 'Race Calculator' }).click();
+
+ // Enter input race (2 mi in 10:30)
+ await page.getByLabel('Input race distance value').fill('2');
+ await page.getByLabel('Input race distance unit').selectOption('Miles');
+ await page.getByLabel('Input race duration hours').fill('0');
+ await page.getByLabel('Input race duration minutes').fill('10');
+ await page.getByLabel('Input race duration seconds').fill('30');
+
+ // Go to split calculator
+ await page.getByRole('link', { name: 'Back' }).click();
+ await page.getByRole('button', { name: 'Split Calculator' }).click();
+
+ // Edit target set
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await page.getByLabel('Target set label').fill('5K 1600m Splits');
+ await page.getByLabel('Target distance value').nth(0).fill('1.6');
+ await page.getByLabel('Target distance unit').nth(0).selectOption('Kilometers');
+ await page.getByLabel('Target distance value').nth(1).fill('3.2');
+ await page.getByLabel('Target distance unit').nth(1).selectOption('Kilometers');
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // Enter input 5K splits (7:00, 6:30, 6:30)
+ await page.getByLabel('Split duration minutes').nth(0).fill('7');
+ await page.getByLabel('Split duration seconds').nth(0).fill('0');
+ await page.getByLabel('Split duration minutes').nth(1).fill('6');
+ await page.getByLabel('Split duration seconds').nth(1).fill('30');
+ await page.getByLabel('Split duration minutes').nth(2).fill('6');
+ await page.getByLabel('Split duration seconds').nth(2).fill('30');
+
+ // Go to unit calculator
+ await page.getByRole('link', { name: 'Back' }).click();
+ await page.getByRole('button', { name: 'Unit Calculator' }).click();
+
+ // Convert speed and pace units (10 kph to time per mile)
+ await page.getByLabel('Selected unit category').selectOption('Speed & Pace');
+ await page.getByLabel('Input units').selectOption('Kilometers per Hour');
+ await page.getByLabel('Input value').fill('10');
+ await page.getByLabel('Output units').selectOption('Time per Mile');
+
+ // Go to workout calculator
+ await page.getByRole('link', { name: 'Back' }).click();
+ await page.getByRole('button', { name: 'Workout Calculator' }).click();
+
+ // Enter input race (1 mi in 5:01)
+ await page.getByLabel('Input race distance value').fill('1');
+ await page.getByLabel('Input race distance unit').selectOption('Miles');
+ await page.getByLabel('Input race duration hours').fill('0');
+ await page.getByLabel('Input race duration minutes').fill('5');
+ await page.getByLabel('Input race duration seconds').fill('1');
+
+ // Change prediction model
+ await page.getByText('Advanced Options').click();
+ await page.getByLabel('Prediction model').selectOption('V̇O₂ Max Model');
+
+ // Change default units (should update on other calculators too)
+ await page.getByLabel('Default units').selectOption('Kilometers');
+
+ // Return to batch calculator
+ await page.getByRole('link', { name: 'Back' }).click();
+ await page.getByRole('button', { name: 'Batch Calculator' }).click();
+
+ // Assert pace results are correct (inputs and options not reset)
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m');
+ await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(17);
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:24.04');
+ await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(17);
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50');
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('2:56.05');
+ await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(17);
+ await expect(page.getByRole('row')).toHaveCount(16);
+
+ // Assert pace results are correct (inputs and options not reset, new pace targets loaded)
+ await page.getByLabel('Calculator').selectOption('Pace Calculator');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m');
+ await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(4);
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:36.58');
+ await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(4);
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50');
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('3:11.38');
+ await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(4);
+ await expect(page.getByRole('row')).toHaveCount(16);
+
+ // Assert workout results are correct (new workout options loaded)
+ await page.getByLabel('Calculator').selectOption('Workout Calculator');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi');
+ await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m @ 5 km');
+ await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(5);
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:41.93');
+ await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(5);
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50');
+ await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('3:16.98');
+ await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(5);
+ await expect(page.getByRole('row')).toHaveCount(16);
+
+ // Return to pace calculator
+ await page.getByRole('link', { name: 'Back' }).click();
+ await page.getByRole('button', { name: 'Pace Calculator' }).click();
+
+ // Assert paces are correct (input pace not reset)
+ await expect(page.getByRole('row').nth(1)).toHaveText('0.4 km' + '1:55.57');
+ await expect(page.getByRole('row').nth(2)).toHaveText('800 m' + '3:51.15');
+ await expect(page.getByRole('row').nth(3)).toHaveText('2.08 km' + '10:00');
+ await expect(page.getByRole('row')).toHaveCount(4);
+
+ // Return to race calculator
+ await page.getByRole('link', { name: 'Back' }).click();
+ await page.getByRole('button', { name: 'Race Calculator' }).click();
+
+ // Assert race predictions are correct (input race not resset and new prediction model loaded)
+ await expect(page.getByRole('row').nth(5)).toHaveText('1 mi' + '5:02.17' + '3:08 / km');
+ await expect(page.getByRole('row').nth(10)).toHaveText('5 km' + '16:44.86' + '3:21 / km');
+ await expect(page.getByRole('row')).toHaveCount(17);
+
+ // Return to split calculator
+ await page.getByRole('link', { name: 'Back' }).click();
+ await page.getByRole('button', { name: 'Split Calculator' }).click();
+
+ // Assert times and paces are correct (split times not reset)
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('7:00.00');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('4:23 / km');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('13:30.00');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('4:04 / km');
+ await expect(page.getByRole('row').nth(3).getByRole('cell').nth(1)).toHaveText('20:00.00');
+ await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('3:37 / km');
+ await expect(page.getByRole('row')).toHaveCount(4);
+
+ // Return to unit calculator
+ await page.getByRole('link', { name: 'Back' }).click();
+ await page.getByRole('button', { name: 'Unit Calculator' }).click();
+
+ // Assert result is correct (state not reset)
+ await expect(page.getByLabel('Output value')).toHaveText('00:09:39.366');
+
+ // Return to workout calculator
+ await page.getByRole('link', { name: 'Back' }).click();
+ await page.getByRole('button', { name: 'Workout Calculator' }).click();
+
+ // Assert workout splits are correct (input race and prediction model not reset)
+ await expect(page.getByRole('row').nth(1)).toHaveText('400 m @ 1 mi' + '1:14.81');
+ await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '5:53.58');
+ await expect(page.getByRole('row')).toHaveCount(5);
+});
diff --git a/tests/e2e/pace-calculator.spec.js b/tests/e2e/pace-calculator.spec.js
@@ -0,0 +1,128 @@
+import { test, expect } from '@playwright/test';
+
+test('Pace Calculator', async ({ page }) => {
+ // Structure:
+ // - Test standard pace results
+ // - Test different default units
+ // - Test modified default target set
+ // - Test custom target set
+ // - Reload page
+ // - Assert outputs are still the same
+ // - Test target set deletion and reversion
+
+ // Go to pace calculator
+ await page.goto('/');
+ await page.getByRole('button', { name: 'Pace Calculator' }).click();
+ await expect(page).toHaveTitle('Pace Calculator - Running Tools');
+
+ // Enter input pace (2 mi in 15:30)
+ await page.getByLabel('Input distance value').fill('2');
+ await page.getByLabel('Input distance unit').selectOption('Miles');
+ await page.getByLabel('Input duration hours').fill('0');
+ await page.getByLabel('Input duration minutes').fill('15');
+ await page.getByLabel('Input duration seconds').fill('30');
+
+ // Assert paces are correct
+ await expect(page.getByRole('row').nth(11)).toHaveText('1 mi' + '7:45.00');
+ await expect(page.getByRole('row').nth(13)).toHaveText('1.29 mi' + '10:00');
+ await expect(page.getByRole('row')).toHaveCount(31);
+
+ // Change default units
+ await page.getByText('Advanced Options').click();
+ await page.getByLabel('Default units').selectOption('Kilometers');
+
+ // Assert paces are correct
+ await expect(page.getByRole('row').nth(11)).toHaveText('1 mi' + '7:45.00');
+ await expect(page.getByRole('row').nth(13)).toHaveText('2.08 km' + '10:00');
+ await expect(page.getByRole('row')).toHaveCount(31);
+
+ // Edit default target set
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await page.getByLabel('Target set label').fill('Less-common Pace Targets');
+ await page.getByLabel('Target distance value').nth(10).fill('1.01');
+ await page.getByLabel('Target distance unit').nth(10).selectOption('Miles');
+ await page.getByLabel('Target duration second').nth(0).fill('1');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Target distance value').last().fill('1.5');
+ await page.getByLabel('Target distance unit').last().selectOption('Miles');
+ await page.getByRole('button', { name: 'Add time target' }).click();
+ await page.getByLabel('Target duration minutes').last().fill('19');
+ await page.getByLabel('Target duration seconds').last().fill('0');
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // Assert paces are correct
+ await expect(page.getByRole('row').nth(11)).toHaveText('1.01 mi' + '7:49.65');
+ await expect(page.getByRole('row').nth(13)).toHaveText('2.08 km' + '10:01');
+ await expect(page.getByRole('row').nth(14)).toHaveText('1.5 mi' + '11:37.50');
+ await expect(page.getByRole('row').nth(18)).toHaveText('3.95 km' + '19:00');
+ await expect(page.getByRole('row')).toHaveCount(33);
+
+ // Create custom target set
+ await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]');
+ await expect(page.getByRole('row').nth(4)).toHaveText('There aren\'t any targets in this set yet.');
+ await expect(page.getByRole('row')).toHaveCount(5);
+
+ // Edit new target set
+ await expect(page.getByRole('row').nth(1)).toHaveText('There aren\'t any targets in this set yet.');
+ await expect(page.getByLabel('Target set label')).toHaveValue('New target set');
+ await page.getByLabel('Target set label').fill('800m Splits');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Target distance value').nth(0).fill('0.4');
+ await page.getByLabel('Target distance unit').nth(0).selectOption('Kilometers');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Target distance value').nth(1).fill('800');
+ await page.getByLabel('Target distance unit').nth(1).selectOption('Meters');
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // Assert paces are correct
+ await expect(page.getByRole('row').nth(1)).toHaveText('0.4 km' + '1:55.57');
+ await expect(page.getByRole('row').nth(2)).toHaveText('800 m' + '3:51.15');
+ await expect(page.getByRole('row')).toHaveCount(3);
+
+ // Reload page
+ await page.reload();
+
+ // Assert paces are correct (custom targets and default units not reset)
+ await expect(page.getByRole('row').nth(1)).toHaveText('0.4 km' + '1:55.57');
+ await expect(page.getByRole('row').nth(2)).toHaveText('800 m' + '3:51.15');
+ await expect(page.getByRole('row')).toHaveCount(3);
+
+ // Switch target set
+ await page.getByText('Advanced Options').click();
+ await page.getByLabel('Selected target set').selectOption('Less-common Pace Targets');
+
+ // Assert paces are correct
+ await expect(page.getByRole('row').nth(11)).toHaveText('1.01 mi' + '7:49.65');
+ await expect(page.getByRole('row').nth(13)).toHaveText('2.08 km' + '10:01');
+ await expect(page.getByRole('row').nth(14)).toHaveText('1.5 mi' + '11:37.50');
+ await expect(page.getByRole('row').nth(18)).toHaveText('3.95 km' + '19:00');
+ await expect(page.getByRole('row')).toHaveCount(33);
+
+ // Delete custom target set
+ await page.getByLabel('Selected target set').selectOption('800m Splits');
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await expect(page.getByLabel('Target set label')).toHaveValue('800m Splits');
+ await page.getByRole('button', { name: 'Delete target set' }).click();
+
+ // Assert paces are correct (back to default target set)
+ await expect(page.getByRole('row').nth(11)).toHaveText('1.01 mi' + '7:49.65');
+ await expect(page.getByRole('row').nth(13)).toHaveText('2.08 km' + '10:01');
+ await expect(page.getByRole('row').nth(14)).toHaveText('1.5 mi' + '11:37.50');
+ await expect(page.getByRole('row').nth(18)).toHaveText('3.95 km' + '19:00');
+ await expect(page.getByRole('row')).toHaveCount(33);
+
+ // Revert target set
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await expect(page.getByLabel('Target set label')).toHaveValue('Less-common Pace Targets');
+ await page.getByRole('button', { name: 'Revert target set' }).click();
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // Assert paces are correct
+ await expect(page.getByRole('row').nth(11)).toHaveText('1 mi' + '7:45.00');
+ await expect(page.getByRole('row').nth(13)).toHaveText('2.08 km' + '10:00');
+ await expect(page.getByRole('row')).toHaveCount(31);
+
+ // Assert title was reset
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await expect(page.getByLabel('Target set label')).toHaveValue('Common Pace Targets');
+});
diff --git a/tests/e2e/race-calculator.spec.js b/tests/e2e/race-calculator.spec.js
@@ -0,0 +1,146 @@
+import { test, expect } from '@playwright/test';
+
+test('Race Calculator', async ({ page }) => {
+ // Structure:
+ // - Test standard race results
+ // - Test different default units
+ // - Test different prediction options
+ // - Test modified default target set
+ // - Test custom target set
+ // - Reload page
+ // - Assert outputs are still the same
+ // - Test target set deletion and reversion
+
+ // Go to race calculator
+ await page.goto('/');
+ await page.getByRole('button', { name: 'Race Calculator' }).click();
+ await expect(page).toHaveTitle('Race Calculator - Running Tools');
+
+ // Enter input race (2 mi in 10:30)
+ await page.getByLabel('Input race distance value').fill('2');
+ await page.getByLabel('Input race distance unit').selectOption('Miles');
+ await page.getByLabel('Input race duration hours').fill('0');
+ await page.getByLabel('Input race duration minutes').fill('10');
+ await page.getByLabel('Input race duration seconds').fill('30');
+
+ // Assert race predictions are correct
+ await expect(page.getByRole('row').nth(5)).toHaveText('1 mi' + '4:55.53' + '4:56 / mi');
+ await expect(page.getByRole('row').nth(10)).toHaveText('5 km' + '16:47.58' + '5:24 / mi');
+ await expect(page.getByRole('row')).toHaveCount(17);
+
+ // Assert race statistics are correct
+ await page.getByText('Race Statistics').click();
+ await expect(page.getByText('Purdy Points:')).toContainText(': 680.1');
+ await expect(page.getByText('V̇O₂:')).toContainText(': 61.0 ml/kg/min (100.5% of max)');
+ await expect(page.getByText('V̇O₂ Max:')).toContainText(': 60.7 ml/kg/min');
+
+ // Change default units
+ await page.getByText('Advanced Options').click();
+ await page.getByLabel('Default units').selectOption('Kilometers');
+
+ // Assert race predictions are correct
+ await expect(page.getByRole('row').nth(5)).toHaveText('1 mi' + '4:55.53' + '3:04 / km');
+ await expect(page.getByRole('row').nth(10)).toHaveText('5 km' + '16:47.58' + '3:22 / km');
+ await expect(page.getByRole('row')).toHaveCount(17);
+
+ // Change prediction model
+ await page.getByLabel('Prediction model').selectOption('Riegel\'s Model');
+
+ // Assert race predictions are correct
+ await expect(page.getByRole('row').nth(5)).toHaveText('1 mi' + '5:02.17' + '3:08 / km');
+ await expect(page.getByRole('row').nth(10)).toHaveText('5 km' + '16:44.86' + '3:21 / km');
+ await expect(page.getByRole('row')).toHaveCount(17);
+
+ // Change Riegel exponent
+ await page.getByLabel('Riegel Exponent').fill('1.12');
+
+ // Assert race predictions are correct
+ await expect(page.getByRole('row').nth(5)).toHaveText('1 mi' + '4:49.86' + '3:00 / km');
+ await expect(page.getByRole('row').nth(10)).toHaveText('5 km' + '17:11.77' + '3:26 / km');
+ await expect(page.getByRole('row')).toHaveCount(17);
+
+ // Edit default target set
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await page.getByLabel('Target set label').fill('Less-common Race Targets');
+ await page.getByLabel('Target distance value').nth(4).fill('1.01');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Target distance value').last().fill('1.5');
+ await page.getByLabel('Target distance unit').last().selectOption('Miles');
+ await page.getByRole('button', { name: 'Add time target' }).click();
+ await page.getByLabel('Target duration minutes').last().fill('19');
+ await page.getByLabel('Target duration seconds').last().fill('0');
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // Assert race predictions are correct
+ await expect(page.getByRole('row').nth(5)).toHaveText('1.01 mi' + '4:53.11' + '3:00 / km');
+ await expect(page.getByRole('row').nth(6)).toHaveText('1.5 mi' + '7:36.47' + '3:09 / km');
+ await expect(page.getByRole('row').nth(12)).toHaveText('5.47 km' + '19:00' + '3:29 / km');
+ await expect(page.getByRole('row')).toHaveCount(19);
+
+ // Create custom target set
+ await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]');
+ await expect(page.getByRole('row').nth(4)).toHaveText('There aren\'t any targets in this set yet.');
+ await expect(page.getByRole('row')).toHaveCount(5);
+
+ // Edit new target set
+ await expect(page.getByRole('row').nth(1)).toHaveText('There aren\'t any targets in this set yet.');
+ await expect(page.getByLabel('Target set label')).toHaveValue('New target set');
+ await page.getByLabel('Target set label').fill('XC Race Targets');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Target distance value').nth(0).fill('5');
+ await page.getByLabel('Target distance unit').nth(0).selectOption('Kilometers');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Target distance value').nth(1).fill('10');
+ await page.getByLabel('Target distance unit').nth(1).selectOption('Kilometers');
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // Assert race predictions are correct
+ await expect(page.getByRole('row').nth(1)).toHaveText('5 km' + '17:11.77' + '3:26 / km');
+ await expect(page.getByRole('row').nth(2)).toHaveText('10 km' + '37:22.53' + '3:44 / km');
+ await expect(page.getByRole('row')).toHaveCount(3);
+
+ // Reload page
+ await page.reload();
+
+ // Assert race predictions are correct (custom targets, default units, and model settings not reset)
+ await expect(page.getByRole('row').nth(1)).toHaveText('5 km' + '17:11.77' + '3:26 / km');
+ await expect(page.getByRole('row').nth(2)).toHaveText('10 km' + '37:22.53' + '3:44 / km');
+ await expect(page.getByRole('row')).toHaveCount(3);
+
+ // Switch target set
+ await page.getByText('Advanced Options').click();
+ await page.getByLabel('Selected target set').selectOption('Less-common Race Targets');
+
+ // Assert race predictions are correct
+ await expect(page.getByRole('row').nth(5)).toHaveText('1.01 mi' + '4:53.11' + '3:00 / km');
+ await expect(page.getByRole('row').nth(6)).toHaveText('1.5 mi' + '7:36.47' + '3:09 / km');
+ await expect(page.getByRole('row').nth(12)).toHaveText('5.47 km' + '19:00' + '3:29 / km');
+ await expect(page.getByRole('row')).toHaveCount(19);
+
+ // Delete custom target set
+ await page.getByLabel('Selected target set').selectOption('XC Race Targets');
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await expect(page.getByLabel('Target set label')).toHaveValue('XC Race Targets');
+ await page.getByRole('button', { name: 'Delete target set' }).click();
+
+ // Assert race predictions are correct (back to default target set)
+ await expect(page.getByRole('row').nth(5)).toHaveText('1.01 mi' + '4:53.11' + '3:00 / km');
+ await expect(page.getByRole('row').nth(6)).toHaveText('1.5 mi' + '7:36.47' + '3:09 / km');
+ await expect(page.getByRole('row').nth(12)).toHaveText('5.47 km' + '19:00' + '3:29 / km');
+ await expect(page.getByRole('row')).toHaveCount(19);
+
+ // Revert target set
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await expect(page.getByLabel('Target set label')).toHaveValue('Less-common Race Targets');
+ await page.getByRole('button', { name: 'Revert target set' }).click();
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // Assert paces are correct
+ await expect(page.getByRole('row').nth(5)).toHaveText('1 mi' + '4:49.86' + '3:00 / km');
+ await expect(page.getByRole('row').nth(10)).toHaveText('5 km' + '17:11.77' + '3:26 / km');
+ await expect(page.getByRole('row')).toHaveCount(17);
+
+ // Assert title was reset
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await expect(page.getByLabel('Target set label')).toHaveValue('Common Race Targets');
+});
diff --git a/tests/e2e/split-calculator.spec.js b/tests/e2e/split-calculator.spec.js
@@ -0,0 +1,191 @@
+import { test, expect } from '@playwright/test';
+
+test('Split Calculator', async ({ page }) => {
+ // Structure:
+ // - Test standard split results
+ // - Test different default units
+ // - Test modified default target set
+ // - Test custom target set
+ // - Reload page
+ // - Assert outputs are still the same
+ // - Test target set deletion and reversion
+
+ // Go to split calculator
+ await page.goto('/');
+ await page.getByRole('button', { name: 'Split Calculator' }).click();
+ await expect(page).toHaveTitle('Split Calculator - Running Tools');
+
+ // Enter input 5K splits (7:00, 6:30, 6:30)
+ await page.getByLabel('Split duration minutes').nth(0).fill('7');
+ await page.getByLabel('Split duration seconds').nth(0).fill('0');
+ await page.getByLabel('Split duration minutes').nth(1).fill('6');
+ await page.getByLabel('Split duration seconds').nth(1).fill('30');
+ await page.getByLabel('Split duration minutes').nth(2).fill('6');
+ await page.getByLabel('Split duration seconds').nth(2).fill('30');
+
+ // Assert times and paces are correct
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('7:00.00');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('7:00 / mi');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('13:30.00');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('6:30 / mi');
+ await expect(page.getByRole('row').nth(3).getByRole('cell').nth(1)).toHaveText('20:00.00');
+ await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('5:52 / mi');
+ await expect(page.getByRole('row')).toHaveCount(4);
+
+ // Change default units
+ await page.getByLabel('Default units').selectOption('Kilometers');
+
+ // Assert times and paces are correct
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('7:00.00');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('4:21 / km');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('13:30.00');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('4:02 / km');
+ await expect(page.getByRole('row').nth(3).getByRole('cell').nth(1)).toHaveText('20:00.00');
+ await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('3:39 / km');
+ await expect(page.getByRole('row')).toHaveCount(4);
+
+ // Edit target set
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await page.getByLabel('Target set label').fill('5K 1600m Splits');
+ await page.getByLabel('Target distance value').nth(0).fill('1.6');
+ await page.getByLabel('Target distance unit').nth(0).selectOption('Kilometers');
+ await page.getByLabel('Target distance value').nth(1).fill('3.2');
+ await page.getByLabel('Target distance unit').nth(1).selectOption('Kilometers');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Target distance value').nth(3).fill('4.8');
+ await page.getByLabel('Target distance unit').nth(3).selectOption('Kilometers');
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // Assert times and paces are correct (new distances are processed)
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('7:00.00');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('4:23 / km');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('13:30.00');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('4:04 / km');
+ await expect(page.getByRole('row').nth(3).getByRole('cell').nth(1)).toHaveText('13:30.00');
+ await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('0:00 / km');
+ await expect(page.getByRole('row').nth(4).getByRole('cell').nth(1)).toHaveText('20:00.00');
+ await expect(page.getByRole('row').nth(4).getByRole('cell').nth(3)).toHaveText('32:30 / km');
+ await expect(page.getByRole('row')).toHaveCount(5);
+
+ // Update third and fourth splits
+ await page.getByLabel('Split duration minutes').nth(2).fill('6');
+ await page.getByLabel('Split duration seconds').nth(2).fill('0');
+ await page.getByLabel('Split duration minutes').nth(3).fill('0');
+ await page.getByLabel('Split duration seconds').nth(3).fill('30');
+ await page.getByLabel('Split duration seconds').nth(3).blur();
+
+ // Assert times and paces are correct (new input splits are processed)
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('7:00.00');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('4:23 / km');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('13:30.00');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('4:04 / km');
+ await expect(page.getByRole('row').nth(3).getByRole('cell').nth(1)).toHaveText('19:30.00');
+ await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('3:45 / km');
+ await expect(page.getByRole('row').nth(4).getByRole('cell').nth(1)).toHaveText('20:00.00');
+ await expect(page.getByRole('row').nth(4).getByRole('cell').nth(3)).toHaveText('2:30 / km');
+ await expect(page.getByRole('row')).toHaveCount(5);
+
+ // Create custom target set
+ await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]');
+ await expect(page.getByRole('row').nth(4)).toHaveText('There aren\'t any targets in this set yet.');
+ await expect(page.getByRole('row')).toHaveCount(5);
+
+ // Edit new target set
+ await expect(page.getByRole('row').nth(1)).toHaveText('There aren\'t any targets in this set yet.');
+ await expect(page.getByLabel('Target set label')).toHaveValue('New target set');
+ await page.getByLabel('Target set label').fill('800m Splits');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Target distance value').nth(0).fill('0.4');
+ await page.getByLabel('Target distance unit').nth(0).selectOption('Kilometers');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Target distance value').nth(1).fill('800');
+ await page.getByLabel('Target distance unit').nth(1).selectOption('Meters');
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // Assert times and paces are correct (input splits initialized to zero)
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('0.4 km');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('0:00.00');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('0:00 / km');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(0)).toHaveText('800 m');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('0:00.00');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('0:00 / km');
+ await expect(page.getByRole('row')).toHaveCount(3);
+
+ // Enter input 800m splits (0:55, 1:05)
+ await page.getByLabel('Split duration minutes').nth(0).fill('0');
+ await page.getByLabel('Split duration seconds').nth(0).fill('55');
+ await page.getByLabel('Split duration minutes').nth(1).fill('1');
+ await page.getByLabel('Split duration seconds').nth(1).fill('5');
+
+ // Assert times and paces are correct
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('0:55.00');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('2:18 / km');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('2:00.00');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('2:43 / km');
+ await expect(page.getByRole('row')).toHaveCount(3);
+
+ // Reload page
+ await page.reload();
+
+ // Assert times and paces are correct (custom targets, split times, and default units not reset)
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('0.4 km');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('0:55.00');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('2:18 / km');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(0)).toHaveText('800 m');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('2:00.00');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('2:43 / km');
+ await expect(page.getByRole('row')).toHaveCount(3);
+
+ // Switch target set
+ await page.getByLabel('Selected target set').selectOption('5K 1600m Splits');
+
+ // Assert times and paces are correct (input splits are not reset)
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('7:00.00');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('4:23 / km');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('13:30.00');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('4:04 / km');
+ await expect(page.getByRole('row').nth(3).getByRole('cell').nth(1)).toHaveText('19:30.00');
+ await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('3:45 / km');
+ await expect(page.getByRole('row').nth(4).getByRole('cell').nth(1)).toHaveText('20:00.00');
+ await expect(page.getByRole('row').nth(4).getByRole('cell').nth(3)).toHaveText('2:30 / km');
+ await expect(page.getByRole('row')).toHaveCount(5);
+
+ // Delete custom target set
+ await page.getByLabel('Selected target set').selectOption('800m Splits');
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await expect(page.getByLabel('Target set label')).toHaveValue('800m Splits');
+ await page.getByRole('button', { name: 'Delete target set' }).click();
+
+ // Assert times and paces are correct (back to default target set)
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('7:00.00');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('4:23 / km');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('13:30.00');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('4:04 / km');
+ await expect(page.getByRole('row').nth(3).getByRole('cell').nth(1)).toHaveText('19:30.00');
+ await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('3:45 / km');
+ await expect(page.getByRole('row').nth(4).getByRole('cell').nth(1)).toHaveText('20:00.00');
+ await expect(page.getByRole('row').nth(4).getByRole('cell').nth(3)).toHaveText('2:30 / km');
+ await expect(page.getByRole('row')).toHaveCount(5);
+
+ // Revert target set
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await expect(page.getByLabel('Target set label')).toHaveValue('5K 1600m Splits');
+ await page.getByRole('button', { name: 'Revert target set' }).click();
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // Assert times and paces are correct (split times are reverted)
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('1 mi');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('0:00.00');
+ await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('0:00 / km');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(0)).toHaveText('2 mi');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('0:00.00');
+ await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('0:00 / km');
+ await expect(page.getByRole('row').nth(3).getByRole('cell').nth(0)).toHaveText('5 km');
+ await expect(page.getByRole('row').nth(3).getByRole('cell').nth(1)).toHaveText('0:00.00');
+ await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('0:00 / km');
+ await expect(page.getByRole('row')).toHaveCount(4);
+
+ // Assert title was reset
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await expect(page.getByLabel('Target set label')).toHaveValue('5K Mile Splits');
+});
diff --git a/tests/e2e/unit-calculator.spec.js b/tests/e2e/unit-calculator.spec.js
@@ -0,0 +1,68 @@
+import { test, expect } from '@playwright/test';
+
+test('Unit Calculator', async ({ page }) => {
+ // Structure:
+ // - Test distance unit conversion
+ // - Test speed and pace unit conversion
+ // - Test time unit conversion
+ // - Reload page
+ // - Assert distance inputs are still loaded
+ // - Assert time inputs are still loaded
+ // - Assert speed and pace inputs are still loaded
+
+ // Go to unit calculator
+ await page.goto('/');
+ await page.getByRole('button', { name: 'Unit Calculator' }).click();
+ await expect(page).toHaveTitle('Unit Calculator - Running Tools');
+
+ // Convert distance units (5000m to mi)
+ await page.getByLabel('Input units').selectOption('Meters');
+ await page.getByLabel('Input value').fill('5000');
+ await page.getByLabel('Output units').selectOption('Miles');
+ await expect(page.getByLabel('Output value')).toHaveText('3.107');
+
+ // Convert speed and pace units (0:04:32/km to mph)
+ await page.getByLabel('Selected unit category').selectOption('Speed & Pace');
+ await page.getByLabel('Input units').selectOption('Time per Kilometer');
+ await page.getByLabel('Input time hours').fill('0');
+ await page.getByLabel('Input time minutes').fill('4');
+ await page.getByLabel('Input time seconds').fill('32');
+ await page.getByLabel('Output units').selectOption('Miles per Hour');
+ await expect(page.getByLabel('Output value')).toHaveText('8.224');
+
+ // Convert speed and pace units (10 kph to time per mile)
+ await page.getByLabel('Input units').selectOption('Kilometers per Hour');
+ await page.getByLabel('Input value').fill('10');
+ await page.getByLabel('Output units').selectOption('Time per Mile');
+ await expect(page.getByLabel('Output value')).toHaveText('00:09:39.366');
+
+ // Convert time units (83.76 min to hh:mm:ss)
+ await page.getByLabel('Selected unit category').selectOption('Time');
+ await page.getByLabel('Input units').selectOption('Minutes');
+ await page.getByLabel('Input value').fill('83.76');
+ await page.getByLabel('Output units').selectOption('hh:mm:ss');
+ await expect(page.getByLabel('Output value')).toHaveText('01:23:45.600');
+
+ // Convert time units (6:54:32.100 to seconds)
+ await page.getByLabel('Selected unit category').selectOption('Time');
+ await page.getByLabel('Input units').selectOption('hh:mm:ss');
+ await page.getByLabel('Input time hours').fill('6');
+ await page.getByLabel('Input time minutes').fill('54');
+ await page.getByLabel('Input time seconds').fill('32.1');
+ await page.getByLabel('Output units').selectOption('seconds');
+ await expect(page.getByLabel('Output value')).toHaveText('24872.100');
+
+ // Reload page
+ await page.reload();
+
+ // Assert distance result is correct (state not reset)
+ await expect(page.getByLabel('Output value')).toHaveText('3.107');
+
+ // Assert time result is correct (state not reset)
+ await page.getByLabel('Selected unit category').selectOption('Time');
+ await expect(page.getByLabel('Output value')).toHaveText('24872.100');
+
+ // Assert speed & pace result is correct (state not reset)
+ await page.getByLabel('Selected unit category').selectOption('Speed & Pace');
+ await expect(page.getByLabel('Output value')).toHaveText('00:09:39.366');
+});
diff --git a/tests/e2e/workout-calculator.spec.js b/tests/e2e/workout-calculator.spec.js
@@ -0,0 +1,143 @@
+import { test, expect } from '@playwright/test';
+
+test('Workout Calculator', async ({ page }) => {
+ // Structure:
+ // - Test standard workout results
+ // - Test different prediction options
+ // - Test modified default target set
+ // - Test custom target set
+ // - Reload page
+ // - Assert outputs are still the same
+ // - Test target set deletion and reversion
+
+ // Go to workout calculator
+ await page.goto('/');
+ await page.getByRole('button', { name: 'Workout Calculator' }).click();
+ await expect(page).toHaveTitle('Workout Calculator - Running Tools');
+
+ // Enter input race (2 mi in 10:30)
+ await page.getByLabel('Input race distance value').fill('2');
+ await page.getByLabel('Input race distance unit').selectOption('Miles');
+ await page.getByLabel('Input race duration hours').fill('0');
+ await page.getByLabel('Input race duration minutes').fill('10');
+ await page.getByLabel('Input race duration seconds').fill('30');
+
+ // Assert workout splits are correct
+ await expect(page.getByRole('row').nth(1)).toHaveText('400 m @ 1 mi' + '1:13.45');
+ await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '5:45.44');
+ await expect(page.getByRole('row')).toHaveCount(5);
+
+ // Change prediction model
+ await page.getByText('Advanced Options').click();
+ await page.getByLabel('Prediction model').selectOption('Riegel\'s Model');
+
+ // Assert workout splits are correct
+ await expect(page.getByRole('row').nth(1)).toHaveText('400 m @ 1 mi' + '1:15.10');
+ await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '5:45.64');
+ await expect(page.getByRole('row')).toHaveCount(5);
+
+ // Change Riegel exponent
+ await page.getByLabel('Riegel Exponent').fill('1.12');
+
+ // Assert workout splits are correct
+ await expect(page.getByRole('row').nth(1)).toHaveText('400 m @ 1 mi' + '1:12.04');
+ await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '6:17.47');
+ await expect(page.getByRole('row')).toHaveCount(5);
+
+ // Edit default target set
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await page.getByLabel('Target set label').fill('Less-common Workout Targets');
+ await page.getByLabel('Split distance value').nth(0).fill('401');
+ await page.getByLabel('Target distance value').nth(0).fill('2');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Split distance value').last().fill('1');
+ await page.getByLabel('Split distance unit').last().selectOption('Miles');
+ await page.getByLabel('Target distance value').last().fill('10');
+ await page.getByLabel('Target distance unit').last().selectOption('Kilometers');
+ await page.getByRole('button', { name: 'Add time target' }).click();
+ await page.getByLabel('Split distance value').last().fill('600');
+ await page.getByLabel('Split distance unit').last().selectOption('Meters');
+ await page.getByLabel('Target duration minutes').last().fill('19');
+ await page.getByLabel('Target duration seconds').last().fill('0');
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // Assert workout splits are correct
+ await expect(page.getByRole('row').nth(1)).toHaveText('401 m @ 2 mi' + '1:18.49');
+ await expect(page.getByRole('row').nth(2)).toHaveText('600 m @ 19:00' + '2:05.14');
+ await expect(page.getByRole('row').nth(4)).toHaveText('1 mi @ 10 km' + '6:00.90');
+ await expect(page.getByRole('row')).toHaveCount(7);
+
+ // Create custom target set
+ await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]');
+ await expect(page.getByRole('row').nth(4)).toHaveText('There aren\'t any targets in this set yet.');
+ await expect(page.getByRole('row')).toHaveCount(5);
+
+ // Edit new target set
+ await expect(page.getByRole('row').nth(1)).toHaveText('There aren\'t any targets in this set yet.');
+ await expect(page.getByLabel('Target set label')).toHaveValue('New target set');
+ await page.getByLabel('Target set label').fill('Workout Target Set #2');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Split distance value').last().fill('800');
+ await page.getByLabel('Split distance unit').last().selectOption('Meters');
+ await page.getByLabel('Target distance value').last().fill('5');
+ await page.getByLabel('Target distance unit').last().selectOption('Kilometers');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Split distance value').last().fill('1600');
+ await page.getByLabel('Split distance unit').last().selectOption('Meters');
+ await page.getByLabel('Target distance value').last().fill('10');
+ await page.getByLabel('Target distance unit').last().selectOption('Kilometers');
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // Assert workout splits are correct
+ await expect(page.getByRole('row').nth(1)).toHaveText('800 m @ 5 km' + '2:45.08');
+ await expect(page.getByRole('row').nth(2)).toHaveText('1600 m @ 10 km' + '5:58.80');
+ await expect(page.getByRole('row')).toHaveCount(3);
+
+ // Reload page
+ await page.reload();
+
+ // Assert workout splits are correct (custom targets and model settings not reset)
+ await expect(page.getByRole('row').nth(1)).toHaveText('800 m @ 5 km' + '2:45.08');
+ await expect(page.getByRole('row').nth(2)).toHaveText('1600 m @ 10 km' + '5:58.80');
+ await expect(page.getByRole('row')).toHaveCount(3);
+
+ // Switch target set
+ await page.getByText('Advanced Options').click();
+ await page.getByLabel('Selected target set').selectOption('Less-common Workout Targets');
+
+ // Assert workout splits are correct
+ await expect(page.getByRole('row').nth(1)).toHaveText('401 m @ 2 mi' + '1:18.49');
+ await expect(page.getByRole('row').nth(2)).toHaveText('600 m @ 19:00' + '2:05.14');
+ await expect(page.getByRole('row').nth(4)).toHaveText('1 mi @ 10 km' + '6:00.90');
+ await expect(page.getByRole('row')).toHaveCount(7);
+
+ // Delete custom target set
+ await page.getByLabel('Selected target set').selectOption('Workout Target Set #2');
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await expect(page.getByLabel('Target set label')).toHaveValue('Workout Target Set #2');
+ await page.getByRole('button', { name: 'Delete target set' }).click();
+
+ // Switch to default target set
+ await page.getByLabel('Selected target set').selectOption('Less-common Workout Targets');
+
+ // Assert workout splits are correct (back to default target set)
+ await expect(page.getByRole('row').nth(1)).toHaveText('401 m @ 2 mi' + '1:18.49');
+ await expect(page.getByRole('row').nth(2)).toHaveText('600 m @ 19:00' + '2:05.14');
+ await expect(page.getByRole('row').nth(4)).toHaveText('1 mi @ 10 km' + '6:00.90');
+ await expect(page.getByRole('row')).toHaveCount(7);
+
+ // Revert target set
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await expect(page.getByLabel('Target set label')).toHaveValue('Less-common Workout Targets');
+ await page.getByRole('button', { name: 'Revert target set' }).click();
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // Assert paces are correct
+ await expect(page.getByRole('row').nth(1)).toHaveText('400 m @ 1 mi' + '1:12.04');
+ await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '6:17.47');
+ await expect(page.getByRole('row')).toHaveCount(5);
+
+ // Assert title was reset
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await expect(page.getByLabel('Target set label')).toHaveValue('Common Workout Targets');
+});
diff --git a/tests/unit/components/DoubleOutputTable.spec.js b/tests/unit/components/DoubleOutputTable.spec.js
@@ -0,0 +1,96 @@
+import { test, expect } from 'vitest';
+import { shallowMount } from '@vue/test-utils';
+import DoubleOutputTable from '@/components/DoubleOutputTable.vue';
+
+test('should correctly render table body rows and headers', () => {
+ // Initialize component
+ const results = [
+ { key: 'key1', value: 'value1', pace: 'pace1', result: 'value', sort: 2 },
+ { key: 'key2', value: 'value2', pace: 'pace2', result: 'value', sort: 1 },
+ { key: 'key3', value: 'value3', pace: 'pace3', result: 'value', sort: 3 },
+
+ { key: 'key4', value: 'value4', pace: 'pace4', result: 'key', sort: 2 },
+ { key: 'key5', value: 'value5', pace: 'pace5', result: 'key', sort: 1 },
+ { key: 'key6', value: 'value6', pace: 'pace6', result: 'key', sort: 3 },
+
+ { key: 'key7', value: 'value7', pace: 'pace7', result: 'value', sort: 2 },
+ { key: 'key8', value: 'value8', pace: 'pace8', result: 'value', sort: 1 },
+ { key: 'key9', value: 'value9', pace: 'pace9', result: 'value', sort: 3 },
+ ];
+ const wrapper = shallowMount(DoubleOutputTable, {
+ propsData: {
+ calculateResult: (col, row) => {
+ expect(col.distanceUnit).to.equal('miles');
+ expect(col.distanceValue).to.equal(2);
+ return results[row.id + 3*(col.time - 600)];
+ },
+ targets: [
+ { id: 0 },
+ { id: 1 },
+ { id: 2 },
+ ],
+ inputTimes: [ 600, 601, 602 ],
+ inputDistance: {
+ distanceUnit: 'miles',
+ distanceValue: 2,
+ },
+ },
+ });
+
+ // Assert headers are correctly generated from first row of results
+ const headers = wrapper.findAll('th');
+ expect(headers[0].element.textContent).to.equal('2 mi');
+ expect(headers[1].element.textContent).to.equal('key1');
+ expect(headers[2].element.textContent).to.equal('key2');
+ expect(headers[3].element.textContent).to.equal('key3');
+ expect(headers.length).to.equal(4);
+
+ // Assert results are correctly rendered
+ const rows = wrapper.findAll('tbody tr');
+ expect(rows[0].findAll('td')[0].element.textContent).to.equal('10:00');
+ expect(rows[0].findAll('td')[1].element.textContent).to.equal('value1');
+ expect(rows[0].findAll('td')[2].element.textContent).to.equal('value2');
+ expect(rows[0].findAll('td')[3].element.textContent).to.equal('value3');
+ expect(rows[0].findAll('td').length).to.equal(4);
+ expect(rows[1].findAll('td')[0].element.textContent).to.equal('10:01');
+ expect(rows[1].findAll('td')[1].element.textContent).to.equal('key4');
+ expect(rows[1].findAll('td')[2].element.textContent).to.equal('key5');
+ expect(rows[1].findAll('td')[3].element.textContent).to.equal('key6');
+ expect(rows[1].findAll('td').length).to.equal(4);
+ expect(rows[2].findAll('td')[0].element.textContent).to.equal('10:02');
+ expect(rows[2].findAll('td')[1].element.textContent).to.equal('value7');
+ expect(rows[2].findAll('td')[2].element.textContent).to.equal('value8');
+ expect(rows[2].findAll('td')[3].element.textContent).to.equal('value9');
+ expect(rows[2].findAll('td').length).to.equal(4);
+ expect(rows.length).to.equal(3);
+});
+
+test('Should display message when inputs are empty', () => {
+ // Initialize component
+ const wrapper = shallowMount(DoubleOutputTable, {
+ propsData: {
+ calculateResult: () => ({ key: 'a', value: 'b', result: 'value', sort: 0 }),
+ targets: [
+ { id: 0 },
+ { id: 1 },
+ { id: 2 },
+ ],
+ inputTimes: [],
+ inputDistance: {
+ distanceUnit: 'miles',
+ distanceValue: 2,
+ },
+ },
+ });
+
+ // Assert headers are correctly generated
+ const headers = wrapper.findAll('th');
+ expect(headers[0].element.textContent).to.equal('2 mi');
+ expect(headers.length).to.equal(1);
+
+ // Assert results are correctly rendered
+ const rows = wrapper.findAll('tbody tr');
+ expect(rows[0].findAll('td')[0].text()).to.equal('No inputs were specified.');
+ expect(rows[0].findAll('td').length).to.equal(1);
+ expect(rows.length).to.equal(1);
+});
diff --git a/tests/unit/components/PaceInput.spec.js b/tests/unit/components/PaceInput.spec.js
@@ -0,0 +1,50 @@
+import { test, expect } from 'vitest';
+import { shallowMount } from '@vue/test-utils';
+import PaceInput from '@/components/PaceInput.vue';
+
+test('should be initialized to modelValue', () => {
+ // Initialize component
+ const wrapper = shallowMount(PaceInput, {
+ propsData: {
+ modelValue: {
+ distanceValue: 3,
+ distanceUnit: 'miles',
+ time: 1000,
+ }
+ },
+ });
+
+ // Assert input fields are correct
+ expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(3);
+ expect(wrapper.find('select').element.value).to.equal('miles');
+ expect(wrapper.findComponent({ name: 'time-input' }).vm.modelValue).to.equal(1000);
+});
+
+test('should update modelValue when inputs are modified', async () => {
+ // Initialize component
+ const wrapper = shallowMount(PaceInput);
+
+ // Update distance value
+ await wrapper.findComponent({ name: 'decimal-input' }).setValue(3);
+ expect(wrapper.vm.modelValue).to.deep.equal({
+ distanceValue: 3,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ });
+
+ // Update distance unit
+ await wrapper.find('select').setValue('miles');
+ expect(wrapper.vm.modelValue).to.deep.equal({
+ distanceValue: 3,
+ distanceUnit: 'miles',
+ time: 1200,
+ });
+
+ // Update time
+ await wrapper.findComponent({ name: 'time-input' }).setValue(1000);
+ expect(wrapper.vm.modelValue).to.deep.equal({
+ distanceValue: 3,
+ distanceUnit: 'miles',
+ time: 1000,
+ });
+});
diff --git a/tests/unit/components/RaceOptions.spec.js b/tests/unit/components/RaceOptions.spec.js
@@ -0,0 +1,38 @@
+import { test, expect } from 'vitest';
+import { shallowMount } from '@vue/test-utils';
+import RaceOptions from '@/components/RaceOptions.vue';
+
+test('should be initialized to modelValue', () => {
+ // Initialize component
+ const wrapper = shallowMount(RaceOptions, {
+ propsData: {
+ modelValue: {
+ model: 'PurdyPointsModel',
+ riegelExponent: 1.2,
+ }
+ },
+ });
+
+ // Assert input fields are correct
+ expect(wrapper.find('select').element.value).to.equal('PurdyPointsModel');
+ expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1.2);
+});
+
+test('should update modelValue when inputs are modified', async () => {
+ // Initialize component
+ const wrapper = shallowMount(RaceOptions);
+
+ // Update model
+ await wrapper.find('select').setValue('CameronModel');
+ expect(wrapper.vm.modelValue).to.deep.equal({
+ model: 'CameronModel',
+ riegelExponent: 1.06,
+ });
+
+ // Update Riegel exponent
+ await wrapper.findComponent({ name: 'decimal-input' }).setValue(1.3);
+ expect(wrapper.vm.modelValue).to.deep.equal({
+ model: 'CameronModel',
+ riegelExponent: 1.3,
+ });
+});
diff --git a/tests/unit/components/SimpleTargetTable.spec.js b/tests/unit/components/SimpleTargetTable.spec.js
@@ -1,123 +0,0 @@
-import { test, expect } from 'vitest';
-import { shallowMount } from '@vue/test-utils';
-import SimpleTargetTable from '@/components/SimpleTargetTable.vue';
-
-test('results should be correct and sorted by time', () => {
- // Initialize component
- const wrapper = shallowMount(SimpleTargetTable, {
- propsData: {
- calculateResult: (row) => ({
- distanceValue: row.distanceValue ? row.distanceValue : row.time / 300,
- distanceUnit: row.distanceUnit ? row.distanceUnit : 'miles',
- time: row.time ? row.time : row.distanceValue * 300,
- result: row.result,
- }),
- targets: [
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 3, distanceUnit: 'miles' },
- { result: 'distance', time: 1230 },
- ],
- },
- });
-
- // Assert results are correctly rendered
- const rows = wrapper.findAll('tbody tr');
- expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 mi');
- expect(rows[0].findAll('td')[1].element.textContent).to.equal('5:00.00');
- expect(rows[0].findAll('td').length).to.equal(2);
- expect(rows[1].findAll('td')[0].element.textContent).to.equal('3 mi');
- expect(rows[1].findAll('td')[1].element.textContent).to.equal('15:00.00');
- expect(rows[1].findAll('td').length).to.equal(2);
- expect(rows[2].findAll('td')[0].element.textContent).to.equal('4.10 mi');
- expect(rows[2].findAll('td')[1].element.textContent).to.equal('20:30');
- expect(rows[2].findAll('td').length).to.equal(2);
- expect(rows[3].findAll('td')[0].element.textContent).to.equal('5 km');
- expect(rows[3].findAll('td')[1].element.textContent).to.equal('25:00.00');
- expect(rows[3].findAll('td').length).to.equal(2);
- expect(rows.length).to.equal(4);
-});
-
-test('should show correct imperial paces when showPace is true', () => {
- // Initialize component
- const wrapper = shallowMount(SimpleTargetTable, {
- propsData: {
- calculateResult: (row) => ({
- distanceValue: row.distanceValue ? row.distanceValue : row.time / 300,
- distanceUnit: row.distanceUnit ? row.distanceUnit : 'miles',
- time: row.time ? row.time : row.distanceValue * 300,
- result: row.result,
- }),
- targets: [
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 3, distanceUnit: 'miles' },
- { result: 'distance', time: 1230 },
- ],
- defaultUnitSystem: 'imperial',
- showPace: true,
- },
- });
-
- // Assert results are correctly rendered
- const rows = wrapper.findAll('tbody tr');
- expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 mi');
- expect(rows[0].findAll('td')[1].element.textContent).to.equal('5:00.00');
- expect(rows[0].findAll('td')[2].element.textContent).to.equal('5:00 / mi');
- expect(rows[0].findAll('td').length).to.equal(3);
- expect(rows[1].findAll('td')[0].element.textContent).to.equal('3 mi');
- expect(rows[1].findAll('td')[1].element.textContent).to.equal('15:00.00');
- expect(rows[1].findAll('td')[2].element.textContent).to.equal('5:00 / mi');
- expect(rows[1].findAll('td').length).to.equal(3);
- expect(rows[2].findAll('td')[0].element.textContent).to.equal('4.10 mi');
- expect(rows[2].findAll('td')[1].element.textContent).to.equal('20:30');
- expect(rows[2].findAll('td')[2].element.textContent).to.equal('5:00 / mi');
- expect(rows[2].findAll('td').length).to.equal(3);
- expect(rows[3].findAll('td')[0].element.textContent).to.equal('5 km');
- expect(rows[3].findAll('td')[1].element.textContent).to.equal('25:00.00');
- expect(rows[3].findAll('td')[2].element.textContent).to.equal('8:03 / mi');
- expect(rows[3].findAll('td').length).to.equal(3);
- expect(rows.length).to.equal(4);
-});
-
-test('should show correct metric paces when showPace is true', () => {
- // Initialize component
- const wrapper = shallowMount(SimpleTargetTable, {
- propsData: {
- calculateResult: (row) => ({
- distanceValue: row.distanceValue ? row.distanceValue : row.time / 300,
- distanceUnit: row.distanceUnit ? row.distanceUnit : 'miles',
- time: row.time ? row.time : row.distanceValue * 300,
- result: row.result,
- }),
- targets: [
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 3, distanceUnit: 'miles' },
- { result: 'distance', time: 1230 },
- ],
- defaultUnitSystem: 'metric',
- showPace: true,
- },
- });
-
- // Assert results are correctly rendered
- const rows = wrapper.findAll('tbody tr');
- expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 mi');
- expect(rows[0].findAll('td')[1].element.textContent).to.equal('5:00.00');
- expect(rows[0].findAll('td')[2].element.textContent).to.equal('3:06 / km');
- expect(rows[0].findAll('td').length).to.equal(3);
- expect(rows[1].findAll('td')[0].element.textContent).to.equal('3 mi');
- expect(rows[1].findAll('td')[1].element.textContent).to.equal('15:00.00');
- expect(rows[1].findAll('td')[2].element.textContent).to.equal('3:06 / km');
- expect(rows[1].findAll('td').length).to.equal(3);
- expect(rows[2].findAll('td')[0].element.textContent).to.equal('4.10 mi');
- expect(rows[2].findAll('td')[1].element.textContent).to.equal('20:30');
- expect(rows[2].findAll('td')[2].element.textContent).to.equal('3:06 / km');
- expect(rows[2].findAll('td').length).to.equal(3);
- expect(rows[3].findAll('td')[0].element.textContent).to.equal('5 km');
- expect(rows[3].findAll('td')[1].element.textContent).to.equal('25:00.00');
- expect(rows[3].findAll('td')[2].element.textContent).to.equal('5:00 / km');
- expect(rows[3].findAll('td').length).to.equal(3);
- expect(rows.length).to.equal(4);
-});
diff --git a/tests/unit/components/SingleOutputTable.spec.js b/tests/unit/components/SingleOutputTable.spec.js
@@ -0,0 +1,103 @@
+import { test, expect } from 'vitest';
+import { shallowMount } from '@vue/test-utils';
+import SingleOutputTable from '@/components/SingleOutputTable.vue';
+
+test('results should be correct and sorted by sort key', () => {
+ // Initialize component
+ const results = [
+ { key: 'key1', value: 'value1', pace: 'pace1', result: 'key', sort: 2 },
+ { key: 'key2', value: 'value2', pace: 'pace2', result: 'key', sort: 1 },
+ { key: 'key3', value: 'value3', pace: 'pace3', result: 'key', sort: 3 },
+ ];
+ const wrapper = shallowMount(SingleOutputTable, {
+ propsData: {
+ calculateResult: (row) => results[row.id],
+ targets: [
+ { id: 0 },
+ { id: 1 },
+ { id: 2 },
+ ],
+ },
+ });
+
+ // Assert results are correctly rendered
+ const rows = wrapper.findAll('tbody tr');
+ expect(rows[0].findAll('td')[0].element.textContent).to.equal('key2');
+ expect(rows[0].findAll('td')[1].element.textContent).to.equal('value2');
+ expect(rows[0].findAll('td').length).to.equal(2);
+ expect(rows[1].findAll('td')[0].element.textContent).to.equal('key1');
+ expect(rows[1].findAll('td')[1].element.textContent).to.equal('value1');
+ expect(rows[1].findAll('td').length).to.equal(2);
+ expect(rows[2].findAll('td')[0].element.textContent).to.equal('key3');
+ expect(rows[2].findAll('td')[1].element.textContent).to.equal('value3');
+ expect(rows[2].findAll('td').length).to.equal(2);
+ expect(rows.length).to.equal(3);
+});
+
+test('results should have correct classes', () => {
+ // Initialize component
+ const results = [
+ { key: 'key1', value: 'value1', pace: 'pace1', result: 'value', sort: 1 },
+ { key: 'key2', value: 'value2', pace: 'pace2', result: 'key', sort: 2 },
+ { key: 'key3', value: 'value3', pace: 'pace3', result: 'value', sort: 3 },
+ ];
+ const wrapper = shallowMount(SingleOutputTable, {
+ propsData: {
+ calculateResult: (row) => results[row.id],
+ targets: [
+ { id: 0 },
+ { id: 1 },
+ { id: 2 },
+ ],
+ },
+ });
+
+ // Assert results are correctly rendered
+ const rows = wrapper.findAll('tbody tr');
+ expect(rows[0].findAll('td')[0].element.classList).toHaveLength(0);
+ expect(rows[0].findAll('td')[1].element.classList).to.contain(['result']);
+ expect(rows[0].findAll('td').length).to.equal(2);
+ expect(rows[1].findAll('td')[0].element.classList).to.contain(['result']);
+ expect(rows[1].findAll('td')[1].element.classList).toHaveLength(0);
+ expect(rows[1].findAll('td').length).to.equal(2);
+ expect(rows[2].findAll('td')[0].element.classList).toHaveLength(0);
+ expect(rows[2].findAll('td')[1].element.classList).contain(['result']);
+ expect(rows[2].findAll('td').length).to.equal(2);
+ expect(rows.length).to.equal(3);
+});
+
+test('should show correct paces when showPace is true', () => {
+ // Initialize component
+ const results = [
+ { key: 'key1', value: 'value1', pace: 'pace1', result: 'key', sort: 1 },
+ { key: 'key2', value: 'value2', pace: 'pace2', result: 'key', sort: 2 },
+ { key: 'key3', value: 'value3', pace: 'pace3', result: 'key', sort: 3 },
+ ];
+ const wrapper = shallowMount(SingleOutputTable, {
+ propsData: {
+ calculateResult: (row) => results[row.id],
+ targets: [
+ { id: 0 },
+ { id: 1 },
+ { id: 2 },
+ ],
+ showPace: true,
+ },
+ });
+
+ // Assert results are correctly rendered
+ const rows = wrapper.findAll('tbody tr');
+ expect(rows[0].findAll('td')[0].element.textContent).to.equal('key1');
+ expect(rows[0].findAll('td')[1].element.textContent).to.equal('value1');
+ expect(rows[0].findAll('td')[2].element.textContent).to.equal('pace1');
+ expect(rows[0].findAll('td').length).to.equal(3);
+ expect(rows[1].findAll('td')[0].element.textContent).to.equal('key2');
+ expect(rows[1].findAll('td')[1].element.textContent).to.equal('value2');
+ expect(rows[1].findAll('td')[2].element.textContent).to.equal('pace2');
+ expect(rows[1].findAll('td').length).to.equal(3);
+ expect(rows[2].findAll('td')[0].element.textContent).to.equal('key3');
+ expect(rows[2].findAll('td')[1].element.textContent).to.equal('value3');
+ expect(rows[2].findAll('td')[2].element.textContent).to.equal('pace3');
+ expect(rows[2].findAll('td').length).to.equal(3);
+ expect(rows.length).to.equal(3);
+});
diff --git a/tests/unit/components/SplitOutputTable.spec.js b/tests/unit/components/SplitOutputTable.spec.js
@@ -0,0 +1,157 @@
+import { test, expect } from 'vitest';
+import { shallowMount } from '@vue/test-utils';
+import SplitOutputTable from '@/components/SplitOutputTable.vue';
+
+test('should initialize undefined splits to 0:00.00', async () => {
+ // Initialize component
+ const wrapper = shallowMount(SplitOutputTable, {
+ propsData: {
+ modelValue: [
+ { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
+ { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
+ { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
+ ],
+ },
+ });
+
+ // Assert results are correct
+ const rows = wrapper.findAll('tbody tr');
+ expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 mi');
+ expect(rows[0].findAll('td')[1].element.textContent).to.equal('0:00.00');
+ expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0);
+ expect(rows[0].findAll('td').length).to.equal(4);
+ expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 mi');
+ expect(rows[1].findAll('td')[1].element.textContent).to.equal('0:00.00');
+ expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0);
+ expect(rows[1].findAll('td').length).to.equal(4);
+ expect(rows[2].findAll('td')[0].element.textContent).to.equal('5 km');
+ expect(rows[2].findAll('td')[1].element.textContent).to.equal('0:00.00');
+ expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0);
+ expect(rows[2].findAll('td').length).to.equal(4);
+ expect(rows.length).to.equal(3);
+});
+
+test('should correctly load split times from split targets', async () => {
+ // Initialize component
+ const wrapper = shallowMount(SplitOutputTable, {
+ propsData: {
+ modelValue: [
+ { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', splitTime: 180 },
+ { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', splitTime: 190 },
+ { result: 'time', distanceValue: 3000, distanceUnit: 'meters', splitTime: 200 },
+ ],
+ },
+ });
+
+ // Assert results are correct
+ const rows = wrapper.findAll('tbody tr');
+ expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 km');
+ expect(rows[0].findAll('td')[1].element.textContent).to.equal('3:00.00');
+ expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue)
+ .to.equal(180);
+ expect(rows[0].findAll('td').length).to.equal(4);
+ expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 km');
+ expect(rows[1].findAll('td')[1].element.textContent).to.equal('6:10.00');
+ expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue)
+ .to.equal(190);
+ expect(rows[1].findAll('td').length).to.equal(4);
+ expect(rows[2].findAll('td')[0].element.textContent).to.equal('3000 m');
+ expect(rows[2].findAll('td')[1].element.textContent).to.equal('9:30.00');
+ expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue)
+ .to.equal(200);
+ expect(rows[2].findAll('td').length).to.equal(4);
+ expect(rows.length).to.equal(3);
+});
+
+test('should correctly calculate paces and cumulative times from entered split times', async () => {
+ // Initialize component
+ const wrapper = shallowMount(SplitOutputTable, {
+ propsData: {
+ modelValue: [
+ { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
+ { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
+ { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
+ ],
+ },
+ });
+
+ // Update split times
+ await wrapper.findAllComponents({ name: 'time-input' })[0].setValue(420);
+ await wrapper.findAllComponents({ name: 'time-input' })[1].setValue(390);
+ await wrapper.findAllComponents({ name: 'time-input' })[2].setValue(390);
+
+ // Assert results are correct
+ const rows = wrapper.findAll('tbody tr');
+ expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 mi');
+ expect(rows[0].findAll('td')[1].element.textContent).to.equal('7:00.00');
+ expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' })
+ .vm.modelValue).to.equal(420);
+ expect(rows[0].findAll('td')[3].element.textContent).to.equal('4:21 / km');
+ expect(rows[0].findAll('td').length).to.equal(4);
+ expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 mi');
+ expect(rows[1].findAll('td')[1].element.textContent).to.equal('13:30.00');
+ expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' })
+ .vm.modelValue).to.equal(390);
+ expect(rows[1].findAll('td')[3].element.textContent).to.equal('4:02 / km');
+ expect(rows[1].findAll('td').length).to.equal(4);
+ expect(rows[2].findAll('td')[0].element.textContent).to.equal('5 km');
+ expect(rows[2].findAll('td')[1].element.textContent).to.equal('20:00.00');
+ expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' })
+ .vm.modelValue).to.equal(390);
+ expect(rows[2].findAll('td')[3].element.textContent).to.equal('3:39 / km');
+ expect(rows[2].findAll('td').length).to.equal(4);
+ expect(rows.length).to.equal(3);
+});
+
+test('should correctly update modelValue with split times', async () => {
+ // Initialize component
+ const wrapper = shallowMount(SplitOutputTable, {
+ propsData: {
+ modelValue: [
+ { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', splitTime: 180 },
+ { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', splitTime: 180 },
+ { result: 'time', distanceValue: 3000, distanceUnit: 'meters', splitTime: 180 },
+ ],
+ },
+ });
+
+ // Update split times
+ await wrapper.findAllComponents({ name: 'time-input' })[1].setValue(190);
+ await wrapper.findAllComponents({ name: 'time-input' })[2].setValue(200);
+
+ // Assert modelValue correctly updated
+ expect(wrapper.vm.modelValue).to.deep.equal([
+ { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', splitTime: 180 },
+ { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', splitTime: 190 },
+ { result: 'time', distanceValue: 3000, distanceUnit: 'meters', splitTime: 200 },
+ ]);
+});
+
+test('should update paces according to default units setting', async () => {
+ // Initialize component
+ const wrapper = shallowMount(SplitOutputTable, {
+ propsData: {
+ modelValue: [
+ { result: 'time', distanceValue: 1, distanceUnit: 'miles', splitTime: 300 },
+ { result: 'time', distanceValue: 2, distanceUnit: 'miles', splitTime: 300 },
+ { result: 'time', distanceValue: 5, distanceUnit: 'kilometers', splitTime: 330 },
+ ],
+ defaultUnitSystem: 'metric',
+ }
+ });
+
+ // Assert paces are correct
+ let rows = wrapper.findAll('tbody tr');
+ expect(rows[0].findAll('td')[3].element.textContent).to.equal('3:06 / km');
+ expect(rows[1].findAll('td')[3].element.textContent).to.equal('3:06 / km');
+ expect(rows[2].findAll('td')[3].element.textContent).to.equal('3:05 / km');
+
+ // Change default units
+ await wrapper.setProps({ defaultUnitSystem: 'imperial' });
+
+ // Assert paces are correct
+ rows = wrapper.findAll('tbody tr');
+ expect(rows[0].findAll('td')[3].element.textContent).to.equal('5:00 / mi');
+ expect(rows[1].findAll('td')[3].element.textContent).to.equal('5:00 / mi');
+ expect(rows[2].findAll('td')[3].element.textContent).to.equal('4:58 / mi');
+});
diff --git a/tests/unit/components/TargetEditor.spec.js b/tests/unit/components/TargetEditor.spec.js
@@ -2,18 +2,19 @@ import { test, expect } from 'vitest';
import { shallowMount } from '@vue/test-utils';
import TargetEditor from '@/components/TargetEditor.vue';
-test('should correctly render target set', async () => {
+test('should correctly render standard target set', async () => {
// Initialize component
const wrapper = shallowMount(TargetEditor, {
propsData: {
modelValue: {
name: 'My target set',
targets: [
- { distanceUnit: 'kilometers', distanceValue: 1.61, result: 'time' },
- { distanceUnit: 'miles', distanceValue: 3.11, result: 'time' },
- { time: 600, result: 'distance' },
+ { distanceUnit: 'kilometers', distanceValue: 1.61, type: 'distance' },
+ { distanceUnit: 'miles', distanceValue: 3.11, type: 'distance' },
+ { time: 600, type: 'time' },
],
},
+ setType: 'standard',
},
});
@@ -28,6 +29,76 @@ test('should correctly render target set', async () => {
expect(rows.length).to.equal(3);
});
+test('should correctly render split target set', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [
+ { distanceUnit: 'kilometers', distanceValue: 1.61, type: 'distance' },
+ { distanceUnit: 'miles', distanceValue: 3.11, type: 'distance' },
+ ],
+ },
+ setType: 'split',
+ },
+ });
+
+ // Assert target set correctly rendered
+ expect(wrapper.find('input').element.value).to.equal('My target set');
+ const rows = wrapper.findAll('tbody tr');
+ expect(rows[0].findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1.61);
+ expect(rows[0].find('select').element.value).to.equal('kilometers');
+ expect(rows[1].findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(3.11);
+ expect(rows[1].find('select').element.value).to.equal('miles');
+ expect(rows.length).to.equal(2);
+});
+
+test('should correctly render workout target set', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ {
+ distanceUnit: 'kilometers', distanceValue: 5,
+ splitUnit: 'miles', splitValue: 1,
+ type: 'distance'
+ },
+ ],
+ },
+ setType: 'workout',
+ },
+ });
+
+ // Assert target set correctly rendered
+ expect(wrapper.find('input').element.value).to.equal('My target set');
+ const rows = wrapper.findAll('tbody tr');
+ expect(rows[0].findAllComponents({ name: 'decimal-input' })[0].vm.modelValue).to.equal(400);
+ expect(rows[0].findAll('select')[0].element.value).to.equal('meters');
+ expect(rows[0].findAllComponents({ name: 'decimal-input' })[1].vm.modelValue).to.equal(2);
+ expect(rows[0].findAll('select')[1].element.value).to.equal('miles');
+ expect(rows[1].findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(2);
+ expect(rows[1].find('select').element.value).to.equal('kilometers');
+ expect(rows[1].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(6000);
+ expect(rows[2].findAllComponents({ name: 'decimal-input' })[0].vm.modelValue).to.equal(1);
+ expect(rows[2].findAll('select')[0].element.value).to.equal('miles');
+ expect(rows[2].findAllComponents({ name: 'decimal-input' })[1].vm.modelValue).to.equal(5);
+ expect(rows[2].findAll('select')[1].element.value).to.equal('kilometers');
+ expect(rows.length).to.equal(3);
+});
+
test('revert button should emit revert event', async () => {
// Initialize component
const wrapper = shallowMount(TargetEditor);
@@ -65,17 +136,18 @@ test('close button should emit close event', async () => {
expect(wrapper.emitted().close.length).to.equal(1);
});
-test('add distance target button should correctly add imperial distance target', async () => {
+test('add distance target button should correctly add standard imperial distance target', async () => {
// Initialize component
const wrapper = shallowMount(TargetEditor, {
propsData: {
modelValue: {
name: 'My target set',
targets: [
- { distanceUnit: 'miles', distanceValue: 0, result: 'time' },
- { time: 0, result: 'distance' },
+ { distanceUnit: 'miles', distanceValue: 0, type: 'distance' },
+ { time: 0, type: 'time' },
],
},
+ setType: 'standard',
defaultUnitSystem: 'imperial'
},
});
@@ -88,25 +160,26 @@ test('add distance target button should correctly add imperial distance target',
[{
name: 'My target set',
targets: [
- { distanceUnit: 'miles', distanceValue: 0, result: 'time' },
- { time: 0, result: 'distance' },
- { distanceUnit: 'miles', distanceValue: 1, result: 'time'},
+ { distanceUnit: 'miles', distanceValue: 0, type: 'distance' },
+ { time: 0, type: 'time' },
+ { distanceUnit: 'miles', distanceValue: 1, type: 'distance'},
],
}],
]);
});
-test('add distance target button should correctly add metric distance target', async () => {
+test('add distance target button should correctly add standard metric distance target', async () => {
// Initialize component
const wrapper = shallowMount(TargetEditor, {
propsData: {
modelValue: {
name: 'My target set',
targets: [
- { distanceUnit: 'miles', distanceValue: 0, result: 'time' },
- { time: 0, result: 'distance' },
+ { distanceUnit: 'miles', distanceValue: 0, type: 'distance' },
+ { time: 0, type: 'time' },
],
},
+ setType: 'standard',
defaultUnitSystem: 'metric'
},
});
@@ -119,25 +192,190 @@ test('add distance target button should correctly add metric distance target', a
[{
name: 'My target set',
targets: [
- { distanceUnit: 'miles', distanceValue: 0, result: 'time' },
- { time: 0, result: 'distance' },
- { distanceUnit: 'kilometers', distanceValue: 1, result: 'time'},
+ { distanceUnit: 'miles', distanceValue: 0, type: 'distance' },
+ { time: 0, type: 'time' },
+ { distanceUnit: 'kilometers', distanceValue: 1, type: 'distance'},
],
}],
]);
});
-test('add time target button should correctly add time target', async () => {
+test('add distance target button should correctly add split imperial distance target', async () => {
// Initialize component
const wrapper = shallowMount(TargetEditor, {
propsData: {
modelValue: {
name: 'My target set',
targets: [
- { distanceUnit: 'miles', distanceValue: 0, result: 'time' },
- { time: 0, result: 'distance' },
+ { distanceUnit: 'miles', distanceValue: 0, type: 'distance' },
],
},
+ setType: 'split',
+ defaultUnitSystem: 'imperial'
+ },
+ });
+
+ // Add distance target
+ await wrapper.find('button[title="Add distance target"]').trigger('click');
+
+ // Assert input event was emitted
+ expect(wrapper.emitted()['update:modelValue']).to.deep.equal([
+ [{
+ name: 'My target set',
+ targets: [
+ { distanceUnit: 'miles', distanceValue: 0, type: 'distance' },
+ { distanceUnit: 'miles', distanceValue: 1, type: 'distance'},
+ ],
+ }],
+ ]);
+});
+
+test('add distance target button should correctly add split metric distance target', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [
+ { distanceUnit: 'miles', distanceValue: 0, type: 'distance' },
+ ],
+ },
+ setType: 'split',
+ defaultUnitSystem: 'metric'
+ },
+ });
+
+ // Add distance target
+ await wrapper.find('button[title="Add distance target"]').trigger('click');
+
+ // Assert input event was emitted
+ expect(wrapper.emitted()['update:modelValue']).to.deep.equal([
+ [{
+ name: 'My target set',
+ targets: [
+ { distanceUnit: 'miles', distanceValue: 0, type: 'distance' },
+ { distanceUnit: 'kilometers', distanceValue: 1, type: 'distance'},
+ ],
+ }],
+ ]);
+});
+
+test('add distance target button should correctly add workout imperial distance target', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ ],
+ },
+ setType: 'workout',
+ defaultUnitSystem: 'imperial'
+ },
+ });
+
+ // Add distance target
+ await wrapper.find('button[title="Add distance target"]').trigger('click');
+
+ // Assert input event was emitted
+ expect(wrapper.emitted()['update:modelValue']).to.deep.equal([
+ [{
+ name: 'My target set',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ {
+ distanceUnit: 'miles', distanceValue: 1,
+ splitUnit: 'miles', splitValue: 1,
+ type: 'distance'
+ },
+ ],
+ }],
+ ]);
+});
+
+test('add distance target button should correctly add workout metric distance target', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ ],
+ },
+ setType: 'workout',
+ defaultUnitSystem: 'metric'
+ },
+ });
+
+ // Add distance target
+ await wrapper.find('button[title="Add distance target"]').trigger('click');
+
+ // Assert input event was emitted
+ expect(wrapper.emitted()['update:modelValue']).to.deep.equal([
+ [{
+ name: 'My target set',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ {
+ distanceUnit: 'kilometers', distanceValue: 1,
+ splitUnit: 'kilometers', splitValue: 1,
+ type: 'distance'
+ },
+ ],
+ }],
+ ]);
+});
+
+test('add time target button should correctly add standard time target', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [
+ { distanceUnit: 'miles', distanceValue: 0, type: 'distance' },
+ { time: 0, type: 'time' },
+ ],
+ },
+ setType: 'standard',
},
});
@@ -148,22 +386,145 @@ test('add time target button should correctly add time target', async () => {
expect(wrapper.emitted()['update:modelValue']).to.deep.equal([
[{ name: 'My target set',
targets: [
- { distanceUnit: 'miles', distanceValue: 0, result: 'time' },
- { time: 0, result: 'distance' },
- { time: 600, result: 'distance' },
+ { distanceUnit: 'miles', distanceValue: 0, type: 'distance' },
+ { time: 0, type: 'time' },
+ { time: 600, type: 'time' },
+ ],
+ }],
+ ]);
+});
+
+test('add time target button should be hidden for split target sets', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [
+ { distanceUnit: 'miles', distanceValue: 1, type: 'distance' },
+ { distanceUnit: 'miles', distanceValue: 2, type: 'distance' },
+ ],
+ },
+ setType: 'split',
+ },
+ });
+
+ // Add time target
+ expect(wrapper.findAll('button[title="Add time target"]')).toHaveLength(0);
+});
+
+test('add time target button should correctly add workout imperial time target', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ ],
+ },
+ setType: 'workout',
+ defaultUnitSystem: 'imperial'
+ },
+ });
+
+ // Add distance target
+ await wrapper.find('button[title="Add time target"]').trigger('click');
+
+ // Assert input event was emitted
+ expect(wrapper.emitted()['update:modelValue']).to.deep.equal([
+ [{
+ name: 'My target set',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ {
+ time: 600,
+ splitUnit: 'miles', splitValue: 1,
+ type: 'time'
+ },
],
}],
]);
});
-test('Should emit input event when targets are updated', async () => {
+test('add time target button should correctly add workout metric time target', async () => {
// Initialize component
const wrapper = shallowMount(TargetEditor, {
propsData: {
modelValue: {
name: 'My target set',
targets: [
- { distanceUnit: 'miles', distanceValue: 2, result: 'time' },
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ ],
+ },
+ setType: 'workout',
+ defaultUnitSystem: 'metric'
+ },
+ });
+
+ // Add distance target
+ await wrapper.find('button[title="Add time target"]').trigger('click');
+
+ // Assert input event was emitted
+ expect(wrapper.emitted()['update:modelValue']).to.deep.equal([
+ [{
+ name: 'My target set',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ {
+ time: 600,
+ splitUnit: 'kilometers', splitValue: 1,
+ type: 'time'
+ },
+ ],
+ }],
+ ]);
+});
+
+test('should emit input event when targets are updated', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [
+ { distanceUnit: 'miles', distanceValue: 2, type: 'distance' },
],
},
},
@@ -178,21 +539,21 @@ test('Should emit input event when targets are updated', async () => {
{
name: 'My target set',
targets: [
- { distanceUnit: 'miles', distanceValue: 3, result: 'time' },
+ { distanceUnit: 'miles', distanceValue: 3, type: 'distance' },
],
},
],
]);
});
-test('Should emit input event when target set name is updated', async () => {
+test('should emit input event when target set name is updated', async () => {
// Initialize component
const wrapper = shallowMount(TargetEditor, {
propsData: {
modelValue: {
name: 'My target set',
targets: [
- { distanceUnit: 'miles', distanceValue: 2, result: 'time' },
+ { distanceUnit: 'miles', distanceValue: 2, type: 'distance' },
],
},
},
@@ -207,7 +568,7 @@ test('Should emit input event when target set name is updated', async () => {
{
name: 'My target set #2',
targets: [
- { distanceUnit: 'miles', distanceValue: 2, result: 'time' },
+ { distanceUnit: 'miles', distanceValue: 2, type: 'distance' },
],
},
],
@@ -221,9 +582,9 @@ test('removeTarget button should correctly remove target', async () => {
modelValue: {
name: 'My target set',
targets: [
- { distanceUnit: 'miles', distanceValue: 1, result: 'time' },
- { distanceUnit: 'miles', distanceValue: 2, result: 'time' },
- { distanceUnit: 'miles', distanceValue: 3, result: 'time' },
+ { distanceUnit: 'miles', distanceValue: 1, type: 'distance' },
+ { distanceUnit: 'miles', distanceValue: 2, type: 'distance' },
+ { distanceUnit: 'miles', distanceValue: 3, type: 'distance' },
],
},
},
@@ -237,9 +598,26 @@ test('removeTarget button should correctly remove target', async () => {
[{
name: 'My target set',
targets: [
- { distanceUnit: 'miles', distanceValue: 1, result: 'time' },
- { distanceUnit: 'miles', distanceValue: 3, result: 'time' },
+ { distanceUnit: 'miles', distanceValue: 1, type: 'distance' },
+ { distanceUnit: 'miles', distanceValue: 3, type: 'distance' },
],
}],
]);
});
+
+test('should display message when target set is empty', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [],
+ },
+ },
+ });
+
+ // Assert message correctly rendered
+ const rows = wrapper.findAll('tbody tr');
+ expect(rows[0].text()).to.equal('There aren\'t any targets in this set yet.');
+ expect(rows.length).to.equal(1);
+});
diff --git a/tests/unit/components/TargetSetSelector.spec.js b/tests/unit/components/TargetSetSelector.spec.js
@@ -1,37 +1,30 @@
-import { beforeEach, test, expect, vi } from 'vitest';
+import { test, expect, vi } from 'vitest';
import { shallowMount } from '@vue/test-utils';
import TargetSetSelector from '@/components/TargetSetSelector.vue';
-beforeEach(() => {
- localStorage.clear();
-})
-
test('should correctly render target sets options', async () => {
- // Initialize localStorage
- const targetSets = {
- 'A': {
- name: '1st target set',
- targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 3, distanceUnit: 'miles' },
- ],
- },
- 'B': {
- name: '2nd target set',
- targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' },
- ],
- },
- };
- localStorage.setItem('running-tools.target-sets', JSON.stringify(targetSets));
-
// Initialize component
const wrapper = shallowMount(TargetSetSelector, {
propsData: {
- modelValue: 'B',
+ selectedTargetSet: 'B',
+ targetSets: {
+ 'A': {
+ name: '1st target set',
+ targets: [
+ { type: 'distance', distanceValue: 1, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 3, distanceUnit: 'miles' },
+ ],
+ },
+ 'B': {
+ name: '2nd target set',
+ targets: [
+ { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 10, distanceUnit: 'kilometers' },
+ ],
+ },
+ },
}
});
@@ -50,86 +43,110 @@ test('should correctly render target sets options', async () => {
});
test('Create New Target Set option should correctly add target set', async () => {
- // Initialize localStorage
+ // Initialize component
let targetSets = {
'A': {
name: '1st target set',
targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 3, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 1, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 3, distanceUnit: 'miles' },
],
},
'B': {
name: '2nd target set',
targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 10, distanceUnit: 'kilometers' },
],
},
};
- localStorage.setItem('running-tools.target-sets', JSON.stringify(targetSets));
-
- // Initialize component
const wrapper = shallowMount(TargetSetSelector, {
propsData: {
- modelValue: 'A',
+ selectedTargetSet: '_new',
+ targetSets,
}
});
+ await wrapper.vm.$nextTick();
- // Add target set
- await wrapper.find('select').setValue('_new');
+ // Assert new target set selected (key is unix timestamp in milliseconds)
+ const key1 = wrapper.find('select').element.value;
+ expect(parseInt(key1)).to.be.closeTo(parseInt(Date.now().toString()), 1000);
// Assert target set options were correctly updated
- const options = wrapper.findAll('option');
+ let options = wrapper.findAll('option');
expect(options[0].element.text).to.equal('1st target set');
expect(options[0].element.value).to.equal('A');
expect(options[1].element.text).to.equal('2nd target set');
expect(options[1].element.value).to.equal('B');
expect(options[2].element.text).to.equal('New target set');
- expect(options[2].element.value).to.match(/\d{12,14}/);
+ expect(options[2].element.value).to.equal(key1)
expect(options[3].element.text).to.equal('[ Create New Target Set ]');
expect(options[3].element.value).to.equal('_new');
expect(options.length).to.equal(4);
// Assert target sets were correctly updated
- targetSets[options[2].element.value] = {
+ targetSets[key1] = {
name: 'New target set',
targets: [],
};
- expect(localStorage.getItem('running-tools.target-sets')).to.equal(JSON.stringify(targetSets));
+ expect(wrapper.vm.targetSets).to.deep.equal(targetSets);
+
+ // Add another target set
+ await wrapper.find('select').setValue('_new');
- // Assert targets-updated event was emitted
- expect(wrapper.emitted()['targets-updated'].length).to.equal(1);
+ // Assert new target set selected (key is unix timestamp in milliseconds)
+ const key2 = wrapper.find('select').element.value;
+ expect(parseInt(key2)).to.be.closeTo(parseInt(Date.now().toString()), 1000);
+ expect(key2).to.not.equal(key1);
+
+ // Assert target set options were correctly updated
+ options = wrapper.findAll('option');
+ expect(options[0].element.text).to.equal('1st target set');
+ expect(options[0].element.value).to.equal('A');
+ expect(options[1].element.text).to.equal('2nd target set');
+ expect(options[1].element.value).to.equal('B');
+ expect(options[2].element.text).to.equal('New target set');
+ expect(options[2].element.value).to.equal(key1)
+ expect(options[3].element.text).to.equal('New target set');
+ expect(options[3].element.value).to.equal(key2);
+ expect(options[4].element.text).to.equal('[ Create New Target Set ]');
+ expect(options[4].element.value).to.equal('_new');
+ expect(options.length).to.equal(5);
+
+ // Assert target sets were correctly updated
+ targetSets[key2] = {
+ name: 'New target set',
+ targets: [],
+ };
+ expect(wrapper.vm.targetSets).to.deep.equal(targetSets);
});
test('Revert event should correctly reset a default target set', async () => {
- // Initialize localStorage
+ // Initialize component
let targetSets = {
'_split_targets': {
name: '1st target set',
targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 3, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 1, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 3, distanceUnit: 'miles' },
],
},
'1234567890123': {
name: '2nd target set',
targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 10, distanceUnit: 'kilometers' },
],
},
};
- localStorage.setItem('running-tools.target-sets', JSON.stringify(targetSets));
-
- // Initialize component
const wrapper = shallowMount(TargetSetSelector, {
propsData: {
- modelValue: '_split_targets',
+ selectedTargetSet: '_split_targets',
+ targetSets,
}
});
@@ -149,42 +166,37 @@ test('Revert event should correctly reset a default target set', async () => {
// Assert target sets were correctly updated
targetSets._split_targets.name = '5K Mile Splits';
targetSets._split_targets.targets[2] = {
- result: 'time',
+ type: 'distance',
distanceValue: 5,
distanceUnit: 'kilometers',
};
- expect(localStorage.getItem('running-tools.target-sets')).to.equal(JSON.stringify(targetSets));
-
- // Assert targets-updated event was emitted
- expect(wrapper.emitted()['targets-updated'].length).to.equal(1);
+ expect(wrapper.vm.targetSets).to.deep.equal(targetSets);
});
test('Revert event should correctly delete a custom target set', async () => {
- // Initialize localStorage
+ // Initialize component
let targetSets = {
'_split_targets': {
name: '1st target set',
targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 3, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 1, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 3, distanceUnit: 'miles' },
],
},
'1234567890123': {
name: '2nd target set',
targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 10, distanceUnit: 'kilometers' },
],
},
};
- localStorage.setItem('running-tools.target-sets', JSON.stringify(targetSets));
-
- // Initialize component
const wrapper = shallowMount(TargetSetSelector, {
propsData: {
- modelValue: '1234567890123',
+ selectedTargetSet: '1234567890123',
+ targetSets,
}
});
@@ -201,36 +213,31 @@ test('Revert event should correctly delete a custom target set', async () => {
// Assert target sets were correctly updated
delete targetSets['1234567890123'];
- expect(localStorage.getItem('running-tools.target-sets')).to.equal(JSON.stringify(targetSets));
-
- // Assert targets-updated event was emitted
- expect(wrapper.emitted()['targets-updated'].length).to.equal(1);
+ expect(wrapper.vm.targetSets).to.deep.equal(targetSets);
});
test('edit button should open target editor with the correct props for default set', async () => {
- // Initialize localStorage
+ // Initialize component
const targetSets = {
'_split_targets': {
name: '5K Mile Splits',
targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 1, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' },
],
},
};
- localStorage.setItem('running-tools.target-sets', JSON.stringify(targetSets));
-
- // Initialize component
const wrapper = shallowMount(TargetSetSelector, {
propsData: {
- modelValue: '_split_targets',
+ selectedTargetSet: '_split_targets',
+ targetSets,
defaultUnitSystem: 'fake-unit-system',
}
});
// Mock showModal function
- wrapper.vm.$refs.dialog.showModal = vi.fn();
+ wrapper.vm.dialogElement.showModal = vi.fn();
// Click edit button
await wrapper.find('button').trigger('click');
@@ -243,29 +250,27 @@ test('edit button should open target editor with the correct props for default s
});
test('edit button should open target editor with the correct props for custom set', async () => {
- // Initialize localStorage
+ // Initialize component
const targetSets = {
'1234567890123': {
name: '2nd target set',
targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 10, distanceUnit: 'kilometers' },
],
},
};
- localStorage.setItem('running-tools.target-sets', JSON.stringify(targetSets));
-
- // Initialize component
const wrapper = shallowMount(TargetSetSelector, {
propsData: {
- modelValue: '1234567890123',
+ selectedTargetSet: '1234567890123',
+ targetSets,
defaultUnitSystem: 'fake-unit-system',
}
});
// Mock showModal function
- wrapper.vm.$refs.dialog.showModal = vi.fn();
+ wrapper.vm.dialogElement.showModal = vi.fn();
// Click edit button
await wrapper.find('button').trigger('click');
@@ -277,48 +282,57 @@ test('edit button should open target editor with the correct props for custom se
expect(targetEditor.vm.defaultUnitSystem).to.equal('fake-unit-system');
});
-test('should reload and sort target set before target editor is opened', async () => {
- // Initialize localStorage
+test('should sort target set after target editor is closed', async () => {
+ // Initialize component
let targetSets = {
'_split_targets': {
name: '5K Mile Splits',
- targets: [
- { result: 'distance', timeValue: 60 },
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 3, distanceUnit: 'miles' },
- ],
+ targets: [],
},
};
- localStorage.setItem('running-tools.target-sets', JSON.stringify(targetSets));
-
- // Initialize component
const wrapper = shallowMount(TargetSetSelector, {
propsData: {
- modelValue: '_split_targets',
+ selectedTargetSet: '_split_targets',
+ targetSets,
}
});
- // Update localStorage
- targetSets._split_targets.name = '5K Mile Splits #2';
- localStorage.setItem('running-tools.target-sets', JSON.stringify(targetSets));
+ // Mock modal close function
+ wrapper.vm.dialogElement.close = vi.fn();
- // Mock showModal function
- wrapper.vm.$refs.dialog.showModal = vi.fn();
-
- // Click edit button
- await wrapper.find('button').trigger('click');
+ // Update targets and trigger close event
+ await wrapper.findComponent({ name: 'target-editor' }).setValue({
+ name: '5K Mile Splits',
+ targets: [
+ { type: 'time', timeValue: 60 },
+ { type: 'distance', distanceValue: 1, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' },
+ { type: 'distance', distanceValue: 3, distanceUnit: 'miles' },
+ ],
+ });
+ await wrapper.findComponent({ name: 'target-editor' }).vm.$emit('close');
// Assert target set was sorted
expect(wrapper.findComponent({ name: 'target-editor' }).vm.modelValue).to.deep.equal({
- name: '5K Mile Splits #2',
+ name: '5K Mile Splits',
targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 3, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- { result: 'distance', timeValue: 60 },
+ { type: 'distance', distanceValue: 1, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 3, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' },
+ { type: 'time', timeValue: 60 },
],
});
});
+
+test('should correctly pass setType prop to TargetEditor', async () => {
+ const wrapper = shallowMount(TargetSetSelector, {
+ propsData: {
+ setType: 'foo'
+ }
+ });
+
+ // Assert target editor props are correct
+ expect(wrapper.findComponent({ name: 'target-editor' }).vm.setType).to.equal('foo');
+});
diff --git a/tests/unit/components/TimeInput.spec.js b/tests/unit/components/TimeInput.spec.js
@@ -42,13 +42,13 @@ test('should emit input event when value changes', async () => {
const wrapper = shallowMount(TimeInput);
// Change value to 1:00:00.00
- await wrapper.setData({ internalValue: 3600 });
+ await wrapper.findAllComponents({ name: 'integer-input' })[0].setValue(1);
// Assert input event was emitted
expect(wrapper.emitted()['update:modelValue']).to.deep.equal([[3600.00]]);
// Change value to 1:00:01.50
- await wrapper.setData({ internalValue: 3601.5 });
+ await wrapper.findComponent({ name: 'decimal-input' }).setValue(1.5);
// Assert another input event was emitted
expect(wrapper.emitted()['update:modelValue']).to.deep.equal([[3600.00], [3601.50]]);
diff --git a/tests/unit/utils/calculators.spec.js b/tests/unit/utils/calculators.spec.js
@@ -0,0 +1,201 @@
+import { test, expect } from 'vitest';
+import * as calculatorUtils from '@/utils/calculators';
+
+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);
+});
+
+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:42 / 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.77');
+ expect(result.pace).to.equal('5:32 / mi');
+ expect(result.result).to.equal('value');
+ expect(result.sort).to.be.closeTo(1031.77, 0.01);
+});
+
+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);
+});
+
+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 = {
+ 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.35');
+ 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 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 = {
+ 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/format.spec.js b/tests/unit/utils/format.spec.js
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'vitest';
-import formatUtils from '@/utils/format';
+import * as formatUtils from '@/utils/format';
describe('formatNumber method', () => {
test('should correctly format number when padding is not 0', () => {
diff --git a/tests/unit/utils/paces.spec.js b/tests/unit/utils/paces.spec.js
@@ -1,20 +1,14 @@
import { describe, test, expect } from 'vitest';
-import paces from '@/utils/paces';
+import * as paces from '@/utils/paces';
-describe('getPace method', () => {
- test('2 meters in 6 seconds should equal 3 seconds per meter', () => {
- expect(paces.getPace(2, 6)).to.equal(3);
+describe('calculateTime method', () => {
+ test('1 meters in 3 seconds should equal 2 meters in 6 seconds', () => {
+ expect(paces.calculateTime(1, 3, 2)).to.equal(6);
});
});
-describe('getTime method', () => {
- test('2 meters at 3 seconds per meter should equal 6 seconds', () => {
- expect(paces.getTime(3, 2)).to.equal(6);
- });
-});
-
-describe('getDistance method', () => {
- test('6 seconds at 3 seconds per meter should equal 2 meters', () => {
- expect(paces.getDistance(3, 6)).to.equal(2);
+describe('calculateDistance method', () => {
+ test('1 meter in 3 seconds should equal 2 meters in 6 seconds', () => {
+ expect(paces.calculateDistance(3, 1, 6)).to.equal(2);
});
});
diff --git a/tests/unit/utils/races.spec.js b/tests/unit/utils/races.spec.js
@@ -1,172 +1,166 @@
import { describe, test, expect } from 'vitest';
-import raceUtils from '@/utils/races';
+import * as raceUtils from '@/utils/races';
-describe('PurdyPointsModel', () => {
- describe('getPurdyPoints method', () => {
- test('Result should be approximately correct', () => {
- const result = raceUtils.PurdyPointsModel.getPurdyPoints(5000, 1200);
- expect(result).to.be.closeTo(454, 1);
+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('PredictTime method', () => {
+ describe('Purdy Points Model', () => {
test('Predictions should be approximately correct', () => {
- const result = raceUtils.PurdyPointsModel.predictTime(5000, 1200, 10000);
+ 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.PurdyPointsModel.predictTime(5000, 1200, 5000);
+ const result = raceUtils.predictTime(5000, 1200, 5000, 'PurdyPointsModel');
expect(result).to.be.closeTo(1200, 0.001);
});
});
- describe('PredictDistance method', () => {
+ describe('VO2 Max Model', () => {
test('Predictions should be approximately correct', () => {
- const result = raceUtils.PurdyPointsModel.predictDistance(1200, 5000, 2490);
- expect(result).to.be.closeTo(10000, 10);
+ 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.PurdyPointsModel.predictDistance(1200, 5000, 1200);
- expect(result).to.be.closeTo(5000, 0.001);
- });
- });
-});
-
-describe('VO2MaxModel', () => {
- describe('getVO2 method', () => {
- test('Result should be approximately correct', () => {
- const result = raceUtils.VO2MaxModel.getVO2(5000, 1200);
- expect(result).to.be.closeTo(47.4, 0.1);
+ const result = raceUtils.predictTime(5000, 1200, 5000, 'VO2MaxModel');
+ expect(result).to.be.closeTo(1200, 0.001);
});
});
- describe('getVO2Percentage method', () => {
- test('Result should be approximately correct', () => {
- const result = raceUtils.VO2MaxModel.getVO2Percentage(660);
- expect(result).to.be.closeTo(1, 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);
});
- });
- describe('getVO2Max method', () => {
- test('Result should be approximately correct', () => {
- const result = raceUtils.VO2MaxModel.getVO2Max(5000, 1200);
- expect(result).to.be.closeTo(49.8, 0.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('PredictTime method', () => {
+ describe('Riegel Model', () => {
test('Predictions should be approximately correct', () => {
- const result = raceUtils.VO2MaxModel.predictTime(5000, 1200, 10000);
- expect(result).to.be.closeTo(2488, 1);
+ 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.VO2MaxModel.predictTime(5000, 1200, 5000);
+ const result = raceUtils.predictTime(5000, 1200, 5000, 'RiegelModel');
expect(result).to.be.closeTo(1200, 0.001);
});
});
+});
- describe('PredictDistance method', () => {
- test('Predictions should be approximately correct', () => {
- const result = raceUtils.VO2MaxModel.predictDistance(1200, 5000, 2488);
+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.VO2MaxModel.predictDistance(1200, 5000, 1200);
+ const result = raceUtils.predictDistance(1200, 5000, 1200, 'AverageModel');
expect(result).to.be.closeTo(5000, 0.001);
});
});
-});
-describe('CameronModel', () => {
- describe('PredictTime method', () => {
+ describe('Purdy Points Model', () => {
test('Predictions should be approximately correct', () => {
- const result = raceUtils.CameronModel.predictTime(5000, 1200, 10000);
- expect(result).to.be.closeTo(2500, 1);
+ 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.CameronModel.predictTime(5000, 1200, 5000);
- expect(result).to.be.closeTo(1200, 0.001);
+ const result = raceUtils.predictDistance(1200, 5000, 1200, 'PurdyPointsModel');
+ expect(result).to.be.closeTo(5000, 0.001);
});
});
- describe('PredictDistance method', () => {
+ describe('VO2 Max Model', () => {
test('Predictions should be approximately correct', () => {
- const result = raceUtils.CameronModel.predictDistance(1200, 5000, 2500);
+ 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.CameronModel.predictDistance(1200, 5000, 1200);
+ const result = raceUtils.predictDistance(1200, 5000, 1200, 'VO2MaxModel');
expect(result).to.be.closeTo(5000, 0.001);
});
});
-});
-describe('RiegelModel', () => {
- describe('PredictTime method', () => {
+ describe('Cameron Model', () => {
test('Predictions should be approximately correct', () => {
- const result = raceUtils.RiegelModel.predictTime(5000, 1200, 10000);
- expect(result).to.be.closeTo(2502, 1);
+ 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.RiegelModel.predictTime(5000, 1200, 5000);
- expect(result).to.be.closeTo(1200, 0.001);
+ const result = raceUtils.predictDistance(1200, 5000, 1200, 'CameronModel');
+ expect(result).to.be.closeTo(5000, 0.001);
});
});
- describe('PredictDistance method', () => {
+ describe('Riegel Model', () => {
test('Predictions should be approximately correct', () => {
- const result = raceUtils.RiegelModel.predictDistance(1200, 5000, 2502);
+ 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.RiegelModel.predictDistance(1200, 5000, 1200);
+ const result = raceUtils.predictDistance(1200, 5000, 1200, 'RiegelModel');
expect(result).to.be.closeTo(5000, 0.001);
});
});
});
-describe('AverageModel', () => {
- describe('PredictTime method', () => {
- test('Predictions should be correct', () => {
- const riegel = raceUtils.RiegelModel.predictTime(5000, 1200, 10000);
- const cameron = raceUtils.CameronModel.predictTime(5000, 1200, 10000);
- const purdyPoints = raceUtils.PurdyPointsModel.predictTime(5000, 1200, 10000);
- const vo2Max = raceUtils.VO2MaxModel.predictTime(5000, 1200, 10000);
- const expected = (riegel + cameron + purdyPoints + vo2Max) / 4;
-
- const result = raceUtils.AverageModel.predictTime(5000, 1200, 10000);
- expect(result).to.equal(expected);
- });
-
- test('Should predict identical times for itentical distances', () => {
- const result = raceUtils.AverageModel.predictTime(5000, 1200, 5000);
- expect(result).to.be.closeTo(1200, 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('PredictDistance method', () => {
- test('Predictions should be correct', () => {
- const riegel = raceUtils.RiegelModel.predictTime(5000, 1200, 10000);
- const cameron = raceUtils.CameronModel.predictTime(5000, 1200, 10000);
- const purdyPoints = raceUtils.PurdyPointsModel.predictTime(5000, 1200, 10000);
- const vo2Max = raceUtils.VO2MaxModel.predictTime(5000, 1200, 10000);
- const expected = (riegel + cameron + purdyPoints + vo2Max) / 4;
+describe('getVO2Percentage method', () => {
+ test('Result should be approximately correct', () => {
+ const result = raceUtils.getVO2Percentage(660);
+ expect(result).to.be.closeTo(1, 0.001);
+ });
+});
- const result = raceUtils.AverageModel.predictDistance(1200, 5000, expected);
- expect(result).to.be.closeTo(10000, 10);
- });
+describe('getVO2Max method', () => {
+ test('Result should be approximately correct', () => {
+ const result = raceUtils.getVO2Max(5000, 1200);
+ expect(result).to.be.closeTo(49.8, 0.1);
+ });
+});
- test('Should predict identical times for itentical distances', () => {
- const result = raceUtils.AverageModel.predictDistance(1200, 5000, 1200);
- expect(result).to.be.closeTo(5000, 0.001);
- });
+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/targets.spec.js b/tests/unit/utils/targets.spec.js
@@ -1,18 +1,18 @@
import { describe, test, expect } from 'vitest';
-import targets from '@/utils/targets';
+import * as targets from '@/utils/targets';
describe('sort method', () => {
test('should correctly sort targets', () => {
// Initialize unsorted and sorted targets
const input = [
- { time: 60, result: 'distance' },
- { distanceUnit: 'kilometers', distanceValue: 5, result: 'time' },
- { distanceUnit: 'miles', distanceValue: 3, result: 'time' },
+ { time: 60, type: 'time' },
+ { distanceUnit: 'kilometers', distanceValue: 5, type: 'distance' },
+ { distanceUnit: 'miles', distanceValue: 3, type: 'distance' },
];
const expected = [
- { distanceUnit: 'miles', distanceValue: 3, result: 'time' },
- { distanceUnit: 'kilometers', distanceValue: 5, result: 'time' },
- { time: 60, result: 'distance' },
+ { distanceUnit: 'miles', distanceValue: 3, type: 'distance' },
+ { distanceUnit: 'kilometers', distanceValue: 5, type: 'distance' },
+ { time: 60, type: 'time' },
];
// Assert sort method sorts targets correctly
diff --git a/tests/unit/utils/units.spec.js b/tests/unit/utils/units.spec.js
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'vitest';
-import units from '@/utils/units';
+import * as units from '@/utils/units';
describe('convertTime method', () => {
test('90 seconds should equal 1.5 minutes', () => {
diff --git a/tests/unit/views/BatchCalculator.spec.js b/tests/unit/views/BatchCalculator.spec.js
@@ -0,0 +1,419 @@
+import { beforeEach, test, expect } from 'vitest';
+import { shallowMount } from '@vue/test-utils';
+import BatchCalculator from '@/views/BatchCalculator.vue';
+
+beforeEach(() => {
+ localStorage.clear();
+})
+
+test('should load input from localStorage', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.batch-calculator-input', JSON.stringify({
+ distanceValue: 2,
+ distanceUnit: 'miles',
+ time: 600,
+ }));
+
+ // Initialize component
+ const wrapper = shallowMount(BatchCalculator);
+
+ // Assert options loaded
+ expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({
+ distanceValue: 2,
+ distanceUnit: 'miles',
+ time: 600,
+ });
+});
+
+test('should save input to localStorage when modified', async () => {
+ // Initialize component
+ const wrapper = shallowMount(BatchCalculator);
+
+ // Update input pace
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 2,
+ distanceUnit: 'miles',
+ time: 600,
+ });
+
+ // Assert input saved
+ expect(localStorage.getItem('running-tools.batch-calculator-input')).to.equal(JSON.stringify({
+ distanceValue: 2,
+ distanceUnit: 'miles',
+ time: 600,
+ }));
+});
+
+test('should load batch options from localStorage', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.batch-calculator-options', JSON.stringify({
+ calculator: 'race',
+ increment: 32,
+ rows: 15,
+ }));
+
+ // Initialize component
+ const wrapper = shallowMount(BatchCalculator);
+
+ // Assert options loaded
+ expect(wrapper.find('select[aria-label="Calculator"]').element.value).to.equal('race');
+ expect(wrapper.findComponent({ name: 'time-input' }).vm.modelValue).to.equal(32);
+ expect(wrapper.findComponent({ name: 'integer-input' }).vm.modelValue).to.equal(15);
+});
+
+test('should save batch options to localStorage when modified', async () => {
+ // Initialize component
+ const wrapper = shallowMount(BatchCalculator);
+
+ // Update active calculator
+ await wrapper.find('select[aria-label="Calculator"]').setValue('race');
+
+ // Assert options saved
+ expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal(JSON.stringify({
+ calculator: 'race',
+ increment: 15,
+ rows: 20,
+ }));
+
+ // Update increment value
+ await wrapper.findComponent({ name: 'time-input' }).setValue(32);
+
+ // Assert options saved
+ expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal(JSON.stringify({
+ calculator: 'race',
+ increment: 32,
+ rows: 20,
+ }));
+
+ // Update number of rows
+ await wrapper.findComponent({ name: 'integer-input' }).setValue(15);
+
+ // Assert options saved
+ expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal(JSON.stringify({
+ calculator: 'race',
+ increment: 32,
+ rows: 15,
+ }));
+});
+
+test('should load default units setting from localStorage', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.default-unit-system', '"metric"');
+
+ // Initialize component
+ const wrapper = shallowMount(BatchCalculator);
+
+ // Assert default units setting loaded
+ expect(wrapper.find('select[aria-label="Default units"]').element.value).to.equal('metric');
+ expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.defaultUnitSystem)
+ .to.equal('metric');
+});
+
+test('should save default units setting from localStorage when modified', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.default-unit-system', '"metric"');
+
+ // Initialize component
+ const wrapper = shallowMount(BatchCalculator);
+
+ // Change default units setting
+ await wrapper.find('select[aria-label="Default units"]').setValue('imperial');
+
+ // New default units should be saved to localStorage
+ expect(localStorage.getItem('running-tools.default-unit-system')).to.equal('"imperial"');
+});
+
+test('should load selected target set from localStorage', async () => {
+ // Initialize localStorage
+ const selectedTargetSets = [
+ {
+ name: 'Pace targets #1',
+ targets: [
+ { type: 'distance', distanceValue: 1, distanceUnit: 'miles' },
+ ],
+ },
+ {
+ name: 'Race targets #1',
+ targets: [
+ { type: 'distance', distanceValue: 3, distanceUnit: 'miles' },
+ ],
+ },
+ {
+ name: 'Workout targets #1',
+ targets: [
+ {
+ type: 'distance', distanceValue: 5, distanceUnit: 'miles',
+ splitValue: 1, splitUnit: 'miles'
+ },
+ ],
+ },
+ ];
+ localStorage.setItem('running-tools.pace-calculator-target-sets', JSON.stringify({
+ 'A': selectedTargetSets[0],
+ 'B': {
+ name: 'Pace targets #2',
+ targets: [
+ { type: 'distance', distanceValue: 2, distanceUnit: 'miles' },
+ ],
+ }
+ }));
+ localStorage.setItem('running-tools.pace-calculator-target-set', '"A"');
+ localStorage.setItem('running-tools.race-calculator-target-sets', JSON.stringify({
+ 'C': selectedTargetSets[1],
+ 'D': {
+ name: 'Race targets #2',
+ targets: [
+ { type: 'distance', distanceValue: 4, distanceUnit: 'miles' },
+ ],
+ }
+ }));
+ localStorage.setItem('running-tools.race-calculator-target-set', '"C"');
+ localStorage.setItem('running-tools.workout-calculator-target-sets', JSON.stringify({
+ 'E': selectedTargetSets[2],
+ 'F': {
+ name: 'Workout targets #2',
+ targets: [
+ { type: 'distance', distanceValue: 6, distanceUnit: 'miles' },
+ ],
+ }
+ }));
+ localStorage.setItem('running-tools.workout-calculator-target-set', '"E"');
+
+ // Initialize component
+ const wrapper = shallowMount(BatchCalculator);
+
+ // Assert selected pace target set is loaded
+ await wrapper.find('select[aria-label="Calculator"]').setValue('pace');
+ expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet).to.equal('A');
+ expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets)
+ .to.deep.equal(selectedTargetSets[0].targets);
+
+ // Assert selected race target set is loaded
+ await wrapper.find('select[aria-label="Calculator"]').setValue('race');
+ expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet).to.equal('C');
+ expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets)
+ .to.deep.equal(selectedTargetSets[1].targets);
+
+ // Assert selected workout target set is loaded
+ await wrapper.find('select[aria-label="Calculator"]').setValue('workout');
+ expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet).to.equal('E');
+ expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets)
+ .to.deep.equal(selectedTargetSets[2].targets);
+});
+
+test('should save selected target set to localStorage when modified', async () => {
+ // Initialize localStorage
+ const selectedTargetSets = [
+ {
+ name: 'Pace targets #1',
+ targets: [
+ { type: 'distance', distanceValue: 1, distanceUnit: 'miles' },
+ ],
+ },
+ {
+ name: 'Race targets #1',
+ targets: [
+ { type: 'distance', distanceValue: 3, distanceUnit: 'miles' },
+ ],
+ },
+ {
+ name: 'Workout targets #1',
+ targets: [
+ {
+ type: 'distance', distanceValue: 5, distanceUnit: 'miles',
+ splitValue: 1, splitUnit: 'miles'
+ },
+ ],
+ },
+ ];
+ localStorage.setItem('running-tools.pace-calculator-target-sets', JSON.stringify({
+ 'A': selectedTargetSets[0],
+ 'B': {
+ name: 'Pace targets #2',
+ targets: [
+ { type: 'distance', distanceValue: 2, distanceUnit: 'miles' },
+ ],
+ }
+ }));
+ localStorage.setItem('running-tools.pace-calculator-target-set', '"B"');
+ localStorage.setItem('running-tools.race-calculator-target-sets', JSON.stringify({
+ 'C': selectedTargetSets[1],
+ 'D': {
+ name: 'Race targets #2',
+ targets: [
+ { type: 'distance', distanceValue: 4, distanceUnit: 'miles' },
+ ],
+ }
+ }));
+ localStorage.setItem('running-tools.race-calculator-target-set', '"D"');
+ localStorage.setItem('running-tools.workout-calculator-target-sets', JSON.stringify({
+ 'E': selectedTargetSets[2],
+ 'F': {
+ name: 'Workout targets #2',
+ targets: [
+ { type: 'distance', distanceValue: 6, distanceUnit: 'miles' },
+ ],
+ }
+ }));
+ localStorage.setItem('running-tools.workout-calculator-target-set', '"F"');
+
+ // Initialize component
+ const wrapper = shallowMount(BatchCalculator);
+
+ // Update selected pace target set
+ await wrapper.find('select[aria-label="Calculator"]').setValue('pace');
+ await wrapper.findComponent({ name: 'target-set-selector' }).setValue('A', 'selectedTargetSet');
+ expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets)
+ .to.deep.equal(selectedTargetSets[0].targets);
+ expect(localStorage.getItem('running-tools.pace-calculator-target-set')).to.equal('"A"');
+
+ // Assert selected race target set is loaded
+ await wrapper.find('select[aria-label="Calculator"]').setValue('race');
+ await wrapper.findComponent({ name: 'target-set-selector' }).setValue('C', 'selectedTargetSet');
+ expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets)
+ .to.deep.equal(selectedTargetSets[1].targets);
+ expect(localStorage.getItem('running-tools.race-calculator-target-set')).to.equal('"C"');
+
+ // Assert selected workout target set is loaded
+ await wrapper.find('select[aria-label="Calculator"]').setValue('workout');
+ await wrapper.findComponent({ name: 'target-set-selector' }).setValue('E', 'selectedTargetSet');
+ expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets)
+ .to.deep.equal(selectedTargetSets[2].targets);
+ expect(localStorage.getItem('running-tools.workout-calculator-target-set')).to.equal('"E"');
+});
+
+test('should load advanced model options from localStorage', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.race-calculator-options', JSON.stringify({
+ model: 'PurdyPointsModel',
+ riegelExponent: 1.2,
+ }));
+ localStorage.setItem('running-tools.workout-calculator-options', JSON.stringify({
+ model: 'RiegelModel',
+ riegelExponent: 1.1,
+ }));
+
+ // Initialize component
+ const wrapper = shallowMount(BatchCalculator);
+
+ // Assert race prediction options are loaded
+ await wrapper.find('select[aria-label="Calculator"]').setValue('race');
+ expect(wrapper.findComponent({ name: 'RaceOptions' }).vm.modelValue).to.deep.equal({
+ model: 'PurdyPointsModel',
+ riegelExponent: 1.2,
+ });
+
+ // Assert workout prediction options are loaded
+ await wrapper.find('select[aria-label="Calculator"]').setValue('workout');
+ expect(wrapper.findComponent({ name: 'RaceOptions' }).vm.modelValue).to.deep.equal({
+ model: 'RiegelModel',
+ riegelExponent: 1.1,
+ });
+});
+
+test('should pass correct input props to DoubleOutputTable', async () => {
+ // Initialize component
+ const wrapper = shallowMount(BatchCalculator);
+
+ // Assert that initial props are correct
+ expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputDistance).to.deep.equal({
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ });
+ expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputTimes).to.deep.equal([
+ 1200, 1215, 1230, 1245, 1260, 1275, 1290, 1305, 1320, 1335,
+ 1350, 1365, 1380, 1395, 1410, 1425, 1440, 1455, 1470, 1485,
+ ]);
+
+ // Change input pace
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 2,
+ distanceUnit: 'miles',
+ time: 600,
+ });
+
+ // Assert that the props are updated
+ expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputDistance).to.deep.equal({
+ distanceValue: 2,
+ distanceUnit: 'miles',
+ });
+ expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputTimes).to.deep.equal([
+ 600, 615, 630, 645, 660, 675, 690, 705, 720, 735,
+ 750, 765, 780, 795, 810, 825, 840, 855, 870, 885,
+ ]);
+
+ // Change increment value
+ await wrapper.findComponent({ name: 'time-input' }).setValue(10);
+
+ // Assert that the props are updated
+ expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputDistance).to.deep.equal({
+ distanceValue: 2,
+ distanceUnit: 'miles',
+ });
+ expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputTimes).to.deep.equal([
+ 600, 610, 620, 630, 640, 650, 660, 670, 680, 690,
+ 700, 710, 720, 730, 740, 750, 760, 770, 780, 790,
+ ]);
+
+ // Change number of rows
+ await wrapper.findComponent({ name: 'integer-input' }).setValue(15);
+
+ // Assert that the props are updated
+ expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputDistance).to.deep.equal({
+ distanceValue: 2,
+ distanceUnit: 'miles',
+ });
+ expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputTimes).to.deep.equal([
+ 600, 610, 620, 630, 640, 650, 660, 670, 680, 690, 700, 710, 720, 730, 740,
+ ]);
+});
+
+test('should correctly calculate outputs', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.race-calculator-options', JSON.stringify({
+ model: 'PurdyPointsModel',
+ riegelExponent: 1.2,
+ }));
+ localStorage.setItem('running-tools.workout-calculator-options', JSON.stringify({
+ model: 'RiegelModel',
+ riegelExponent: 1.1,
+ }));
+ localStorage.setItem('running-tools.default-unit-system', '"imperial"');
+
+ // Initialize component
+ const wrapper = shallowMount(BatchCalculator);
+ const input = { distanceValue: 2, distanceUnit: 'miles', time: 600 };
+
+ // Assert pace outputs are calculated correctly
+ await wrapper.find('select[aria-label="Calculator"]').setValue('pace');
+ let calculate = wrapper.findComponent({ name: 'double-output-table' }).vm.calculateResult;
+ expect(calculate(input, { type: 'time', time: 3600 })).to.deep.equal({
+ key: '12.00 mi',
+ value: '1:00:00',
+ pace: '5:00 / mi',
+ sort: 3600,
+ result: 'key',
+ });
+
+ // Assert race outputs are calculated correctly
+ await wrapper.find('select[aria-label="Calculator"]').setValue('race');
+ calculate = wrapper.findComponent({ name: 'double-output-table' }).vm.calculateResult;
+ expect(calculate(input, { type: 'time', time: 3600 })).to.deep.equal({
+ key: '10.93 mi',
+ value: '1:00:00',
+ pace: '5:29 / mi',
+ sort: 3600,
+ result: 'key',
+ });
+
+ // Assert workout outputs are calculated correctly
+ await wrapper.find('select[aria-label="Calculator"]').setValue('workout');
+ calculate = wrapper.findComponent({ name: 'double-output-table' }).vm.calculateResult;
+ const workoutTarget = { type: 'time', time: 3600, splitValue: 1, splitUnit: 'miles' };
+ const result = calculate(input, workoutTarget);
+ expect(result.key).to.equal('1 mi @ 1:00:00');
+ expect(result.value).to.equal('5:53.07');
+ expect(result.pace).to.equal('');
+ expect(result.sort).to.be.closeTo(353.07, 0.01);
+ expect(result.result).to.equal('value');
+});
diff --git a/tests/unit/views/PaceCalculator.spec.js b/tests/unit/views/PaceCalculator.spec.js
@@ -1,6 +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';
beforeEach(() => {
localStorage.clear();
@@ -11,59 +12,57 @@ test('should correctly calculate time results', async () => {
const wrapper = shallowMount(PaceCalculator);
// Enter input pace data
- await wrapper.findComponent({ name: 'decimal-input' }).setValue(1);
- await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers');
- await wrapper.findComponent({ name: 'time-input' }).setValue(100);
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 1,
+ distanceUnit: 'kilometers',
+ time: 100,
+ });
// Calculate result
- const calculateResult = wrapper.findComponent({ name: 'simple-target-table' }).vm.calculateResult;
+ const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult;
const result = calculateResult({
distanceValue: 20,
distanceUnit: 'meters',
- result: 'time',
+ type: 'distance',
});
// Assert result is correct
expect(result).to.deep.equal({
- distanceValue: 20,
- distanceUnit: 'meters',
- time: 2,
- result: 'time',
+ key: '20 m',
+ value: '0:02.00',
+ pace: '2:41 / mi',
+ result: 'value',
+ sort: 2,
});
});
test('should correctly calculate distance results according to default units setting', async () => {
// Initialize component
- const wrapper = shallowMount(PaceCalculator, {
- data() {
- return {
- defaultUnitSystem: 'metric',
- };
- },
- });
+ const wrapper = shallowMount(PaceCalculator);
// Enter input pace data
- await wrapper.findComponent({ name: 'decimal-input' }).setValue(2);
- await wrapper.find('select[aria-label="Input distance unit"]').setValue('miles');
- await wrapper.findComponent({ name: 'time-input' }).setValue(1200);
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 2,
+ distanceUnit: 'miles',
+ time: 1200,
+ });
+
+ // Set default units
+ await wrapper.find('select[aria-label="Default units"]').setValue('metric');
// Get calculate result function
- const calculateResult = wrapper.findComponent({ name: 'simple-target-table' }).vm.calculateResult;
+ const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult;
// Assert result is correct
- let result = calculateResult({ result: 'distance', time: 600 });
- expect(result.distanceValue).to.be.closeTo(1.609, 0.001);
- expect(result.distanceUnit).to.equal('kilometers');
+ let result = calculateResult({ type: 'time', time: 600 });
+ expect(result.key).to.equal('1.61 km');
// Change default units
await wrapper.find('select[aria-label="Default units"]').setValue('imperial');
// Assert result is correct
- result = calculateResult({ result: 'distance', time: 600 });
- expect(result.distanceValue).to.equal(1);
- expect(result.distanceUnit).to.equal('miles');
- expect(result.time).to.equal(600);
- expect(result.result).to.equal('distance');
+ result = calculateResult({ type: 'time', time: 600 });
+ expect(result.key).to.equal('1.00 mi');
});
test('should not show paces in results table', async () => {
@@ -71,56 +70,47 @@ test('should not show paces in results table', async () => {
const wrapper = shallowMount(PaceCalculator);
// Assert paces are not shown in results table
- expect(wrapper.findComponent({ name: 'simple-target-table' }).vm.showPace).to.equal(false);
+ expect(wrapper.findComponent({ name: 'single-output-table' }).vm.showPace).to.equal(false);
});
test('should correctly handle null target set', async () => {
// Initialize component
- const paceTargets = [
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- ];
- const wrapper = shallowMount(PaceCalculator, {
- data() {
- return {
- targetSets: {
- '_pace_targets': {
- name: 'Common pace targets',
- targets: paceTargets,
- },
- '_race_targets': null,
- },
- };
- },
- });
+ const wrapper = shallowMount(PaceCalculator);
// Switch to invalid target set
- await wrapper.findComponent({ name: 'target-set-selector' }).setValue('_race_targets');
+ await wrapper.findComponent({ name: 'target-set-selector' })
+ .setValue('does_not_exist', 'selectedTargetSet');
- // Assert empty array passed to SimpleTargetTable component
- expect(wrapper.findComponent({ name: 'simple-target-table' }).vm.targets).to.deep.equal([]);
+ // Assert empty array passed to SingleOutputTable component
+ expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets).to.deep.equal([]);
// Switch to valid target set
- await wrapper.findComponent({ name: 'target-set-selector' }).setValue('_pace_targets');
+ await wrapper.findComponent({ name: 'target-set-selector' })
+ .setValue('_pace_targets', 'selectedTargetSet');
- // Assert valid targets passed to SimpleTargetTable component
- expect(wrapper.findComponent({ name: 'simple-target-table' }).vm.targets).to.deep.equal(paceTargets);
+ // Assert valid targets passed to SingleOutputTable component
+ const paceTargets = defaultTargetSets._pace_targets.targets;
+ expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets)
+ .to.deep.equal(paceTargets);
});
test('should load input pace from localStorage', async () => {
// Initialize localStorage
- localStorage.setItem('running-tools.pace-calculator-input-distance', '1');
- localStorage.setItem('running-tools.pace-calculator-input-unit', '"miles"');
- localStorage.setItem('running-tools.pace-calculator-input-time', '600');
+ localStorage.setItem('running-tools.pace-calculator-input', JSON.stringify({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ }));
// Initialize component
const wrapper = shallowMount(PaceCalculator);
// Assert data loaded
- expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1);
- expect(wrapper.find('select[aria-label="Input distance unit"]').element.value).to.equal('miles');
- expect(wrapper.findComponent({ name: 'time-input' }).vm.modelValue).to.equal(600);
+ expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ });
});
test('should save input pace to localStorage', async () => {
@@ -128,50 +118,51 @@ test('should save input pace to localStorage', async () => {
const wrapper = shallowMount(PaceCalculator);
// Enter input pace data
- await wrapper.findComponent({ name: 'decimal-input' }).setValue(1);
- await wrapper.find('select[aria-label="Input distance unit"]').setValue('miles');
- await wrapper.findComponent({ name: 'time-input' }).setValue(600);
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ });
// Assert data saved to localStorage
- expect(localStorage.getItem('running-tools.pace-calculator-input-distance')).to.equal('1');
- expect(localStorage.getItem('running-tools.pace-calculator-input-unit')).to.equal('"miles"');
- expect(localStorage.getItem('running-tools.pace-calculator-input-time')).to.equal('600');
+ expect(localStorage.getItem('running-tools.pace-calculator-input')).to.equal(JSON.stringify({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ }));
});
test('should load selected target set from localStorage', async () => {
// Initialize localStorage
- localStorage.setItem('running-tools.pace-calculator-target-set', '"_race_targets"');
+ const targetSet2 = {
+ name: 'Pace targets #2',
+ targets: [
+ { type: 'distance', distanceValue: 1, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' },
+ ],
+ };
+ localStorage.setItem('running-tools.pace-calculator-target-sets', JSON.stringify({
+ '_pace_targets': {
+ name: 'Pace targets #1',
+ targets: [
+ { type: 'distance', distanceValue: 400, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 800, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 1600, distanceUnit: 'meters' },
+ ],
+ },
+ 'B': targetSet2,
+ }));
+ localStorage.setItem('running-tools.pace-calculator-target-set', '"B"');
// Initialize component
- const raceTargets = [
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 3, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 6, distanceUnit: 'kilometers' },
- ];
- const wrapper = shallowMount(PaceCalculator, {
- data() {
- return {
- targetSets: {
- '_pace_targets': {
- name: 'Common pace targets',
- targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- ],
- },
- '_race_targets': {
- name: 'Common race targets',
- targets: raceTargets,
- },
- },
- };
- },
- });
+ const wrapper = shallowMount(PaceCalculator);
// Assert selection is loaded
- expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.modelValue).to.equal('_race_targets');
- expect(wrapper.findComponent({ name: 'simple-target-table' }).vm.targets).to.deep.equal(raceTargets);
+ expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet)
+ .to.equal('B');
+ expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets)
+ .to.deep.equal(targetSet2.targets);
});
test('should save selected target set to localStorage when modified', async () => {
@@ -179,23 +170,20 @@ test('should save selected target set to localStorage when modified', async () =
const wrapper = shallowMount(PaceCalculator);
// Select a new target set
- await wrapper.findComponent({ name: 'target-set-selector' }).setValue('_race_targets');
+ await wrapper.findComponent({ name: 'target-set-selector' })
+ .setValue('B', 'selectedTargetSet');
// New selected target set should be saved to localStorage
- expect(localStorage.getItem('running-tools.pace-calculator-target-set')).to.equal('"_race_targets"');
+ expect(localStorage.getItem('running-tools.pace-calculator-target-set'))
+ .to.equal('"B"');
});
test('should save default units setting to localStorage when modified', async () => {
// Initialize component
- const wrapper = shallowMount(PaceCalculator, {
- data() {
- return {
- defaultUnitSystem: 'metric',
- };
- },
- });
+ const wrapper = shallowMount(PaceCalculator);
// Change default units
+ await wrapper.find('select[aria-label="Default units"]').setValue('metric');
await wrapper.find('select[aria-label="Default units"]').setValue('imperial');
// New default units should be saved to localStorage
diff --git a/tests/unit/views/RaceCalculator.spec.js b/tests/unit/views/RaceCalculator.spec.js
@@ -1,6 +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';
beforeEach(() => {
localStorage.clear();
@@ -10,60 +11,64 @@ test('should correctly predict race times', async () => {
// Initialize component
const wrapper = shallowMount(RaceCalculator);
- // Enter input pace data
- await wrapper.findComponent({ name: 'decimal-input' }).setValue(5);
- await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers');
- await wrapper.findComponent({ name: 'time-input' }).setValue(1200);
+ // Enter input race data
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ });
// Calculate result
- const calculateResult = wrapper.findComponent({ name: 'simple-target-table' }).vm.calculateResult;
+ const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult;
const result = calculateResult({
distanceValue: 10,
distanceUnit: 'kilometers',
- result: 'time',
+ type: 'distance',
});
// Assert result is correct
- expect(result.time).to.be.closeTo(2495, 1);
- expect(result.distanceValue).to.equal(10);
- expect(result.distanceUnit).to.equal('kilometers');
- expect(result.result).to.equal('time');
+ expect(result.key).to.equal('10 km');
+ expect(result.value).to.equal('41:34.80');
+ expect(result.pace).to.equal('6:42 / mi');
+ expect(result.result).to.equal('value');
+ expect(result.sort).to.be.closeTo(2494.80, 0.01);
});
test('should correctly calculate distance results according to default units setting', async () => {
// Initialize component
- const wrapper = shallowMount(RaceCalculator, {
- data() {
- return {
- defaultUnitSystem: 'metric',
- };
- },
+ const wrapper = shallowMount(RaceCalculator);
+
+ // Enter input race data
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
});
- // Enter input pace data
- await wrapper.findComponent({ name: 'decimal-input' }).setValue(5);
- await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers');
- await wrapper.findComponent({ name: 'time-input' }).setValue(1200);
+ // Set default units
+ await wrapper.find('select[aria-label="Default units"]').setValue('metric');
// Get calculate result function
- const calculateResult = wrapper.findComponent({ name: 'simple-target-table' }).vm.calculateResult;
+ const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult;
// Assert result is correct
- let result = calculateResult({ result: 'distance', time: 2495 });
- expect(result.distanceValue).to.be.closeTo(10, 0.01);
- expect(result.distanceUnit).to.equal('kilometers');
- expect(result.time).to.equal(2495);
- expect(result.result).to.equal('distance');
+ let result = calculateResult({ type: 'time', time: 2495 });
+ expect(result.key).to.equal('10.00 km');
+ expect(result.value).to.equal('41:35');
+ expect(result.pace).to.equal('4:09 / km');
+ expect(result.result).to.equal('key');
+ expect(result.sort).to.equal(2495);
// Change default units
await wrapper.find('select[aria-label="Default units"]').setValue('imperial');
// Assert result is correct
- result = calculateResult({ result: 'distance', time: 2495 });
- expect(result.distanceValue).to.be.closeTo(6.214, 0.01);
- expect(result.distanceUnit).to.equal('miles');
- expect(result.time).to.equal(2495);
- expect(result.result).to.equal('distance');
+ result = calculateResult({ type: 'time', time: 2495 });
+ expect(result.key).to.equal('6.21 mi');
+ expect(result.value).to.equal('41:35');
+ expect(result.pace).to.equal('6:41 / mi');
+ expect(result.result).to.equal('key');
+ expect(result.sort).to.equal(2495);
});
test('should show paces in results table', async () => {
@@ -71,51 +76,40 @@ test('should show paces in results table', async () => {
const wrapper = shallowMount(RaceCalculator);
// Assert paces are shown in results table
- expect(wrapper.findComponent({ name: 'simple-target-table' }).vm.showPace).to.equal(true);
+ expect(wrapper.findComponent({ name: 'single-output-table' }).vm.showPace).to.equal(true);
});
test('should correctly handle null target set', async () => {
// Initialize component
- const raceTargets = [
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- ];
- const wrapper = shallowMount(RaceCalculator, {
- data() {
- return {
- targetSets: {
- '_pace_targets': null,
- '_race_targets': {
- name: 'Common race targets',
- targets: raceTargets,
- },
- },
- };
- },
- });
+ const wrapper = shallowMount(RaceCalculator);
// Switch to invalid target set
- await wrapper.findComponent({ name: 'target-set-selector' }).setValue('_pace_targets');
+ await wrapper.findComponent({ name: 'target-set-selector' })
+ .setValue('does_not_exist', 'selectedTargetSet');
- // Assert empty array passed to SimpleTargetTable component
- expect(wrapper.findComponent({ name: 'simple-target-table' }).vm.targets).to.deep.equal([]);
+ // Assert empty array passed to SingleOutputTable component
+ expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets).to.deep.equal([]);
// Switch to valid target set
- await wrapper.findComponent({ name: 'target-set-selector' }).setValue('_race_targets');
+ await wrapper.findComponent({ name: 'target-set-selector' })
+ .setValue('_race_targets', 'selectedTargetSet');
- // Assert valid targets passed to SimpleTargetTable component
- expect(wrapper.findComponent({ name: 'simple-target-table' }).vm.targets).to.deep.equal(raceTargets);
+ // Assert valid targets passed to SingleOutputTable component
+ const raceTargets = defaultTargetSets._race_targets.targets;
+ expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets)
+ .to.deep.equal(raceTargets);
});
test('should correctly calculate race statistics', async () => {
// Initialize component
const wrapper = shallowMount(RaceCalculator);
- // Enter input pace data
- await wrapper.findComponent({ name: 'decimal-input' }).setValue(5);
- await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers');
- await wrapper.findComponent({ name: 'time-input' }).setValue(1200);
+ // Enter input race data
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ });
// Get race statistics
const raceStats = wrapper.findAll('details')[0];
@@ -133,104 +127,116 @@ test('should correctly calculate results according to advanced model options', a
// Initialize component
const wrapper = shallowMount(RaceCalculator);
- // Enter input pace data
- await wrapper.findComponent({ name: 'decimal-input' }).setValue(5);
- await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers');
- await wrapper.findComponent({ name: 'time-input' }).setValue(1200);
+ // Enter input race data
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ });
// Switch model
- await wrapper.find('select[aria-label="Prediction model"]').setValue('RiegelModel');
+ await wrapper.findComponent({ name: 'RaceOptions' }).setValue({
+ model: 'RiegelModel',
+ riegelExponent: 1.06, // default value
+ });
// Calculate result
- const calculateResult = wrapper.findComponent({ name: 'simple-target-table' }).vm.calculateResult;
+ const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult;
let result = calculateResult({
distanceValue: 10,
distanceUnit: 'kilometers',
- result: 'time',
+ type: 'distance',
});
// Assert result is correct
- expect(result.time).to.be.closeTo(2502, 1);
+ expect(result.value).to.equal('41:41.92');
// Update Riegel Exponent
- expect(wrapper.findComponent('[aria-label="Riegel exponent"').vm.modelValue).to.equal(1.06);
- await wrapper.findComponent('[aria-label="Riegel exponent"').setValue(1);
+ await wrapper.findComponent({ name: 'RaceOptions' }).setValue({
+ model: 'RiegelModel', // existing value
+ riegelExponent: 1,
+ });
// Calculate result
result = calculateResult({
distanceValue: 10,
distanceUnit: 'kilometers',
- result: 'time',
+ type: 'distance',
});
// Assert result is correct
- expect(result.time).to.equal(2400);
+ expect(result.value).to.equal('40:00.00');
});
-test('should load input pace from localStorage', async () => {
+test('should load input race from localStorage', async () => {
// Initialize localStorage
- localStorage.setItem('running-tools.race-calculator-input-distance', '1');
- localStorage.setItem('running-tools.race-calculator-input-unit', '"miles"');
- localStorage.setItem('running-tools.race-calculator-input-time', '600');
+ localStorage.setItem('running-tools.race-calculator-input', JSON.stringify({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ }));
// Initialize component
const wrapper = shallowMount(RaceCalculator);
// Assert data loaded
- expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1);
- expect(wrapper.find('select[aria-label="Input distance unit"]').element.value).to.equal('miles');
- expect(wrapper.findComponent({ name: 'time-input' }).vm.modelValue).to.equal(600);
+ expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ });
});
-test('should save input pace to localStorage', async () => {
+test('should save input race to localStorage', async () => {
// Initialize component
const wrapper = shallowMount(RaceCalculator);
- // Enter input pace data
- await wrapper.findComponent({ name: 'decimal-input' }).setValue(1);
- await wrapper.find('select[aria-label="Input distance unit"]').setValue('miles');
- await wrapper.findComponent({ name: 'time-input' }).setValue(600);
+ // Enter input race data
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ });
// Assert data saved to localStorage
- expect(localStorage.getItem('running-tools.race-calculator-input-distance')).to.equal('1');
- expect(localStorage.getItem('running-tools.race-calculator-input-unit')).to.equal('"miles"');
- expect(localStorage.getItem('running-tools.race-calculator-input-time')).to.equal('600');
+ expect(localStorage.getItem('running-tools.race-calculator-input')).to.equal(JSON.stringify({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ }));
});
test('should load selected target set from localStorage', async () => {
// Initialize localStorage
- localStorage.setItem('running-tools.race-calculator-target-set', '"_pace_targets"');
+ const targetSet2 = {
+ name: 'Race targets #2',
+ targets: [
+ { type: 'distance', distanceValue: 1, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' },
+ ],
+ };
+ localStorage.setItem('running-tools.race-calculator-target-sets', JSON.stringify({
+ '_race_targets': {
+ name: 'Race targets #1',
+ targets: [
+ { type: 'distance', distanceValue: 400, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 800, distanceUnit: 'meters' },
+ { type: 'distance', distanceValue: 1600, distanceUnit: 'meters' },
+ ],
+ },
+ 'B': targetSet2,
+ }));
+ localStorage.setItem('running-tools.race-calculator-target-set', '"B"');
// Initialize component
- const paceTargets = [
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 3, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 6, distanceUnit: 'kilometers' },
- ];
- const wrapper = shallowMount(RaceCalculator, {
- data() {
- return {
- targetSets: {
- '_pace_targets': {
- name: 'Common pace targets',
- targets: paceTargets,
- },
- '_race_targets': {
- name: 'Common race targets',
- targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- ],
- },
- },
- };
- },
- });
+ const wrapper = shallowMount(RaceCalculator);
// Assert selection is loaded
- expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.modelValue).to.equal('_pace_targets');
- expect(wrapper.findComponent({ name: 'simple-target-table' }).vm.targets).to.deep.equal(paceTargets);
+ expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet)
+ .to.equal('B');
+ expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets)
+ .to.deep.equal(targetSet2.targets);
});
test('should save selected target set to localStorage when modified', async () => {
@@ -238,23 +244,20 @@ test('should save selected target set to localStorage when modified', async () =
const wrapper = shallowMount(RaceCalculator);
// Select a new target set
- await wrapper.findComponent({ name: 'target-set-selector' }).setValue('_pace_targets');
+ await wrapper.findComponent({ name: 'target-set-selector' })
+ .setValue('B', 'selectedTargetSet');
// New selected target set should be saved to localStorage
- expect(localStorage.getItem('running-tools.race-calculator-target-set')).to.equal('"_pace_targets"');
+ expect(localStorage.getItem('running-tools.race-calculator-target-set'))
+ .to.equal('"B"');
});
test('should save default units setting to localStorage when modified', async () => {
// Initialize component
- const wrapper = shallowMount(RaceCalculator, {
- data() {
- return {
- defaultUnitSystem: 'metric',
- };
- },
- });
+ const wrapper = shallowMount(RaceCalculator);
// Change default units
+ await wrapper.find('select[aria-label="Default units"]').setValue('metric');
await wrapper.find('select[aria-label="Default units"]').setValue('imperial');
// New default units should be saved to localStorage
@@ -263,15 +266,19 @@ test('should save default units setting to localStorage when modified', async ()
test('should load advanced model options from localStorage', async () => {
// Initialize localStorage
- localStorage.setItem('running-tools.race-calculator-model', '"PurdyPointsModel"');
- localStorage.setItem('running-tools.race-calculator-riegel-exponent', '1.20');
+ localStorage.setItem('running-tools.race-calculator-options', JSON.stringify({
+ model: 'PurdyPointsModel',
+ riegelExponent: 1.2,
+ }));
// Initialize component
const wrapper = shallowMount(RaceCalculator);
// Assert data loaded
- expect(wrapper.find('select[aria-label="Prediction model"]').element.value).to.equal('PurdyPointsModel');
- expect(wrapper.findComponent('[aria-label="Riegel exponent"]').vm.modelValue).to.equal(1.20);
+ expect(wrapper.findComponent({ name: 'RaceOptions' }).vm.modelValue).to.deep.equal({
+ model: 'PurdyPointsModel',
+ riegelExponent: 1.2,
+ });
});
test('should save advanced model options to localStorage when modified', async () => {
@@ -279,11 +286,15 @@ test('should save advanced model options to localStorage when modified', async (
const wrapper = shallowMount(RaceCalculator);
// Update advanced model options
- await wrapper.find('select[aria-label="Prediction model"]').setValue('CameronModel');
- await wrapper.findComponent('[aria-label="Riegel exponent"]').setValue(1.30);
+ await wrapper.findComponent({ name: 'RaceOptions' }).setValue({
+ model: 'CameronModel',
+ riegelExponent: 1.30,
+ });
// Assert data saved to localStorage
- expect(localStorage.getItem('running-tools.race-calculator-model')).to.equal('"CameronModel"');
- expect(localStorage.getItem('running-tools.race-calculator-riegel-exponent')).to.equal('1.3');
+ expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal(JSON.stringify({
+ model: 'CameronModel',
+ riegelExponent: 1.3,
+ }));
});
diff --git a/tests/unit/views/SplitCalculator.spec.js b/tests/unit/views/SplitCalculator.spec.js
@@ -6,502 +6,180 @@ beforeEach(() => {
localStorage.clear();
})
-test('should initialize undefined splits to 0:00.00', async () => {
+test('should load selected target set from localStorage', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.split-calculator-target-set', '"B"');
+
// Initialize component
- const wrapper = shallowMount(SplitCalculator, {
- data() {
- return {
- targetSets: {
- '_split_targets': {
- name: 'Split targets',
- targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 2, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 3000, distanceUnit: 'meters' },
- ],
- },
- },
- defaultUnitSystem: 'metric',
- };
- },
- });
-
- // Assert results are correct
- const rows = wrapper.findAll('tbody tr');
- expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 km');
- expect(rows[0].findAll('td')[1].element.textContent).to.equal('0:00.00');
- expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0);
- expect(rows[0].findAll('td')[3].element.textContent).to.equal('0:00 / km');
- expect(rows[0].findAll('td').length).to.equal(4);
- expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 km');
- expect(rows[1].findAll('td')[1].element.textContent).to.equal('0:00.00');
- expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0);
- expect(rows[1].findAll('td')[3].element.textContent).to.equal('0:00 / km');
- expect(rows[1].findAll('td').length).to.equal(4);
- expect(rows[2].findAll('td')[0].element.textContent).to.equal('3000 m');
- expect(rows[2].findAll('td')[1].element.textContent).to.equal('0:00.00');
- expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0);
- expect(rows[2].findAll('td')[3].element.textContent).to.equal('0:00 / km');
- expect(rows[2].findAll('td').length).to.equal(4);
- expect(rows.length).to.equal(3);
+ const wrapper = shallowMount(SplitCalculator);
+
+ // Assert selection is loaded
+ expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet).to.equal('B');
});
-test('should correctly load split times from split targets', async () => {
- // Initialize component
- const wrapper = shallowMount(SplitCalculator, {
- data() {
- return {
- targetSets: {
- '_split_targets': {
- name: 'Split targets',
- targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', split: 180 },
- { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', split: 190 },
- { result: 'time', distanceValue: 3000, distanceUnit: 'meters', split: 200 },
- ],
- },
- },
- defaultUnitSystem: 'metric',
- };
+test('should load targets from localStorage and pass to splitOutputTable', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.split-calculator-target-sets', JSON.stringify({
+ '_split_targets': {
+ name: 'Split targets',
+ targets: [
+ { type: 'distance', distanceValue: 1, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' },
+ ],
},
- });
-
- // Assert results are correct
- const rows = wrapper.findAll('tbody tr');
- expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 km');
- expect(rows[0].findAll('td')[1].element.textContent).to.equal('3:00.00');
- expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(180);
- expect(rows[0].findAll('td')[3].element.textContent).to.equal('3:00 / km');
- expect(rows[0].findAll('td').length).to.equal(4);
- expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 km');
- expect(rows[1].findAll('td')[1].element.textContent).to.equal('6:10.00');
- expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(190);
- expect(rows[1].findAll('td')[3].element.textContent).to.equal('3:10 / km');
- expect(rows[1].findAll('td').length).to.equal(4);
- expect(rows[2].findAll('td')[0].element.textContent).to.equal('3000 m');
- expect(rows[2].findAll('td')[1].element.textContent).to.equal('9:30.00');
- expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(200);
- expect(rows[2].findAll('td')[3].element.textContent).to.equal('3:20 / km');
- expect(rows[2].findAll('td').length).to.equal(4);
- expect(rows.length).to.equal(3);
-});
+ 'B': {
+ name: 'Split targets #2',
+ targets: [
+ { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers', split: 180 },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers', split: 190 },
+ { type: 'distance', distanceValue: 3000, distanceUnit: 'meters', split: 200 },
+ ],
+ },
+ }));
-test('should correctly handle null target set', async () => {
// Initialize component
- const wrapper = shallowMount(SplitCalculator, {
- data() {
- return {
- targetSets: {
- '_split_targets': {
- name: 'Split targets',
- targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', split: 180 },
- { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', split: 190 },
- { result: 'time', distanceValue: 3000, distanceUnit: 'meters', split: 200 },
- ],
- },
- 'B': null,
- },
- selectedTargetSet: 'B',
- defaultUnitSystem: 'metric',
- };
- },
- });
+ const wrapper = shallowMount(SplitCalculator);
- // Assert results are empty
- let rows = wrapper.findAll('tbody tr');
- expect(rows[0].findAll('td')[0].element.textContent.trim()).to.equal('There aren\'t any targets in this set yet.');
- expect(rows[0].findAll('td').length).to.equal(1);
- expect(rows.length).to.equal(1);
+ // Assert default split targets are initially loaded
+ expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet)
+ .to.equal('_split_targets');
+ expect(wrapper.findComponent({ name: 'split-output-table' }).vm.modelValue).to.deep.equal([
+ { type: 'distance', distanceValue: 1, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' },
+ ]);
- // Switch to valid target set
- await wrapper.findComponent({ name: 'target-set-selector' }).setValue('_split_targets');
-
- // Assert results are correct
- rows = wrapper.findAll('tbody tr');
- expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 km');
- expect(rows[0].findAll('td')[1].element.textContent).to.equal('3:00.00');
- expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(180);
- expect(rows[0].findAll('td')[3].element.textContent).to.equal('3:00 / km');
- expect(rows[0].findAll('td').length).to.equal(4);
- expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 km');
- expect(rows[1].findAll('td')[1].element.textContent).to.equal('6:10.00');
- expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(190);
- expect(rows[1].findAll('td')[3].element.textContent).to.equal('3:10 / km');
- expect(rows[1].findAll('td').length).to.equal(4);
- expect(rows[2].findAll('td')[0].element.textContent).to.equal('3000 m');
- expect(rows[2].findAll('td')[1].element.textContent).to.equal('9:30.00');
- expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(200);
- expect(rows[2].findAll('td')[3].element.textContent).to.equal('3:20 / km');
- expect(rows[2].findAll('td').length).to.equal(4);
- expect(rows.length).to.equal(3);
+ // Select a new target set
+ await wrapper.findComponent({ name: 'target-set-selector' }).setValue('B', 'selectedTargetSet');
+
+ // Assert new target set is loaded
+ expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet)
+ .to.equal('B');
+ expect(wrapper.findComponent({ name: 'split-output-table' }).vm.modelValue).to.deep.equal([
+ { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers', split: 180 },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers', split: 190 },
+ { type: 'distance', distanceValue: 3000, distanceUnit: 'meters', split: 200 },
+ ]);
});
-test('should correctly calculate paces and cululative times from entered split times', async () => {
+test('should correctly handle null target set', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.split-calculator-target-set', '"does_not_exist"');
+
// Initialize component
- const wrapper = shallowMount(SplitCalculator, {
- data() {
- return {
- targetSets: {
- '_split_targets': {
- name: 'Split targets',
- targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', split: 180 },
- { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', split: 180 },
- { result: 'time', distanceValue: 3000, distanceUnit: 'meters', split: 180 },
- ],
- },
- },
- defaultUnitSystem: 'metric',
- };
- },
- });
+ const wrapper = shallowMount(SplitCalculator);
- // Update split times
- await wrapper.findAllComponents({ name: 'time-input' })[1].setValue(190);
- await wrapper.findAllComponents({ name: 'time-input' })[2].setValue(200);
-
- // Assert results are correct
- const rows = wrapper.findAll('tbody tr');
- expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 km');
- expect(rows[0].findAll('td')[1].element.textContent).to.equal('3:00.00');
- expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(180);
- expect(rows[0].findAll('td')[3].element.textContent).to.equal('3:00 / km');
- expect(rows[0].findAll('td').length).to.equal(4);
- expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 km');
- expect(rows[1].findAll('td')[1].element.textContent).to.equal('6:10.00');
- expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(190);
- expect(rows[1].findAll('td')[3].element.textContent).to.equal('3:10 / km');
- expect(rows[1].findAll('td').length).to.equal(4);
- expect(rows[2].findAll('td')[0].element.textContent).to.equal('3000 m');
- expect(rows[2].findAll('td')[1].element.textContent).to.equal('9:30.00');
- expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(200);
- expect(rows[2].findAll('td')[3].element.textContent).to.equal('3:20 / km');
- expect(rows[2].findAll('td').length).to.equal(4);
- expect(rows.length).to.equal(3);
-});
+ // Assert selection is loaded
+ expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet).to.equal('does_not_exist');
-test('should correctly sort split targets', async () => {
- // Initialize component
- const wrapper = shallowMount(SplitCalculator, {
- data() {
- return {
- targetSets: {
- '_split_targets': {
- name: 'Split targets',
- targets: [
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 1, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 2, distanceUnit: 'kilometers' },
- ],
- },
- },
- defaultUnitSystem: 'metric',
- };
- },
- });
-
- // Assert results are correct
- const rows = wrapper.findAll('tbody tr');
- expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 km');
- expect(rows[0].findAll('td')[1].element.textContent).to.equal('0:00.00');
- expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0);
- expect(rows[0].findAll('td')[3].element.textContent).to.equal('0:00 / km');
- expect(rows[0].findAll('td').length).to.equal(4);
- expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 km');
- expect(rows[1].findAll('td')[1].element.textContent).to.equal('0:00.00');
- expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0);
- expect(rows[1].findAll('td')[3].element.textContent).to.equal('0:00 / km');
- expect(rows[1].findAll('td').length).to.equal(4);
- expect(rows[2].findAll('td')[0].element.textContent).to.equal('2 mi');
- expect(rows[2].findAll('td')[1].element.textContent).to.equal('0:00.00');
- expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0);
- expect(rows[2].findAll('td')[3].element.textContent).to.equal('0:00 / km');
- expect(rows[2].findAll('td').length).to.equal(4);
- expect(rows.length).to.equal(3);
+ // Assert empty array passed to SplitOutputTable
+ expect(wrapper.findComponent({ name: 'split-output-table' }).vm.modelValue).to.deep.equal([]);
+
+ // Switch to valid target set
+ await wrapper.findComponent({ name: 'target-set-selector' })
+ .setValue('_split_targets', 'selectedTargetSet');
+
+ // Assert non-empty target set passed to SplitOutputTable
+ expect(wrapper.findComponent({ name: 'split-output-table' }).vm.modelValue).to.deep.equal([
+ { type: 'distance', distanceValue: 1, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'miles' },
+ { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' },
+ ]);
});
-test('should ignore time based targets', async () => {
- // Initialize component
- const wrapper = shallowMount(SplitCalculator, {
- data() {
- return {
- targetSets: {
- '_split_targets': {
- name: 'Split targets',
- targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'kilometers' },
- { result: 'distance', time: 600 },
- { result: 'time', distanceValue: 2, distanceUnit: 'kilometers' },
- { result: 'time', distanceValue: 3000, distanceUnit: 'meters' },
- ],
- },
- },
- defaultUnitSystem: 'metric',
- };
+test('should update targets in localStorage when modified by splitOutputTable', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.split-calculator-target-sets', JSON.stringify({
+ '_split_targets': {
+ name: 'Split targets',
+ targets: [
+ { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers', split: 180 },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers', split: 180 },
+ { type: 'distance', distanceValue: 3000, distanceUnit: 'meters', split: 180 },
+ ],
},
- });
-
- // Assert results are correct
- const rows = wrapper.findAll('tbody tr');
- expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 km');
- expect(rows[0].findAll('td')[1].element.textContent).to.equal('0:00.00');
- expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0);
- expect(rows[0].findAll('td')[3].element.textContent).to.equal('0:00 / km');
- expect(rows[0].findAll('td').length).to.equal(4);
- expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 km');
- expect(rows[1].findAll('td')[1].element.textContent).to.equal('0:00.00');
- expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0);
- expect(rows[1].findAll('td')[3].element.textContent).to.equal('0:00 / km');
- expect(rows[1].findAll('td').length).to.equal(4);
- expect(rows[2].findAll('td')[0].element.textContent).to.equal('3000 m');
- expect(rows[2].findAll('td')[1].element.textContent).to.equal('0:00.00');
- expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0);
- expect(rows[2].findAll('td')[3].element.textContent).to.equal('0:00 / km');
- expect(rows[2].findAll('td').length).to.equal(4);
- expect(rows.length).to.equal(3);
-});
+ }));
-test('should correctly save split times with split targets in localStorage', async () => {
// Initialize component
- const wrapper = shallowMount(SplitCalculator, {
- data() {
- return {
- targetSets: {
- '_split_targets': {
- name: 'Split targets',
- targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', split: 180 },
- { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', split: 180 },
- { result: 'time', distanceValue: 3000, distanceUnit: 'meters', split: 180 },
- ],
- },
- },
- defaultUnitSystem: 'metric',
- };
- },
- });
+ const wrapper = shallowMount(SplitCalculator);
// Update split times
- await wrapper.findAllComponents({ name: 'time-input' })[1].setValue(190);
- await wrapper.findAllComponents({ name: 'time-input' })[2].setValue(200);
+ await wrapper.findComponent({ name: 'split-output-table' }).setValue([
+ { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers', split: 180 },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers', split: 190 },
+ { type: 'distance', distanceValue: 3000, distanceUnit: 'meters', split: 200 },
+ ]);
// Assert targets correctly saved in localStorage
- expect(localStorage.getItem('running-tools.target-sets')).to.equal(JSON.stringify({
+ expect(localStorage.getItem('running-tools.split-calculator-target-sets')).to.equal(JSON.stringify({
'_split_targets': {
name: 'Split targets',
targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', split: 180 },
- { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', split: 190 },
- { result: 'time', distanceValue: 3000, distanceUnit: 'meters', split: 200 },
+ { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers', split: 180 },
+ { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers', split: 190 },
+ { type: 'distance', distanceValue: 3000, distanceUnit: 'meters', split: 200 },
],
},
}));
});
-test('should update results when a new target set is selected', async () => {
- // Initialize component
- const wrapper = shallowMount(SplitCalculator, {
- data() {
- return {
- targetSets: {
- '_split_targets': {
- name: 'Split targets',
- targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- ],
- },
- 'B': {
- name: 'Split targets #2',
- targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', split: 180 },
- { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', split: 190 },
- { result: 'time', distanceValue: 3000, distanceUnit: 'meters', split: 200 },
- ],
- },
- },
- defaultUnitSystem: 'metric',
- };
- },
- });
-
- // Assert default split targets are initially loaded
- expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.modelValue).to.equal('_split_targets');
- expect(wrapper.findAll('tbody td')[0].element.textContent).to.equal('1 mi');
-
- // Select a new target set
- await wrapper.findComponent({ name: 'target-set-selector' }).setValue('B');
-
- // Assert results are correct
- const rows = wrapper.findAll('tbody tr');
- expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 km');
- expect(rows[0].findAll('td')[1].element.textContent).to.equal('3:00.00');
- expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(180);
- expect(rows[0].findAll('td')[3].element.textContent).to.equal('3:00 / km');
- expect(rows[0].findAll('td').length).to.equal(4);
- expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 km');
- expect(rows[1].findAll('td')[1].element.textContent).to.equal('6:10.00');
- expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(190);
- expect(rows[1].findAll('td')[3].element.textContent).to.equal('3:10 / km');
- expect(rows[1].findAll('td').length).to.equal(4);
- expect(rows[2].findAll('td')[0].element.textContent).to.equal('3000 m');
- expect(rows[2].findAll('td')[1].element.textContent).to.equal('9:30.00');
- expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(200);
- expect(rows[2].findAll('td')[3].element.textContent).to.equal('3:20 / km');
- expect(rows[2].findAll('td').length).to.equal(4);
- expect(rows.length).to.equal(3);
-});
-
-test('should load selected target set from localStorage', async () => {
- // Initialize localStorage
- localStorage.setItem('running-tools.split-calculator-target-set', '"B"');
-
- // Initialize component
- const wrapper = shallowMount(SplitCalculator, {
- data() {
- return {
- targetSets: {
- '_split_targets': {
- name: 'Split targets',
- targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- ],
- },
- 'B': {
- name: 'Split targets #2',
- targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', split: 180 },
- { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', split: 190 },
- { result: 'time', distanceValue: 3000, distanceUnit: 'meters', split: 200 },
- ],
- },
- },
- defaultUnitSystem: 'metric',
- };
- },
- });
-
- // Assert selection is loaded
- expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.modelValue).to.equal('B');
-
- // Assert results are correct
- const rows = wrapper.findAll('tbody tr');
- expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 km');
- expect(rows[0].findAll('td')[1].element.textContent).to.equal('3:00.00');
- expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(180);
- expect(rows[0].findAll('td')[3].element.textContent).to.equal('3:00 / km');
- expect(rows[0].findAll('td').length).to.equal(4);
- expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 km');
- expect(rows[1].findAll('td')[1].element.textContent).to.equal('6:10.00');
- expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(190);
- expect(rows[1].findAll('td')[3].element.textContent).to.equal('3:10 / km');
- expect(rows[1].findAll('td').length).to.equal(4);
- expect(rows[2].findAll('td')[0].element.textContent).to.equal('3000 m');
- expect(rows[2].findAll('td')[1].element.textContent).to.equal('9:30.00');
- expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(200);
- expect(rows[2].findAll('td')[3].element.textContent).to.equal('3:20 / km');
- expect(rows[2].findAll('td').length).to.equal(4);
- expect(rows.length).to.equal(3);
-});
-
test('should save selected target set to localStorage when modified', async () => {
// Initialize component
- const wrapper = shallowMount(SplitCalculator, {
- data() {
- return {
- targetSets: {
- '_split_targets': {
- name: 'Split targets',
- targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles' },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' },
- ],
- },
- 'B': {
- name: 'Split targets #2',
- targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', split: 180 },
- { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', split: 190 },
- { result: 'time', distanceValue: 3000, distanceUnit: 'meters', split: 200 },
- ],
- },
- },
- defaultUnitSystem: 'metric',
- };
- },
- });
+ const wrapper = shallowMount(SplitCalculator);
// Select a new target set
- await wrapper.findComponent({ name: 'target-set-selector' }).setValue('B');
+ await wrapper.findComponent({ name: 'target-set-selector' })
+ .setValue('_race_targets', 'selectedTargetSet');
// New selected target set should be saved to localStorage
- expect(localStorage.getItem('running-tools.split-calculator-target-set')).to.equal('"B"');
+ expect(localStorage.getItem('running-tools.split-calculator-target-set'))
+ .to.equal('"_race_targets"');
});
-test('should update paces according to default units setting', async () => {
+test('should load default units from localStorage and pass to splitOutputTable', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.default-unit-system', '"metric"');
+
// Initialize component
- const wrapper = shallowMount(SplitCalculator, {
- data() {
- return {
- targetSets: {
- '_split_targets': {
- name: 'Split targets',
- targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'miles', split: 300 },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles', split: 300 },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers', split: 330 },
- ],
- },
- },
- defaultUnitSystem: 'metric',
- };
- },
- });
+ const wrapper = shallowMount(SplitCalculator);
+
+ // Assert default units setting is initialy loaded
+ expect(wrapper.find('select', { name: 'Default units' }).element.value).to.equal('metric');
- // Assert paces are correct
- let rows = wrapper.findAll('tbody tr');
- expect(rows[0].findAll('td')[3].element.textContent).to.equal('3:06 / km');
- expect(rows[1].findAll('td')[3].element.textContent).to.equal('3:06 / km');
- expect(rows[2].findAll('td')[3].element.textContent).to.equal('3:05 / km');
+ // Assert prop is correct
+ expect(wrapper.findComponent({ name: 'split-output-table' }).vm.defaultUnitSystem)
+ .to.equal('metric');
// Change default units
await wrapper.find('select').setValue('imperial');
- // Assert paces are correct
- rows = wrapper.findAll('tbody tr');
- expect(rows[0].findAll('td')[3].element.textContent).to.equal('5:00 / mi');
- expect(rows[1].findAll('td')[3].element.textContent).to.equal('5:00 / mi');
- expect(rows[2].findAll('td')[3].element.textContent).to.equal('4:58 / mi');
+ // Assert prop is correct
+ expect(wrapper.findComponent({ name: 'split-output-table' }).vm.defaultUnitSystem)
+ .to.equal('imperial');
});
test('should save default units setting to localStorage when modified', async () => {
// Initialize component
- const wrapper = shallowMount(SplitCalculator, {
- data() {
- return {
- targetSets: {
- '_split_targets': {
- name: 'Split targets',
- targets: [
- { result: 'time', distanceValue: 1, distanceUnit: 'miles', split: 300 },
- { result: 'time', distanceValue: 2, distanceUnit: 'miles', split: 300 },
- { result: 'time', distanceValue: 5, distanceUnit: 'kilometers', split: 330 },
- ],
- },
- },
- defaultUnitSystem: 'metric',
- };
- },
- });
+ const wrapper = shallowMount(SplitCalculator);
- // Change default units
+ // Set default units setting
+ await wrapper.find('select').setValue('metric');
+
+ // New default units should be saved to localStorage
+ expect(localStorage.getItem('running-tools.default-unit-system')).to.equal('"metric"');
+
+ // Set default units setting
await wrapper.find('select').setValue('imperial');
// New default units should be saved to localStorage
expect(localStorage.getItem('running-tools.default-unit-system')).to.equal('"imperial"');
});
+
+test('should correctly set targetSetSelector setType prop', async () => {
+ // Initialize component
+ const wrapper = shallowMount(SplitCalculator);
+
+ // Assert setType prop is correctly set
+ expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.setType).to.equal('split');
+});
diff --git a/tests/unit/views/UnitCalculator.spec.js b/tests/unit/views/UnitCalculator.spec.js
@@ -27,7 +27,7 @@ test('should correctly update controls when category changes', async () => {
expect(wrapper.find('select[aria-label="Output units"]').element.value).to.equal('miles_per_hour');
// Change category
- await wrapper.setData({ category: 'distance' });
+ await wrapper.find('select[aria-label="Selected unit category"]').setValue('distance');
// Assert controls are correct
expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1);
@@ -91,15 +91,23 @@ test('should correctly convert to and from hh:mm:ss', async () => {
test('should correctly load saved inputs', async () => {
// Initialize localStorage
- localStorage.setItem('running-tools.unit-calculator-distance-input-value', '5');
- localStorage.setItem('running-tools.unit-calculator-distance-input-unit', '"kilometers"');
- localStorage.setItem('running-tools.unit-calculator-distance-output-unit', '"miles"');
- localStorage.setItem('running-tools.unit-calculator-time-input-value', '90');
- localStorage.setItem('running-tools.unit-calculator-time-input-unit', '"hh:mm:ss"');
- localStorage.setItem('running-tools.unit-calculator-time-output-unit', '"minutes"');
- localStorage.setItem('running-tools.unit-calculator-speed-input-value', '15');
- localStorage.setItem('running-tools.unit-calculator-speed-input-unit', '"miles_per_hour"');
- localStorage.setItem('running-tools.unit-calculator-speed-output-unit', '"seconds_per_mile"');
+ localStorage.setItem('running-tools.unit-calculator-inputs', JSON.stringify({
+ distance: {
+ inputValue: 5,
+ inputUnit: 'kilometers',
+ outputUnit: 'miles',
+ },
+ time: {
+ inputValue: 90,
+ inputUnit: 'hh:mm:ss',
+ outputUnit: 'minutes',
+ },
+ speed_and_pace: {
+ inputValue: 15,
+ inputUnit: 'miles_per_hour',
+ outputUnit: 'seconds_per_mile',
+ },
+ }));
// Initialize component
const wrapper = shallowMount(UnitCalculator);
@@ -121,7 +129,7 @@ test('should correctly load saved inputs', async () => {
expect(wrapper.find('select[aria-label="Output units"]').element.value).to.equal('seconds_per_mile');
// Change category
- await wrapper.setData({ category: 'distance' });
+ await wrapper.find('select[aria-label="Selected unit category"]').setValue('distance');
// Assert inputs are correct
expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(5);
@@ -152,13 +160,21 @@ test('should correctly save inputs', async () => {
await wrapper.find('select[aria-label="Output units"]').setValue('seconds_per_mile');
// Initialize localStorage
- expect(localStorage.getItem('running-tools.unit-calculator-distance-input-value')).to.equal('5');
- expect(localStorage.getItem('running-tools.unit-calculator-distance-input-unit')).to.equal('"kilometers"');
- expect(localStorage.getItem('running-tools.unit-calculator-distance-output-unit')).to.equal('"miles"');
- expect(localStorage.getItem('running-tools.unit-calculator-time-input-value')).to.equal('90');
- expect(localStorage.getItem('running-tools.unit-calculator-time-input-unit')).to.equal('"hh:mm:ss"');
- expect(localStorage.getItem('running-tools.unit-calculator-time-output-unit')).to.equal('"minutes"');
- expect(localStorage.getItem('running-tools.unit-calculator-speed-input-value')).to.equal('15');
- expect(localStorage.getItem('running-tools.unit-calculator-speed-input-unit')).to.equal('"miles_per_hour"');
- expect(localStorage.getItem('running-tools.unit-calculator-speed-output-unit')).to.equal('"seconds_per_mile"');
+ expect(localStorage.getItem('running-tools.unit-calculator-inputs')).to.equal(JSON.stringify({
+ distance: {
+ inputValue: 5,
+ inputUnit: 'kilometers',
+ outputUnit: 'miles',
+ },
+ time: {
+ inputValue: 90,
+ inputUnit: 'hh:mm:ss',
+ outputUnit: 'minutes',
+ },
+ speed_and_pace: {
+ inputValue: 15,
+ inputUnit: 'miles_per_hour',
+ outputUnit: 'seconds_per_mile',
+ },
+ }));
});
diff --git a/tests/unit/views/WorkoutCalculator.spec.js b/tests/unit/views/WorkoutCalculator.spec.js
@@ -0,0 +1,238 @@
+import { beforeEach, test, expect } from 'vitest';
+import { shallowMount } from '@vue/test-utils';
+import WorkoutCalculator from '@/views/WorkoutCalculator.vue';
+import { defaultTargetSets } from '@/utils/targets';
+
+beforeEach(() => {
+ localStorage.clear();
+})
+
+test('should correctly predict workout splits', async () => {
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Enter input race data
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ });
+
+ // Calculate result
+ const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult;
+ const result = calculateResult({
+ splitValue: 1, splitUnit: 'kilometers',
+ type: 'distance', distanceValue: 10, distanceUnit: 'kilometers',
+ });
+
+ // Assert result is correct
+ expect(result.key).to.equal('1 km @ 10 km');
+ expect(result.value).to.equal('4:09.48');
+ expect(result.result).to.equal('value');
+ expect(result.sort).to.be.closeTo(249.48, 0.01);
+});
+
+test('should correctly handle null target set', async () => {
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Switch to invalid target set
+ await wrapper.findComponent({ name: 'target-set-selector' })
+ .setValue('does_not_exist', 'selectedTargetSet');
+
+ // Assert empty array passed to SingleOutputTable component
+ expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets).to.deep.equal([]);
+
+ // Switch to valid target set
+ await wrapper.findComponent({ name: 'target-set-selector' })
+ .setValue('_workout_targets', 'selectedTargetSet');
+
+ // Assert valid targets passed to SingleOutputTable component
+ const workoutTargets = defaultTargetSets._workout_targets.targets;
+ expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets)
+ .to.deep.equal(workoutTargets);
+});
+
+test('should correctly calculate results according to advanced model options', async () => {
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Enter input race data
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ });
+
+ // Update model and Riegel Exponent
+ await wrapper.findComponent({ name: 'RaceOptions' }).setValue({
+ model: 'RiegelModel',
+ riegelExponent: 1.10,
+ });
+
+ // Calculate result
+ const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult;
+ let result = calculateResult({
+ splitValue: 1, splitUnit: 'kilometers',
+ type: 'distance', distanceValue: 10, distanceUnit: 'kilometers',
+ });
+
+ // Assert result is correct
+ expect(result.key).to.equal('1 km @ 10 km');
+ expect(result.value).to.equal('4:17.23');
+});
+
+test('should load input race from localStorage', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.workout-calculator-input', JSON.stringify({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ }));
+
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Assert data loaded
+ expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ });
+});
+
+test('should save input race to localStorage', async () => {
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Enter input race data
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ });
+
+ // Assert data saved to localStorage
+ expect(localStorage.getItem('running-tools.workout-calculator-input')).to.equal(JSON.stringify({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ }));
+});
+
+test('should load selected target set from localStorage', async () => {
+ // Initialize localStorage
+ const targetSet2 = {
+ name: 'Workout targets #2',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ {
+ distanceUnit: 'kilometers', distanceValue: 5,
+ splitUnit: 'miles', splitValue: 1,
+ type: 'distance'
+ },
+ ],
+ };
+ localStorage.setItem('running-tools.workout-calculator-target-sets', JSON.stringify({
+ '_workout_targets': {
+ name: 'Workout targets #1',
+ targets: [
+ {
+ splitValue: 400, splitUnit: 'meters',
+ type: 'distance', distanceValue: 1, distanceUnit: 'miles',
+ },
+ {
+ splitValue: 800, splitUnit: 'meters',
+ type: 'distance', distanceValue: 5, distanceUnit: 'kilometers',
+ },
+ {
+ splitValue: 1600, splitUnit: 'meters',
+ type: 'time', time: 3600,
+ },
+ {
+ splitValue: 2, splitUnit: 'miles',
+ type: 'time', time: 7200,
+ },
+ ],
+ },
+ 'B': targetSet2,
+ }));
+ localStorage.setItem('running-tools.workout-calculator-target-set', '"B"');
+
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Assert selection is loaded
+ expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet)
+ .to.equal('B');
+ expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets)
+ .to.deep.equal(targetSet2.targets);
+});
+
+test('should save selected target set to localStorage when modified', async () => {
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Select a new target set
+ await wrapper.findComponent({ name: 'target-set-selector' })
+ .setValue('B', 'selectedTargetSet');
+
+ // New selected target set should be saved to localStorage
+ expect(localStorage.getItem('running-tools.workout-calculator-target-set'))
+ .to.equal('"B"');
+});
+
+test('should save default units setting to localStorage when modified', async () => {
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Change default units
+ await wrapper.find('select[aria-label="Default units"]').setValue('metric');
+ await wrapper.find('select[aria-label="Default units"]').setValue('imperial');
+
+ // New default units should be saved to localStorage
+ expect(localStorage.getItem('running-tools.default-unit-system')).to.equal('"imperial"');
+});
+
+test('should load advanced model options from localStorage', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.workout-calculator-options', JSON.stringify({
+ model: 'PurdyPointsModel',
+ riegelExponent: 1.2,
+ }));
+
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Assert data loaded
+ expect(wrapper.findComponent({ name: 'RaceOptions' }).vm.modelValue).to.deep.equal({
+ model: 'PurdyPointsModel',
+ riegelExponent: 1.2,
+ });
+});
+
+test('should save advanced model options to localStorage when modified', async () => {
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Update advanced model options
+ await wrapper.findComponent({ name: 'RaceOptions' }).setValue({
+ model: 'CameronModel',
+ riegelExponent: 1.30,
+ });
+
+ // Assert data saved to localStorage
+ expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal(JSON.stringify({
+ model: 'CameronModel',
+ riegelExponent: 1.3,
+ }));
+});
diff --git a/vite.config.js b/vite.config.js
@@ -59,5 +59,6 @@ export default defineConfig({
base: process.env.BASE_URL ? process.env.BASE_URL : '/',
test: {
environment: 'jsdom',
+ include: ['tests/unit/**/*.{test,spec}.?(c|m)[jt]s?(x)'],
},
});