diff --git a/.gitignore b/.gitignore index 88eab4f..e8b52f5 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ node_modules/ # OS .DS_Store + +# Test coverage files +coverage/ \ No newline at end of file diff --git a/NESTED_SETS_FORMAT.md b/NESTED_SETS_FORMAT.md new file mode 100644 index 0000000..f8ae088 --- /dev/null +++ b/NESTED_SETS_FORMAT.md @@ -0,0 +1,72 @@ +# New Nested Sets Format + +The workout format now supports multiple sets per exercise with indented set items. Each set can have its own rep and weight values. + +## Format + +``` +title: Workout Name +state: planned|started|completed +startDate: 2026-01-08 15:45 +duration: 11m 33s +--- +- [ ] Exercise Name | Duration: [180s] + - [ ] Reps: [10] | Weight: [185] lbs + - [ ] Reps: [8] | Weight: [185] lbs + - [ ] Reps: [6] | Weight: [185] lbs +``` + +## Example: Upper Body Workout + +``` +title: Upper Body Strength +state: planned +startDate: +duration: +--- +- [ ] Bench Press | Duration: [300s] + - [ ] Reps: [10] | Weight: [225] lbs + - [ ] Reps: [8] | Weight: [235] lbs + - [ ] Reps: [6] | Weight: [245] lbs + +- [ ] Barbell Rows | Duration: [300s] + - [ ] Reps: [10] | Weight: [225] lbs + - [ ] Reps: [8] | Weight: [235] lbs + - [ ] Reps: [6] | Weight: [245] lbs + +- [ ] Rest | Duration: [60s] + - [ ] Duration: [60s] + +- [ ] Overhead Press | Duration: [180s] + - [ ] Reps: [8] | Weight: [155] lbs + - [ ] Reps: [6] | Weight: [165] lbs + +- [ ] Lat Pulldown | Duration: [200s] + - [ ] Reps: [12] | Weight: [180] lbs + - [ ] Reps: [10] | Weight: [200] lbs + - [ ] Reps: [8] | Weight: [220] lbs +``` + +## Key Features + +- **Exercise Header**: Parent item with exercise name and optional duration +- **Indented Sets**: Each set is an indented bulleted item (2 spaces + `- [ ]`) +- **Set Parameters**: Each set can have Reps, Weight, or other parameters +- **State Tracking**: Each set has its own checkbox state (pending, in progress, completed, skipped) +- **Editable Values**: Wrap values in brackets `[value]` to make them editable +- **Units**: Add units after values, e.g., `Weight: [185] lbs` or `Reps: [10] /arm` + +## Parse/Serialize Behavior + +- When parsing, each exercise automatically creates a default set from any exercise-level params (for backward compatibility) +- Exercise-level params now typically contain `Duration` for timed exercises +- Set-level params contain the actual exercise data (Reps, Weight, etc.) +- When serializing, exercise header comes first, followed by indented sets + +## "+ Set" Button + +When you click "+ Set" during a workout: +1. The current set is marked as completed +2. A new pending set is added to the same exercise +3. You can modify the reps/weight values for the new set +4. The "+ Next" button will move to the next exercise when all sets are done diff --git a/README.md b/README.md index e46c0af..06f99f7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Track your workouts directly in Obsidian with interactive timers, editable value ## Features - **Timers**: Count-up for exercises, countdown for rest periods with auto-advance -- **Editable values**: Click to edit weight, reps, or duration during workout +- **Editable values**: Click to edit weight or reps during workout - **Add Set / Add Rest**: Quickly add extra sets or rest periods on the fly - **Skip / Pause / Resume**: Full control over your workout flow - **Copy as Template**: Reuse completed workouts as templates @@ -25,7 +25,7 @@ The easiest way to install and keep the plugin updated, especially useful for mo 1. Install the [BRAT](https://github.com/TfTHacker/obsidian42-brat) plugin from Obsidian's Community Plugins 2. Open BRAT settings (Settings → BRAT) 3. Click "Add Beta plugin" -4. Enter: `https://github.com/ldomaradzki/obsidian-workout-log` +4. Enter: `https://github.com/erikparra/obsidian-workout-log` 5. Enable the plugin in Settings → Community Plugins BRAT will automatically check for updates and notify you when new versions are available. Perfect for mobile users who can't manually copy files! @@ -51,10 +51,15 @@ startDate: duration: restDuration: 45s --- -- [ ] Squats | Weight: [60] kg | Reps: [10] -- [ ] Rest | Duration: [45s] -- [ ] Bench Press | Weight: [40] kg | Reps: [8] -- [ ] Plank | Duration: [60s] +- [ ] Squats + - [ ] Weight: [140] lbs | Reps: [5] | Rest: 45s + - [ ] Weight: [120] lbs | Reps: [8] | Rest: 45s + - [ ] Weight: [100] lbs | Reps: [10] | Rest: 45s +- [ ] Bench Press + - [ ] Weight: [100] lbs | Reps: [10] | Rest: 45s + - [ ] Weight: [100] lbs | Reps: [8] | Rest: 45s + - [ ] Weight: [100] lbs | Reps: [4] | Rest: 45s +- [ ] Plank | Duration: [60s] | Rest: 45s ``` ```` @@ -70,10 +75,17 @@ restDuration: 45s | `restDuration` | Default duration for "+ Rest" button | ### Exercise Format +#### Single Set Exercise ``` - [ ] Exercise Name | Key: [value] unit | Key: value ``` +#### Multi Set Exercise +``` +- [ ] Exercise Name + - [ ] Key: [value] unit | Key: value + - [ ] Key: [value] unit | Key: value +``` - `[ ]` pending, `[\]` in progress, `[x]` completed, `[-]` skipped - `[value]` = editable, `value` = locked diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..987344d --- /dev/null +++ b/jest.config.js @@ -0,0 +1,85 @@ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/*.test.ts'], + moduleFileExtensions: ['ts', 'js'], + testPathIgnorePatterns: ['src/timer/manager.test.ts'], + moduleNameMapper: { + '^obsidian$': '/src/__mocks__/obsidian.ts' + }, + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/**/*.test.ts', + '!src/main.ts', + '!src/timer/**' + ], + coverageThreshold: { + global: { + branches: 75, + functions: 70, + lines: 88, + statements: 88 + }, + 'src/parser/exercise.ts': { + branches: 85, + functions: 100, + lines: 95, + statements: 95 + }, + 'src/parser/metadata.ts': { + branches: 90, + functions: 100, + lines: 100, + statements: 100 + }, + 'src/parser/index.ts': { + branches: 90, + lines: 100, + statements: 100 + }, + 'src/serializer.ts': { + branches: 60, + functions: 85, + lines: 83, + statements: 83 + }, + 'src/file/updater.ts': { + branches: 89, + functions: 100, + lines: 99, + statements: 99 + }, + 'src/renderer/header.ts': { + branches: 80, + functions: 100, + lines: 90, + statements: 90 + }, + 'src/renderer/emptyState.ts': { + branches: 80, + functions: 100, + lines: 90, + statements: 90 + }, + 'src/renderer/controls.ts': { + branches: 75, + functions: 80, + lines: 85, + statements: 85 + }, + 'src/renderer/exercise.ts': { + branches: 80, + functions: 60, + lines: 85, + statements: 85 + }, + 'src/renderer/index.ts': { + branches: 70, + functions: 40, + lines: 85, + statements: 85 + } + } +}; diff --git a/manifest.json b/manifest.json index 3dfa7b1..123678c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { "id": "workout-log", "name": "Workout Log", - "version": "1.1.1", + "version": "1.1.3", "minAppVersion": "1.5.0", "description": "Render workout blocks as interactive trackers with built-in timers.", - "author": "Lukasz", + "author": "erikparra", "isDesktopOnly": false } diff --git a/package-lock.json b/package-lock.json index 8747733..41fa306 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,614 +1,5697 @@ { "name": "obsidian-workout-log", - "version": "1.0.0", + "version": "1.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-workout-log", - "version": "1.0.0", + "version": "1.1.3", "license": "MIT", "dependencies": { "obsidian": "latest" }, "devDependencies": { + "@types/jest": "^30.0.0", + "@types/jsdom": "^28.0.1", "@types/node": "^16.11.6", "esbuild": "0.25.5", + "jest": "^30.3.0", + "jsdom": "^29.0.2", + "ts-jest": "^29.4.9", "tslib": "2.4.0", "typescript": "^5.8.3" } }, - "node_modules/@codemirror/state": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", - "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.8.tgz", + "integrity": "sha512-OISPR9c2uPo23rUdvfEQiLPjoMLOpEeLNnP5iGkxr6tDDxJd3NjD+6fxY0mdaMbIPUjFGL4HFOJqLvow5q4aqQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@marijn/find-cluster-break": "^1.0.0" + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/@codemirror/view": { - "version": "6.38.6", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", - "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.8.tgz", + "integrity": "sha512-erMO6FgtM02dC24NGm0xufMzWz5OF0wXKR7BpvGD973bq/GbmR8/DbxNZbj0YevQ5hlToJaWSVK/G9/NDgGEVw==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@codemirror/state": "^6.5.0", - "crelt": "^1.0.6", - "style-mod": "^4.1.0", - "w3c-keyname": "^2.2.4" + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", - "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", - "cpu": [ - "ppc64" - ], + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", - "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", - "cpu": [ - "arm" - ], + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", - "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", - "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", - "cpu": [ - "x64" - ], + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", - "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", - "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", - "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", - "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", - "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", - "cpu": [ - "arm" - ], + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", - "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", - "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", - "cpu": [ - "ia32" - ], + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", - "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", - "cpu": [ - "loong64" - ], + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", - "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", - "cpu": [ - "mips64el" - ], + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", - "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", - "cpu": [ - "ppc64" - ], + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, "engines": { - "node": ">=18" + "node": ">=6.0.0" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", - "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", - "cpu": [ - "riscv64" - ], + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", - "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", - "cpu": [ - "s390x" - ], + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", - "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/netbsd-arm64": { + "node_modules/@esbuild/android-arm": { "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", - "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", "cpu": [ - "arm64" + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": 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, + "license": "ISC", + "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/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", + "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", + "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.3.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.3.0", + "jest-config": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-resolve-dependencies": "30.3.0", + "jest-runner": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "jest-watcher": "30.3.0", + "pretty-format": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", + "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-mock": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.3.0", + "jest-snapshot": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", + "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", + "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@sinonjs/fake-timers": "^15.0.0", + "@types/node": "*", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", + "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/types": "30.3.0", + "jest-mock": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.3.0.tgz", + "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz", + "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.3.0.tgz", + "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.3.0", + "@jest/types": "30.3.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz", + "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.3.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz", + "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.3.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.3.0", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT", + "peer": true + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "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, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.0.tgz", + "integrity": "sha512-m2xozxSfCIxjDdvbhIWazlP2i2aha/iUmbl94alpsIbd3iLTfeXgfBVbwyWogB6l++istyGZqamgA/EcqYf+Bg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/jsdom": { + "version": "28.0.1", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.1.tgz", + "integrity": "sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0", + "undici-types": "^7.21.0" + } + }, + "node_modules/@types/node": { + "version": "16.18.126", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", + "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/babel-jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", + "integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.3.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.3.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz", + "integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz", + "integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.3.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", + "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/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, + "license": "MIT" + }, + "node_modules/cliui/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, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/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, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/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, + "license": "MIT", + "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/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT", + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.334", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", + "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "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, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/esbuild": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.3.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "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, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.3.0.tgz", + "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.3.0", + "@jest/types": "30.3.0", + "import-local": "^3.2.0", + "jest-cli": "30.3.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.3.0.tgz", + "integrity": "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.3.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.3.0.tgz", + "integrity": "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "p-limit": "^3.1.0", + "pretty-format": "30.3.0", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.3.0.tgz", + "integrity": "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.3.0.tgz", + "integrity": "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.3.0", + "@jest/types": "30.3.0", + "babel-jest": "30.3.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.3.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-runner": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "parse-json": "^5.2.0", + "pretty-format": "30.3.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.3.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.3.0.tgz", + "integrity": "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "jest-util": "30.3.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.3.0.tgz", + "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-mock": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", + "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "picomatch": "^4.0.3", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", + "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", + "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.3.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.3.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.3.0.tgz", + "integrity": "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.3.0.tgz", + "integrity": "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.3.0.tgz", + "integrity": "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.3.0", + "@jest/environment": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-leak-detector": "30.3.0", + "jest-message-util": "30.3.0", + "jest-resolve": "30.3.0", + "jest-runtime": "30.3.0", + "jest-util": "30.3.0", + "jest-watcher": "30.3.0", + "jest-worker": "30.3.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.3.0.tgz", + "integrity": "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/globals": "30.3.0", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz", + "integrity": "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.3.0", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.3.0", + "graceful-fs": "^4.2.11", + "jest-diff": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "pretty-format": "30.3.0", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", + "integrity": "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.3.0", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.3.0.tgz", + "integrity": "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.3.0", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", + "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.3.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/obsidian": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.11.4.tgz", + "integrity": "sha512-n0KD3S+VndgaByrEtEe8NELy0ya6/s+KZ7OcxA6xOm5NN4thxKpQjo6eqEudHEvfGCeT/TYToAKJzitQ1I3XTg==", + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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, + "license": "BlueOak-1.0.0", + "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.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length/node_modules/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, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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, + "license": "MIT", + "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, + "license": "MIT", + "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/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "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, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/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, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "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, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT", + "peer": true + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-jest": { + "version": "29.4.9", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", + "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.4", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.7.tgz", + "integrity": "sha512-XA+gOBkzYD3C74sZowtCLTpgtaCdqZhqCvR6y9LXvrKTt/IVU6bz49T4D+BPi475scshCCkb0IklJRw6T1ZlgQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT", + "peer": true + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], "engines": { - "node": ">=18" + "node": ">=20" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", - "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", - "cpu": [ - "x64" - ], + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", - "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", - "cpu": [ - "arm64" - ], + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "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, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, "engines": { - "node": ">=18" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", - "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", - "cpu": [ - "x64" - ], + "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, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, "engines": { - "node": ">=18" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", - "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", - "cpu": [ - "x64" - ], + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", - "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", - "cpu": [ - "arm64" - ], + "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, + "license": "MIT" + }, + "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, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", - "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", - "cpu": [ - "ia32" - ], + "node_modules/wrap-ansi-cjs/node_modules/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, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "ansi-regex": "^5.0.1" + }, "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", - "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", - "cpu": [ - "x64" - ], + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">=18" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@marijn/find-cluster-break": { + "node_modules/wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", - "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", - "license": "MIT", - "peer": true + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" }, - "node_modules/@types/codemirror": { - "version": "5.60.8", - "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", - "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", - "license": "MIT", + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", "dependencies": { - "@types/tern": "*" + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } }, - "node_modules/@types/node": { - "version": "16.18.126", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", - "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" }, - "node_modules/@types/tern": { - "version": "0.23.9", - "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", - "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", - "license": "MIT", - "dependencies": { - "@types/estree": "*" + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" } }, - "node_modules/crelt": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", - "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", - "license": "MIT", - "peer": true + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" }, - "node_modules/esbuild": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", - "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.5", - "@esbuild/android-arm": "0.25.5", - "@esbuild/android-arm64": "0.25.5", - "@esbuild/android-x64": "0.25.5", - "@esbuild/darwin-arm64": "0.25.5", - "@esbuild/darwin-x64": "0.25.5", - "@esbuild/freebsd-arm64": "0.25.5", - "@esbuild/freebsd-x64": "0.25.5", - "@esbuild/linux-arm": "0.25.5", - "@esbuild/linux-arm64": "0.25.5", - "@esbuild/linux-ia32": "0.25.5", - "@esbuild/linux-loong64": "0.25.5", - "@esbuild/linux-mips64el": "0.25.5", - "@esbuild/linux-ppc64": "0.25.5", - "@esbuild/linux-riscv64": "0.25.5", - "@esbuild/linux-s390x": "0.25.5", - "@esbuild/linux-x64": "0.25.5", - "@esbuild/netbsd-arm64": "0.25.5", - "@esbuild/netbsd-x64": "0.25.5", - "@esbuild/openbsd-arm64": "0.25.5", - "@esbuild/openbsd-x64": "0.25.5", - "@esbuild/sunos-x64": "0.25.5", - "@esbuild/win32-arm64": "0.25.5", - "@esbuild/win32-ia32": "0.25.5", - "@esbuild/win32-x64": "0.25.5" + "node": ">=12" } }, - "node_modules/moment": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", - "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", - "license": "MIT", + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", "engines": { - "node": "*" + "node": ">=12" } }, - "node_modules/obsidian": { - "version": "1.11.4", - "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.11.4.tgz", - "integrity": "sha512-n0KD3S+VndgaByrEtEe8NELy0ya6/s+KZ7OcxA6xOm5NN4thxKpQjo6eqEudHEvfGCeT/TYToAKJzitQ1I3XTg==", + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", - "dependencies": { - "@types/codemirror": "5.60.8", - "moment": "2.29.4" - }, - "peerDependencies": { - "@codemirror/state": "6.5.0", - "@codemirror/view": "6.38.6" + "engines": { + "node": ">=8" } }, - "node_modules/style-mod": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", - "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", - "license": "MIT", - "peer": true + "node_modules/yargs/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, + "license": "MIT" }, - "node_modules/tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "node_modules/yargs/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, - "license": "0BSD" + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "node_modules/yargs/node_modules/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, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=14.17" + "node": ">=8" } }, - "node_modules/w3c-keyname": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", - "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "license": "MIT", - "peer": true + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 459d2b3..f519ea8 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,30 @@ { "name": "obsidian-workout-log", - "version": "1.1.1", + "version": "1.1.3", "description": "A block-rendered workout tracker for Obsidian with timer functionality", "main": "main.js", "type": "module", "scripts": { "dev": "node esbuild.config.mjs", - "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production" + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "test": "jest", + "test:coverage": "jest --coverage" }, - "keywords": ["obsidian", "workout", "fitness", "timer"], + "keywords": [ + "obsidian", + "workout", + "fitness", + "timer" + ], "license": "MIT", "devDependencies": { + "@types/jest": "^30.0.0", + "@types/jsdom": "^28.0.1", "@types/node": "^16.11.6", "esbuild": "0.25.5", + "jest": "^30.3.0", + "jsdom": "^29.0.2", + "ts-jest": "^29.4.9", "tslib": "2.4.0", "typescript": "^5.8.3" }, diff --git a/sample-workout.md b/sample-workout.md index da9837d..17fd35f 100644 --- a/sample-workout.md +++ b/sample-workout.md @@ -1,6 +1,33 @@ # Sample Workouts -## Full Body Strength +## Upper Body Strength (with nested sets) + +```workout +title: Upper Body Strength +state: planned +startDate: +duration: +--- +- [ ] Bench Press | Duration: [300s] + - [ ] Reps: [10] | Weight: [225] | Rest: [90s] + - [ ] Reps: [8] | Weight: [235] | Rest: [90s] + - [ ] Reps: [6] | Weight: [245] | Rest: [120s] +- [ ] Barbell Rows | Duration: [300s] + - [ ] Reps: [10] | Weight: [225] | Rest: [90s] + - [ ] Reps: [8] | Weight: [235] | Rest: [90s] + - [ ] Reps: [6] | Weight: [245] | Rest: [120s] +- [ ] Rest | Duration: [60s] + - [ ] Duration: [60s] +- [ ] Overhead Press | Duration: [180s] + - [ ] Reps: [8] | Weight: [155] | Rest: [60s] + - [ ] Reps: [6] | Weight: [165] | Rest: [120s] +- [ ] Lat Pulldown | Duration: [200s] + - [ ] Reps: [12] | Weight: [180] | Rest: [60s] + - [ ] Reps: [10] | Weight: [200] | Rest: [60s] + - [ ] Reps: [8] | Weight: [220] | Rest: [90s] +``` + +## Full Body Strength (legacy format) ```workout title: Full Body Strength @@ -32,15 +59,19 @@ startDate: duration: --- - [ ] Cat-Cow Stretch | Duration: [60s] -- [ ] World's Greatest Stretch | Reps: [5] /side -- [ ] Rest | Duration: [15s] -- [ ] Hip Circles | Reps: [10] /direction -- [ ] Rest | Duration: [15s] + - [ ] Duration: [60s] +- [ ] World's Greatest Stretch + - [ ] Reps: [5] /side +- [ ] Hip Circles + - [ ] Reps: [10] /direction - [ ] Arm Circles | Duration: [30s] -- [ ] Thoracic Rotations | Reps: [8] /side -- [ ] Rest | Duration: [15s] + - [ ] Duration: [30s] +- [ ] Thoracic Rotations + - [ ] Reps: [8] /side - [ ] Deep Squat Hold | Duration: [45s] -- [ ] Ankle Circles | Reps: [10] /foot + - [ ] Duration: [45s] +- [ ] Ankle Circles + - [ ] Reps: [10] /foot ``` ## Quick HIIT diff --git a/src/__mocks__/obsidian.ts b/src/__mocks__/obsidian.ts new file mode 100644 index 0000000..1fae7c6 --- /dev/null +++ b/src/__mocks__/obsidian.ts @@ -0,0 +1,7 @@ +// Mock for Obsidian API +export class App {} + +export class TFile { + constructor(public path: string) {} +} + diff --git a/src/file/updater.test.ts b/src/file/updater.test.ts new file mode 100644 index 0000000..7813437 --- /dev/null +++ b/src/file/updater.test.ts @@ -0,0 +1,752 @@ +import { FileUpdater } from './updater'; +import { SectionInfo, ParsedWorkout } from '../types'; +import { TFile } from 'obsidian'; + +// Test mock using the mocked TFile class +class MockTFile extends TFile { + constructor(path: string) { + super(path); + } +} + +class MockApp { + private files: Map }> = new Map(); + + setFileContent(path: string, content: string): void { + if (!this.files.has(path)) { + this.files.set(path, { content, frontmatter: {} }); + } else { + const file = this.files.get(path)!; + file.content = content; + } + } + + getFileContent(path: string): string | undefined { + return this.files.get(path)?.content; + } + + getFileFrontmatter(path: string): Record { + return this.files.get(path)?.frontmatter || {}; + } + + vault = { + getAbstractFileByPath: (path: string) => { + return this.files.has(path) ? new MockTFile(path) : null; + }, + process: async (file: MockTFile, callback: (content: string) => string) => { + const fileData = this.files.get(file.path); + if (fileData) { + const result = callback(fileData.content); + fileData.content = result; + } + } + }; + + fileManager = { + processFrontMatter: async (file: MockTFile, callback: (frontmatter: Record) => void) => { + const fileData = this.files.get(file.path); + if (fileData) { + callback(fileData.frontmatter); + } else { + console.error(`File not found in mock: ${file.path}`); + } + } + }; +} + +describe('FileUpdater', () => { + let updater: FileUpdater; + let mockApp: MockApp; + + beforeEach(() => { + mockApp = new MockApp(); + updater = new FileUpdater(mockApp as any); + }); + + describe('normalizeToCamelCase', () => { + it('should convert single word to lowercase', () => { + const result = (updater as any).normalizeToCamelCase('Bench'); + expect(result).toBe('bench'); + }); + + it('should convert multi-word to camelCase with spaces', () => { + const result = (updater as any).normalizeToCamelCase('Bench Press'); + expect(result).toBe('benchPress'); + }); + + it('should convert multi-word with hyphens', () => { + const result = (updater as any).normalizeToCamelCase('Dumb-Bell-Curl'); + expect(result).toBe('dumbBellCurl'); + }); + + it('should convert multi-word with underscores', () => { + const result = (updater as any).normalizeToCamelCase('Leg_Press'); + expect(result).toBe('legPress'); + }); + + it('should handle mixed separators', () => { + const result = (updater as any).normalizeToCamelCase('Cable - Row_Machine'); + expect(result).toBe('cableRowMachine'); + }); + + it('should trim whitespace', () => { + const result = (updater as any).normalizeToCamelCase(' Pull Ups '); + expect(result).toBe('pullUps'); + }); + + it('should preserve case internally', () => { + const result = (updater as any).normalizeToCamelCase('Machine Chest Press'); + expect(result).toBe('machineChestPress'); + }); + }); + + describe('withLock', () => { + it('should execute function successfully', async () => { + const fn = jest.fn(async () => 'result'); + const result = await (updater as any).withLock('file.md', fn); + expect(result).toBe('result'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should serialize concurrent updates to same file', async () => { + const calls: number[] = []; + const fn1 = jest.fn(async () => { + calls.push(1); + await new Promise(r => setTimeout(r, 10)); + calls.push(2); + return 'result1'; + }); + const fn2 = jest.fn(async () => { + calls.push(3); + await new Promise(r => setTimeout(r, 5)); + calls.push(4); + return 'result2'; + }); + + const promise1 = (updater as any).withLock('file.md', fn1); + const promise2 = (updater as any).withLock('file.md', fn2); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + expect(result1).toBe('result1'); + expect(result2).toBe('result2'); + // Verify they ran sequentially: calls should be [1, 2, 3, 4] + expect(calls).toEqual([1, 2, 3, 4]); + }); + + it('should allow parallel updates to different files', async () => { + const calls: string[] = []; + const fn1 = jest.fn(async () => { + calls.push('file1-start'); + await new Promise(r => setTimeout(r, 10)); + calls.push('file1-end'); + return 'result1'; + }); + const fn2 = jest.fn(async () => { + calls.push('file2-start'); + await new Promise(r => setTimeout(r, 10)); + calls.push('file2-end'); + return 'result2'; + }); + + const promise1 = (updater as any).withLock('file1.md', fn1); + const promise2 = (updater as any).withLock('file2.md', fn2); + + await Promise.all([promise1, promise2]); + + // Verify they ran in parallel (interleaved) + expect(calls[0]).toBe('file1-start'); + expect(calls[1]).toBe('file2-start'); + }); + + it('should clean up lock after successful execution', async () => { + const getLocks = () => (updater as any).updateLocks; + expect(getLocks().size).toBe(0); + + await (updater as any).withLock('file.md', async () => 'done'); + + expect(getLocks().size).toBe(0); + }); + + it('should clean up lock after error', async () => { + const getLocks = () => (updater as any).updateLocks; + expect(getLocks().size).toBe(0); + + try { + await (updater as any).withLock('file.md', async () => { + throw new Error('test error'); + }); + } catch (e) { + // Expected + } + + expect(getLocks().size).toBe(0); + }); + }); + + describe('updateCodeBlock', () => { + it('should update code block content successfully', async () => { + const filePath = 'test.md'; + mockApp.setFileContent(filePath, '# Test\n```workout\ntitle: Old\n---\n- [ ] Exercise\n```\nEnd'); + + const sectionInfo: SectionInfo = { + lineStart: 1, + lineEnd: 5 + }; + + const newContent = 'title: New\n---\n- [ ] Exercise Updated'; + + const result = await updater.updateCodeBlock(filePath, sectionInfo, newContent); + + expect(result).toBe(true); + const updatedContent = mockApp.getFileContent(filePath); + expect(updatedContent).toContain('title: New'); + expect(updatedContent).toContain('Exercise Updated'); + }); + + it('should return false if file not found', async () => { + const sectionInfo: SectionInfo = { + lineStart: 0, + lineEnd: 5 + }; + + const result = await updater.updateCodeBlock('nonexistent.md', sectionInfo, 'content'); + + expect(result).toBe(false); + }); + + it('should return false if sectionInfo is null', async () => { + mockApp.setFileContent('test.md', 'content'); + + const result = await updater.updateCodeBlock('test.md', null, 'content'); + + expect(result).toBe(false); + }); + + it('should return false if workout code block is missing', async () => { + const filePath = 'test.md'; + mockApp.setFileContent(filePath, '# Test\nNo code block here\n'); + + const sectionInfo: SectionInfo = { + lineStart: 1, + lineEnd: 5 + }; + + const result = await updater.updateCodeBlock(filePath, sectionInfo, 'new content'); + + expect(result).toBe(false); + }); + + it('should validate expected title matches', async () => { + const filePath = 'test.md'; + mockApp.setFileContent(filePath, '# Test\n```workout\ntitle: MyWorkout\n---\n- [ ] Exercise\n```\n'); + + const sectionInfo: SectionInfo = { + lineStart: 1, + lineEnd: 5 + }; + + const result = await updater.updateCodeBlock( + filePath, + sectionInfo, + 'title: MyWorkout\nnew content', + 'MyWorkout' + ); + + expect(result).toBe(true); + }); + + it('should return false if title does not match expected', async () => { + const filePath = 'test.md'; + mockApp.setFileContent(filePath, '# Test\n```workout\ntitle: OldTitle\n---\n- [ ] Exercise\n```\n'); + + const sectionInfo: SectionInfo = { + lineStart: 1, + lineEnd: 5 + }; + + const result = await updater.updateCodeBlock( + filePath, + sectionInfo, + 'new content', + 'ExpectedTitle' + ); + + expect(result).toBe(false); + // Content should remain unchanged + const content = mockApp.getFileContent(filePath); + expect(content).toContain('OldTitle'); + }); + + it('should preserve content before and after code block', async () => { + const filePath = 'test.md'; + mockApp.setFileContent(filePath, '# Header\nSome text\n```workout\nold content\n```\nMore text'); + + const sectionInfo: SectionInfo = { + lineStart: 2, + lineEnd: 4 + }; + + await updater.updateCodeBlock(filePath, sectionInfo, 'new content'); + + const result = mockApp.getFileContent(filePath); + expect(result).toContain('# Header'); + expect(result).toContain('Some text'); + expect(result).toContain('More text'); + expect(result).toContain('new content'); + expect(result).not.toContain('old content'); + }); + + it('should handle multi-line code block updates', async () => { + const filePath = 'test.md'; + const oldContent = '```workout\ntitle: Test\n---\n- [ ] Ex1\n- [ ] Ex2\n```'; + mockApp.setFileContent(filePath, oldContent); + + const sectionInfo: SectionInfo = { + lineStart: 0, + lineEnd: 5 + }; + + const newContent = 'title: Test\n---\n- [x] Ex1\n- [ ] Ex2 Updated'; + + await updater.updateCodeBlock(filePath, sectionInfo, newContent); + + const result = mockApp.getFileContent(filePath); + expect(result).toContain('- [x] Ex1'); + expect(result).toContain('- [ ] Ex2 Updated'); + }); + }); + + describe('insertLineAfter', () => { + it('should insert line after specified relative line index', async () => { + const filePath = 'test.md'; + mockApp.setFileContent(filePath, 'Line 1\n```workout\nLine 1\nLine 2\nLine 3\n```\nLine 4'); + + const sectionInfo: SectionInfo = { + lineStart: 1, + lineEnd: 5 + }; + + await updater.insertLineAfter(filePath, sectionInfo, 0, 'Inserted'); + + const result = mockApp.getFileContent(filePath); + const lines = result.split('\n'); + + // sectionInfo.lineStart is 1 (```workout) + // relativeLineIndex 0 refers to index 0 inside code block (Line 1) + // Should insert at absolute line 3 (after Line 1) + expect(lines[3]).toBe('Inserted'); + }); + + it('should return silently if file not found', async () => { + const sectionInfo: SectionInfo = { + lineStart: 0, + lineEnd: 5 + }; + + await updater.insertLineAfter('nonexistent.md', sectionInfo, 0, 'new line'); + // Should not throw + }); + + it('should return silently if sectionInfo is null', async () => { + mockApp.setFileContent('test.md', 'content'); + + await updater.insertLineAfter('test.md', null, 0, 'new line'); + // Should not throw + }); + + it('should handle insertion at different relative indices', async () => { + const filePath = 'test.md'; + mockApp.setFileContent(filePath, '```workout\nLine 1\nLine 2\nLine 3\n```'); + + const sectionInfo: SectionInfo = { + lineStart: 0, + lineEnd: 4 + }; + + await updater.insertLineAfter(filePath, sectionInfo, 2, 'Inserted at end'); + + const result = mockApp.getFileContent(filePath); + const lines = result.split('\n'); + + // relativeLineIndex 2 is Line 3, insert after it + expect(lines).toContain('Inserted at end'); + }); + }); + + describe('saveToProperties', () => { + it('should not save properties if saveToProperties is false', async () => { + const filePath = 'test.md'; + mockApp.setFileContent(filePath, 'content'); + + const parsed: ParsedWorkout = { + metadata: { + title: 'Test', + state: 'started', + saveToProperties: false + }, + exercises: [], + rawLines: [], + metadataEndIndex: -1 + }; + + await updater.saveToProperties(filePath, parsed); + + const frontmatter = mockApp.getFileFrontmatter(filePath); + expect(frontmatter).toEqual({}); + }); + + it('should save metadata properties', async () => { + const filePath = 'test.md'; + mockApp.setFileContent(filePath, 'content'); + + const parsed: ParsedWorkout = { + metadata: { + title: 'My Workout', + state: 'started', + startDate: '2026-04-08 10:00', + duration: '30m', + restDuration: 300, + saveToProperties: true + }, + exercises: [], + rawLines: [], + metadataEndIndex: -1 + }; + + await updater.saveToProperties(filePath, parsed); + + const frontmatter = mockApp.getFileFrontmatter(filePath); + expect(frontmatter.workoutTitle).toBe('My Workout'); + expect(frontmatter.workoutState).toBe('started'); + expect(frontmatter.workoutStartDate).toBe('2026-04-08 10:00'); + expect(frontmatter.workoutDuration).toBe('30m'); + expect(frontmatter.workoutRestDuration).toBe(300); + }); + + it('should save exercise data to properties', async () => { + const filePath = 'test.md'; + mockApp.setFileContent(filePath, 'content'); + + const parsed: ParsedWorkout = { + metadata: { + title: 'Test', + state: 'started', + saveToProperties: true + }, + exercises: [ + { + name: 'Bench Press', + state: 'completed', + recordedTime: '10m', + params: [], + lineIndex: 0, + sets: [ + { + state: 'completed', + params: [ + { key: 'Duration', value: '120', unit: 's', editable: false } + ], + lineIndex: 1 + } + ] + } + ], + rawLines: [], + metadataEndIndex: -1 + }; + + await updater.saveToProperties(filePath, parsed); + + const frontmatter = mockApp.getFileFrontmatter(filePath); + expect(frontmatter.workoutExercises).toBeDefined(); + expect(Array.isArray(frontmatter.workoutExercises)).toBe(true); + + const exercises = frontmatter.workoutExercises as any[]; + expect(exercises).toHaveLength(1); + expect(exercises[0].name).toBe('Bench Press'); + expect(exercises[0].state).toBe('completed'); + expect(exercises[0].recordedDuration).toBe('10m'); + }); + + it('should calculate exercise totals for weight', async () => { + const filePath = 'test.md'; + mockApp.setFileContent(filePath, 'content'); + + const parsed: ParsedWorkout = { + metadata: { + title: 'Test', + state: 'started', + saveToProperties: true + }, + exercises: [ + { + name: 'Dumb Bell Curl', + state: 'completed', + params: [], + lineIndex: 0, + sets: [ + { + state: 'completed', + params: [ + { key: 'Weight', value: '10', unit: 'kg', editable: true }, + { key: 'Reps', value: '10', unit: '', editable: true } + ], + lineIndex: 1 + }, + { + state: 'completed', + params: [ + { key: 'Weight', value: '12', unit: 'kg', editable: true }, + { key: 'Reps', value: '8', unit: '', editable: true } + ], + lineIndex: 2 + } + ] + } + ], + rawLines: [], + metadataEndIndex: -1 + }; + + await updater.saveToProperties(filePath, parsed); + + const frontmatter = mockApp.getFileFrontmatter(filePath); + expect(frontmatter.dumbBellCurlTotalWeight).toBe(22); + expect(frontmatter.dumbBellCurlTotalReps).toBe(18); + }); + + it('should calculate exercise totals for duration', async () => { + const filePath = 'test.md'; + mockApp.setFileContent(filePath, 'content'); + + const parsed: ParsedWorkout = { + metadata: { + title: 'Test', + state: 'started', + saveToProperties: true + }, + exercises: [ + { + name: 'Treadmill', + state: 'completed', + params: [], + lineIndex: 0, + sets: [ + { + state: 'completed', + params: [ + { key: 'Duration', value: '300', unit: '', editable: true } + ], + lineIndex: 1 + }, + { + state: 'completed', + params: [ + { key: 'Duration', value: '180', unit: '', editable: true } + ], + lineIndex: 2 + } + ] + } + ], + rawLines: [], + metadataEndIndex: -1 + }; + + await updater.saveToProperties(filePath, parsed); + + const frontmatter = mockApp.getFileFrontmatter(filePath); + expect(frontmatter.treadmillTotalDuration).toBe(480); + }); + + it('should handle multiple exercises with different parameters', async () => { + const filePath = 'test.md'; + mockApp.setFileContent(filePath, 'content'); + + const parsed: ParsedWorkout = { + metadata: { + title: 'Test', + state: 'started', + saveToProperties: true + }, + exercises: [ + { + name: 'Bench Press', + state: 'completed', + params: [], + lineIndex: 0, + sets: [ + { + state: 'completed', + params: [ + { key: 'Weight', value: '100', unit: 'kg', editable: true }, + { key: 'Reps', value: '5', unit: '', editable: true } + ], + lineIndex: 1 + } + ] + }, + { + name: 'Squats', + state: 'completed', + params: [], + lineIndex: 2, + sets: [ + { + state: 'completed', + params: [ + { key: 'Weight', value: '150', unit: 'kg', editable: true }, + { key: 'Reps', value: '8', unit: '', editable: true } + ], + lineIndex: 3 + } + ] + } + ], + rawLines: [], + metadataEndIndex: -1 + }; + + await updater.saveToProperties(filePath, parsed); + + const frontmatter = mockApp.getFileFrontmatter(filePath); + expect(frontmatter.benchPressTotalWeight).toBe(100); + expect(frontmatter.benchPressTotalReps).toBe(5); + expect(frontmatter.squatsTotalWeight).toBe(150); + expect(frontmatter.squatsTotalReps).toBe(8); + }); + + it('should skip invalid numeric values', async () => { + const filePath = 'test.md'; + mockApp.setFileContent(filePath, 'content'); + + const parsed: ParsedWorkout = { + metadata: { + title: 'Test', + state: 'started', + saveToProperties: true + }, + exercises: [ + { + name: 'Test', + state: 'completed', + params: [], + lineIndex: 0, + sets: [ + { + state: 'completed', + params: [ + { key: 'Weight', value: 'invalid', unit: 'kg', editable: true }, + { key: 'Reps', value: '10', unit: '', editable: true } + ], + lineIndex: 1 + } + ] + } + ], + rawLines: [], + metadataEndIndex: -1 + }; + + await updater.saveToProperties(filePath, parsed); + + const frontmatter = mockApp.getFileFrontmatter(filePath); + expect(frontmatter.testTotalWeight).toBeUndefined(); + expect(frontmatter.testTotalReps).toBe(10); + }); + + it('should not add zero totals', async () => { + const filePath = 'test.md'; + mockApp.setFileContent(filePath, 'content'); + + const parsed: ParsedWorkout = { + metadata: { + title: 'Test', + state: 'started', + saveToProperties: true + }, + exercises: [ + { + name: 'NoParams', + state: 'completed', + params: [], + lineIndex: 0, + sets: [ + { + state: 'completed', + params: [], + lineIndex: 1 + } + ] + } + ], + rawLines: [], + metadataEndIndex: -1 + }; + + await updater.saveToProperties(filePath, parsed); + + const frontmatter = mockApp.getFileFrontmatter(filePath); + expect(frontmatter.noParamsTotalWeight).toBeUndefined(); + expect(frontmatter.noParamsTotalReps).toBeUndefined(); + expect(frontmatter.noParamsTotalDuration).toBeUndefined(); + }); + + it('should return silently if file not found', async () => { + const parsed: ParsedWorkout = { + metadata: { + title: 'Test', + state: 'started', + saveToProperties: true + }, + exercises: [], + rawLines: [], + metadataEndIndex: -1 + }; + + await updater.saveToProperties('nonexistent.md', parsed); + // Should not throw + }); + + it('should handle exercises with recorded duration in set params', async () => { + const filePath = 'test.md'; + mockApp.setFileContent(filePath, 'content'); + + const parsed: ParsedWorkout = { + metadata: { + title: 'Test', + state: 'started', + saveToProperties: true + }, + exercises: [ + { + name: 'Exercise', + state: 'completed', + recordedTime: '10m', + params: [], + lineIndex: 0, + sets: [ + { + state: 'completed', + params: [ + { key: 'Duration', value: '300s', unit: '', editable: false } + ], + lineIndex: 1 + } + ] + } + ], + rawLines: [], + metadataEndIndex: -1 + }; + + await updater.saveToProperties(filePath, parsed); + + const frontmatter = mockApp.getFileFrontmatter(filePath); + const exercises = frontmatter.workoutExercises as any[]; + expect(exercises[0].recordedDuration).toBe('10m'); + expect(exercises[0].sets[0].recordedDuration).toBe('300s'); + }); + }); +}); diff --git a/src/file/updater.ts b/src/file/updater.ts index 6e52fd8..f544168 100644 --- a/src/file/updater.ts +++ b/src/file/updater.ts @@ -1,12 +1,93 @@ +/** + * File update operations for workout blocks. + * + * Manages persisting changes back to the Obsidian vault with: + * - Code block content replacement (workout markdown source) + * - Line insertion (for adding exercises/sets) + * - Frontmatter/properties export (optional feature for external tools) + * - Concurrency control (locks) to prevent race conditions on simultaneous edits + * - Validation checks (stale section detection, title matching) + * + * Architecture: + * - FileUpdater class wraps Obsidian's app.vault.process() API + * - withLock() serializes updates to prevent concurrent modifications + * - updateCodeBlock() validates sectionInfo before updating + * - saveToProperties() converts parsed data to file frontmatter + */ + import { App, TFile } from 'obsidian'; -import { SectionInfo } from '../types'; +import { SectionInfo, ParsedWorkout } from '../types'; +/** + * Manages file update operations with concurrency control. + * + * Public methods: + * - updateCodeBlock() - Replace workout code block content + * - insertLineAfter() - Insert a new line within code block + * - saveToProperties() - Export workout data to file frontmatter + * + * Private helpers: + * - normalizeToCamelCase() - Convert exercise names to property keys + * - withLock() - Serialize updates to prevent races + */ export class FileUpdater { private updateLocks = new Map>(); + /** + * Create a FileUpdater instance. + * + * Parameters: + * - app: Obsidian App instance for vault operations + */ constructor(private app: App) {} - // Serialize updates to the same file to prevent race conditions + /** + * Normalize exercise name to camelCase for use as property keys. + * + * Converts spaces, hyphens, underscores to camelCase: + * - "Push ups" → "pushUps" + * - "bench-press" → "benchPress" + * - "leg_raises" → "legRaises" + * + * Parameters: + * - name: Exercise name + * + * Returns: camelCase version suitable for object keys + */ + private normalizeToCamelCase(name: string): string { + return name + .trim() + .split(/[\s\-_]+/) // Split on spaces, hyphens, underscores + .map((word, index) => { + if (index === 0) { + // First word: lowercase + return word.toLowerCase(); + } + // Following words: capitalize first letter + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }) + .join(''); + } + + /** + * Serialize updates to the same file to prevent race conditions. + * + * When multiple save operations occur simultaneously, this ensures they execute + * sequentially. Uses a Map of locks keyed by filePath. + * + * Flow: + * 1. Wait for any pending update on this file + * 2. Create a new lock promise + * 3. Execute the provided function + * 4. Resolve the lock when done + * 5. Clean up the lock entry + * + * Parameters: + * - filePath: File path to lock + * - fn: Async function to execute under lock + * + * Returns: Result from fn() + */ private async withLock(filePath: string, fn: () => Promise): Promise { // Wait for any pending update to complete const pending = this.updateLocks.get(filePath); @@ -22,14 +103,35 @@ export class FileUpdater { try { return await fn(); } finally { + // Signal lock completion resolve!(); - // Clean up if this is still our lock + // Clean up if this is still our lock (prevent stale cleanup) if (this.updateLocks.get(filePath) === lock) { this.updateLocks.delete(filePath); } } } + /** + * Replace the content of a workout code block. + * + * Updates the markdown source between ```workout fences while preserving fence lines. + * Includes validation to detect stale updates. + * + * Validation checks: + * - File exists and is a TFile + * - sectionInfo is valid (not null) + * - Code block still starts at specified lineStart (detects stale renders) + * - Optional: title matches expectedTitle (detects conflicting updates) + * + * Parameters: + * - sourcePath: File path to update + * - sectionInfo: Section location (lineStart, lineEnd) or null if unknown + * - newContent: New content to place between code fences + * - expectedTitle: Optional title to validate (if provided, must match) + * + * Returns: true if update succeeded, false if validation failed + */ async updateCodeBlock( sourcePath: string, sectionInfo: SectionInfo | null, @@ -53,7 +155,8 @@ export class FileUpdater { await this.app.vault.process(file, (content) => { const lines = content.split('\n'); - // Validate that the target location still has a workout code block + // Validate that target location still has a workout code block + // This detects stale renders (sectionInfo from before re-render) const startLine = lines[sectionInfo.lineStart]; if (!startLine || !startLine.trim().startsWith('```workout')) { console.error('Workout Log: Stale sectionInfo - expected ```workout at line', sectionInfo.lineStart, '. Try navigating away and back.'); @@ -61,6 +164,7 @@ export class FileUpdater { } // If we have an expected title, validate it matches + // This detects conflicting updates from simultaneous editing if (expectedTitle) { const blockContent = lines.slice(sectionInfo.lineStart + 1, sectionInfo.lineEnd).join('\n'); const titleMatch = blockContent.match(/^title:\s*(.+)$/m); @@ -93,6 +197,23 @@ export class FileUpdater { return updateSucceeded; } + /** + * Insert a new line within a code block. + * + * Adds a line at the specified position (relative to code block start). + * Used for adding exercises or sets to existing workouts. + * + * Position calculation: + * - sectionInfo.lineStart = line with ```workout marker + * - relativeLineIndex = position within code block (0 = first content line) + * - Absolute line = lineStart + 1 + relativeLineIndex + * + * Parameters: + * - sourcePath: File path to update + * - sectionInfo: Section location or null + * - relativeLineIndex: Line position relative to code block content start + * - newLine: Line content to insert + */ async insertLineAfter( sourcePath: string, sectionInfo: SectionInfo | null, @@ -113,7 +234,7 @@ export class FileUpdater { await this.app.vault.process(file, (content) => { const lines = content.split('\n'); - // Calculate absolute line number + // Calculate absolute line number within the file // sectionInfo.lineStart is the ```workout line // relativeLineIndex is relative to inside the code block const absoluteLineIndex = sectionInfo.lineStart + 1 + relativeLineIndex; @@ -124,4 +245,139 @@ export class FileUpdater { return lines.join('\n'); }); } + + /** + * Export workout data to file frontmatter/properties. + * + * Optional feature: only runs if parsed.metadata.saveToProperties is true. + * + * Exported properties: + * - Workout-level: workoutTitle, workoutState, workoutStartDate, workoutDuration, workoutRestDuration + * - Exercise data: workoutExercises (array with name, state, recorded times) + * - Exercise totals: [exerciseName]TotalWeight, [exerciseName]TotalReps, [exerciseName]TotalDuration + * (names converted to camelCase, e.g., "Push ups" → "pushUps") + * + * Use cases: + * - Dataview queries on workout properties + * - External tools accessing file properties + * - Analytics on exercise performance + * + * Parameters: + * - sourcePath: File path to update + * - parsed: ParsedWorkout with data to export + */ + async saveToProperties(sourcePath: string, parsed: ParsedWorkout): Promise { + const file = this.app.vault.getAbstractFileByPath(sourcePath); + if (!(file instanceof TFile)) { + console.error('Workout Log: Cannot save to properties - file not found:', sourcePath); + return; + } + + // Only proceed if saveToProperties is explicitly true + if (!parsed.metadata.saveToProperties) { + return; + } + + await this.withLock(sourcePath, async () => { + // Build properties object from parsed workout data + const properties: Record = {}; + + const metadata = parsed.metadata; + + // Workout-level metadata + if (metadata.title) { + properties.workoutTitle = metadata.title; + } + if (metadata.state) { + properties.workoutState = metadata.state; + } + if (metadata.startDate) { + properties.workoutStartDate = metadata.startDate; + } + if (metadata.duration) { + properties.workoutDuration = metadata.duration; + } + if (metadata.restDuration !== undefined) { + properties.workoutRestDuration = metadata.restDuration; + } + + // Exercise data (overall structure) + if (parsed.exercises.length > 0) { + const exercises = parsed.exercises.map(exercise => ({ + name: exercise.name, + state: exercise.state, + recordedDuration: exercise.recordedTime, + sets: exercise.sets.map(set => { + // Extract recorded duration from params if it exists + const durationParam = set.params.find(p => p.key.toLowerCase() === 'duration' && !p.editable); + return { + state: set.state, + recordedDuration: durationParam?.value + }; + }) + })); + properties.workoutExercises = exercises; + + // Calculate and add per-exercise totals + for (const exercise of parsed.exercises) { + const normalizedName = this.normalizeToCamelCase(exercise.name); + + // Aggregate totals from all sets of this exercise + let totalWeight = 0; + let totalReps = 0; + let totalDuration = 0; + + for (const set of exercise.sets) { + // Extract weight from set params + const weightParam = set.params.find(p => p.key.toLowerCase() === 'weight'); + if (weightParam && weightParam.value) { + const weight = parseFloat(weightParam.value); + if (!isNaN(weight)) { + totalWeight += weight; + } + } + + // Extract reps from set params + const repsParam = set.params.find(p => p.key.toLowerCase() === 'reps'); + if (repsParam && repsParam.value) { + const reps = parseInt(repsParam.value, 10); + if (!isNaN(reps)) { + totalReps += reps; + } + } + + // Extract duration from set params + const durationParam = set.params.find(p => p.key.toLowerCase() === 'duration'); + if (durationParam && durationParam.value) { + const duration = parseInt(durationParam.value, 10); + if (!isNaN(duration)) { + totalDuration += duration; + } + } + } + + // Add properties for this exercise if any totals exist + if (totalWeight > 0) { + properties[`${normalizedName}TotalWeight`] = totalWeight; + } + if (totalReps > 0) { + properties[`${normalizedName}TotalReps`] = totalReps; + } + if (totalDuration > 0) { + properties[`${normalizedName}TotalDuration`] = totalDuration; + } + } + } + + // Update file frontmatter with properties + try { + await this.app.fileManager.processFrontMatter(file, (frontmatter) => { + // Merge properties into frontmatter + Object.assign(frontmatter, properties); + }); + } catch (error) { + console.error('Workout Log: Failed to save properties to file:', error); + } + }); + } } diff --git a/src/main.ts b/src/main.ts index 6c108ef..219be03 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,63 +1,150 @@ import { Plugin, MarkdownPostProcessorContext } from 'obsidian'; import { parseWorkout } from './parser'; -import { serializeWorkout, updateParamValue, updateExerciseState, addSet, addRest, setRecordedDuration, lockAllFields, createSampleWorkout } from './serializer'; +import { serializeWorkout, updateParamValue, updateSetParamValue, updateExerciseState, updateSetState, addSet, addRest, setRecordedDuration, setSetRecordedDuration, setSetRecordedRest, lockAllFields, createSampleWorkout } from './serializer'; import { renderWorkout } from './renderer'; import { TimerManager } from './timer/manager'; import { FileUpdater } from './file/updater'; -import { ParsedWorkout, WorkoutCallbacks, SectionInfo } from './types'; -import { formatDurationHuman } from './parser/exercise'; +import { ParsedWorkout, WorkoutCallbacks, SectionInfo, Exercise } from './types'; +import { formatDurationHuman, parseDurationToSeconds } from './parser/exercise'; export default class WorkoutLogPlugin extends Plugin { private timerManager: TimerManager = new TimerManager(); private fileUpdater: FileUpdater | null = null; + /** + * Called when the plugin loads. Initializes the file updater and registers the workout code block processor. + * After this, Obsidian will call our processor whenever it encounters a ```workout code block. + */ async onload(): Promise { this.fileUpdater = new FileUpdater(this.app); - // Register the workout code block processor + // Register the markdown code block processor for 'workout' blocks + // Tells Obsidian: "When you find a code block of type 'workout', call processWorkoutBlock" this.registerMarkdownCodeBlockProcessor('workout', (source, el, ctx) => { this.processWorkoutBlock(source, el, ctx); }); } + /** + * Called when the plugin unloads. Cleans up resources, especially the timer manager. + * Ensures all active timers are stopped and cleaned up gracefully. + */ onunload(): void { this.timerManager.destroy(); } + /** + * Processes a single workout code block when Obsidian renders it. + * This is the main entry point for each workout workout visible on screen. + * + * Key responsibilities: + * 1. Parse the markdown source into structured workout data + * 2. Generate unique ID for this workout (file path + line number) + * 3. Sync timer state with file state (handles undo/external edits) + * 4. Create callbacks for user interactions + * 5. Render the UI and connect timers + * + * @param source - Raw markdown text from the code block + * @param containerElement - DOM element where UI should be rendered + * @param ctx - Obsidian context (has file path, section info, etc) + */ private processWorkoutBlock( source: string, - el: HTMLElement, + containerElement: HTMLElement, ctx: MarkdownPostProcessorContext ): void { + // Parse markdown source into ParsedWorkout structure const parsed = parseWorkout(source); - const sectionInfo = ctx.getSectionInfo(el) as SectionInfo | null; + const sectionInfo = ctx.getSectionInfo(containerElement) as SectionInfo | null; // Warn if sectionInfo is null - this can cause issues with multiple workouts if (!sectionInfo) { console.warn('Workout Log: sectionInfo is null for', ctx.sourcePath, '- file updates may not work correctly'); } + // Detect if the parser had to auto-generate sets for legacy/shorthand exercises + // Auto-generated sets inherit the exact lineIndex of their parent exercise + const hasAutoGeneratedSets = parsed.exercises.some( + ex => ex.sets.length > 0 && ex.sets[0]?.lineIndex === ex.lineIndex + ); + + if (hasAutoGeneratedSets && sectionInfo) { + // Schedule an update to explicitly write the sets back to the file + setTimeout(() => { + this.updateFileWithParsed(ctx, sectionInfo, parsed).catch(console.error); + }, 100); + } + + // Generate unique workout ID: prevents timer confusion when multiple workouts exist in same file + // Format: "path/to/file.md:123" where 123 is the line number of workout block start const workoutId = `${ctx.sourcePath}:${sectionInfo?.lineStart ?? 0}`; - // Sync timer state with parsed state (handles undo/external changes) + // Sync timer state with file state (critical for handling undo/external changes) + // If user hit undo, file may have changed but timer is still running const isTimerRunning = this.timerManager.isTimerRunning(workoutId); if (isTimerRunning && parsed.metadata.state !== 'started') { - // File was reverted to non-started state, stop the timer + // File was reverted to non-started state (user hit undo), stop the timer this.timerManager.stopWorkoutTimer(workoutId); } else if (isTimerRunning && parsed.metadata.state === 'started') { - // Sync active exercise index with parsed state (handles undo of exercise actions) + // Timer is running and file is still started. Check if active exercise changed due to undo const parsedActiveIndex = parsed.exercises.findIndex(e => e.state === 'inProgress'); const timerActiveIndex = this.timerManager.getActiveExerciseIndex(workoutId); if (parsedActiveIndex >= 0 && parsedActiveIndex !== timerActiveIndex) { - // Active exercise changed externally (undo), update timer + // Active exercise changed externally (user hit undo on exercise action), resync timer this.timerManager.setActiveExerciseIndex(workoutId, parsedActiveIndex); } + } else if (!isTimerRunning && parsed.metadata.state === 'started') { + // Timer was destroyed (e.g., Obsidian restart or 5-minute timeout), but workout is started. + // Auto-resume the timer so the user can continue without losing their progress. + const parsedActiveIndex = parsed.exercises.findIndex(e => e.state === 'inProgress'); + const activeIndex = Math.max(0, parsedActiveIndex); + + // Calculate total elapsed time from completed exercises and sets + let elapsedWorkoutSeconds = 0; + for (const ex of parsed.exercises) { + if (ex.state === 'completed') { + if (ex.recordedTime) elapsedWorkoutSeconds += parseDurationToSeconds(ex.recordedTime); + if (ex.recordedRest) elapsedWorkoutSeconds += parseDurationToSeconds(ex.recordedRest); + } else if (ex.state === 'inProgress') { + for (const set of ex.sets) { + if (set.state === 'completed') { + if (set.recordedTime) elapsedWorkoutSeconds += parseDurationToSeconds(set.recordedTime); + if (set.recordedRest) elapsedWorkoutSeconds += parseDurationToSeconds(set.recordedRest); + } + } + } + } + + // Recreate the timer at the correct exercise, restoring the total workout time + this.timerManager.startWorkoutTimer(workoutId, activeIndex, elapsedWorkoutSeconds); + + // Sync to the correct active set within the exercise + const activeExercise = parsed.exercises[activeIndex]; + if (activeExercise && activeExercise.sets.length > 0) { + let parsedSetIndex = activeExercise.sets.findIndex(s => s.state === 'inProgress'); + + if (parsedSetIndex === -1) { + // If no set is inProgress (e.g., app closed during a rest period), find the first pending set + parsedSetIndex = activeExercise.sets.findIndex(s => s.state === 'pending'); + } + + if (parsedSetIndex === -1) { + // If all sets are completed but exercise is inProgress (closed during final rest) + parsedSetIndex = activeExercise.sets.length - 1; + } + + if (parsedSetIndex > 0) { + this.timerManager.advanceSet(workoutId, activeIndex, parsedSetIndex); + } + } } + // Create the callback functions that will handle user interactions const callbacks = this.createCallbacks(ctx, sectionInfo, parsed, workoutId); + // Render the UI and wire up the timers and callbacks renderWorkout({ - el, + containerElement, parsed, callbacks, workoutId, @@ -65,244 +152,575 @@ export default class WorkoutLogPlugin extends Plugin { }); } + // ==================== HELPER METHODS ==================== + /** + * Finds the next pending exercise after the given index. + * Used to determine which exercise to activate when current one finishes. + * @param afterIndex - Start searching after this exercise index + * @param exercises - Array of exercises to search + * @returns Index of next pending exercise, or -1 if none found + */ + private findNextPendingExercise(afterIndex: number, exercises: Exercise[]): number { + return exercises.findIndex((e, i) => i > afterIndex && e.state === 'pending'); + } + + /** + * Serializes the current parsed workout state and saves it back to the file. + * This is the main persistence mechanism - all state changes flow through here. + * Also handles optional saving to Obsidian properties (front matter) if enabled. + * @param ctx - Markdown processor context with file info + * @param sectionInfo - Section location in file (for updating the correct code block) + * @param parsed - Current workout state to persist + */ + private async updateFileWithParsed( + ctx: MarkdownPostProcessorContext, + sectionInfo: SectionInfo | null, + parsed: ParsedWorkout + ): Promise { + const newContent = serializeWorkout(parsed); + const expectedTitle = parsed.metadata.title; + // Update the code block with new content, title validation prevents cross-contamination + await this.fileUpdater?.updateCodeBlock(ctx.sourcePath, sectionInfo, newContent, expectedTitle); + + // Optionally save metadata to Obsidian file properties (front matter) + if (parsed.metadata.saveToProperties) { + await this.fileUpdater?.saveToProperties(ctx.sourcePath, parsed); + } + } + + // ==================== CALLBACK HANDLERS ==================== + // These methods handle major workout flow events (start, finish, exercise transitions). + // Each one updates the parsed state and persists changes to the file. + + /** + * Handles workout start: sets metadata, activates first exercise, starts timers. + * Flow: Set state → Find first exercise → Activate it → Save → Start timers + */ + private async handleStartWorkout( + currentParsed: ParsedWorkout, + ctx: MarkdownPostProcessorContext, + sectionInfo: SectionInfo | null, + workoutId: string + ): Promise { + // Mark workout as started and record start time + currentParsed.metadata.state = 'started'; + currentParsed.metadata.startDate = this.formatStartDate(new Date()); + + // Find and activate the first pending exercise + const firstPending = currentParsed.exercises.findIndex(e => e.state === 'pending'); + if (firstPending >= 0) { + const exercise = currentParsed.exercises[firstPending]; + if (exercise) { + exercise.state = 'inProgress'; + } + } + + // Persist to file (state change must happen before timer starts) + await this.updateFileWithParsed(ctx, sectionInfo, currentParsed); + + // Start the workout timer at the first exercise + this.timerManager.startWorkoutTimer(workoutId, firstPending >= 0 ? firstPending : 0); + } + + /** + * Handles workout completion: calculates total time, locks fields, saves state. + * Flow: Get elapsed time → Mark completed → Lock all params → Save → Stop timers + */ + private async handleFinishWorkout( + currentParsed: ParsedWorkout, + ctx: MarkdownPostProcessorContext, + sectionInfo: SectionInfo | null, + workoutId: string + ): Promise { + // Fetch total elapsed time from timer + const timerState = this.timerManager.getTimerState(workoutId); + if (timerState) { + currentParsed.metadata.duration = formatDurationHuman(timerState.workoutElapsed); + } + + // Mark workout as completed + currentParsed.metadata.state = 'completed'; + + // Lock all fields to prevent further edits once workout is done + currentParsed = lockAllFields(currentParsed); + + // Persist final state to file + await this.updateFileWithParsed(ctx, sectionInfo, currentParsed); + + // Stop the workout timer in the timer manager + this.timerManager.stopWorkoutTimer(workoutId); + } + + /** + * Handles set completion: records duration, handles rest periods, advances to next set or exercise. + * This is the most complex flow with multiple branching scenarios: + * - If more sets exist in exercise: Check for rest period, start rest or advance to next set + * - If last set in exercise: Mark exercise done, find next exercise or complete workout + * + * @returns Updated ParsedWorkout for callback closure to capture + */ + private async handleSetFinish( + exerciseIndex: number, + setIndex: number, + currentParsed: ParsedWorkout, + ctx: MarkdownPostProcessorContext, + sectionInfo: SectionInfo | null, + workoutId: string, + handleRestStart: (exerciseIndex: number, restDuration: number) => Promise + ): Promise { + const exercise = currentParsed.exercises[exerciseIndex]; + if (!exercise) return currentParsed; + + const timerState = this.timerManager.getTimerState(workoutId); + + // Step 1: Record how long this set actually took (from timer) + if (timerState) { + currentParsed = setSetRecordedDuration( + currentParsed, + exerciseIndex, + setIndex, + formatDurationHuman(timerState.exerciseElapsed) + ); + } + + // Step 2: Mark current set as completed + currentParsed = updateSetState(currentParsed, exerciseIndex, setIndex, 'completed'); + + // Step 3: Check if current set has a rest period - logic is identical for both last and non-last sets + const set = exercise.sets[setIndex]; + if (!set) return currentParsed; + const restParam = set.params.find(p => p.key.toLowerCase() === 'rest'); + + if (restParam) { + // Has rest period - save state first, then start rest timer + // This path applies whether it's the last set or not - handleRestEnd handles the logic after rest + await this.updateFileWithParsed(ctx, sectionInfo, currentParsed); + const restDurationSeconds = parseDurationToSeconds(restParam.value); + await handleRestStart(exerciseIndex, restDurationSeconds); + } else if (setIndex < exercise.sets.length - 1) { + // No rest AND more sets exist - advance immediately to next set + const nextSetIndex = setIndex + 1; + currentParsed = updateSetState(currentParsed, exerciseIndex, nextSetIndex, 'inProgress'); + this.timerManager.advanceSet(workoutId, exerciseIndex, nextSetIndex); + await this.updateFileWithParsed(ctx, sectionInfo, currentParsed); + } else { + // No rest AND last set - mark exercise as completed and look for next exercise + currentParsed = updateExerciseState(currentParsed, exerciseIndex, 'completed'); + + const nextPending = this.findNextPendingExercise(exerciseIndex, currentParsed.exercises); + + if (nextPending >= 0) { + // Found next exercise - activate it + currentParsed = updateExerciseState(currentParsed, nextPending, 'inProgress'); + this.timerManager.advanceExercise(workoutId, nextPending); + await this.updateFileWithParsed(ctx, sectionInfo, currentParsed); + } else { + // No more exercises - complete the entire workout + await this.completeWorkout(currentParsed, ctx, sectionInfo, workoutId); + } + } + + return currentParsed; + } + + /** + * Handles rest period initiation: starts the rest timer in the timer manager. + * Called after a set is completed if a rest period is defined. + */ + private async handleRestStart( + ctx: MarkdownPostProcessorContext, + sectionInfo: SectionInfo | null, + workoutId: string, + exerciseIndex: number, + restDuration: number + ): Promise { + // Start counting down the rest period + this.timerManager.startRest(workoutId, restDuration); + } + + /** + * Handles rest period completion: advances to next set or exercise. + * Called when user finishes rest between sets. Similar branching to handleSetFinish. + */ + private async handleRestEnd( + exerciseIndex: number, + currentParsed: ParsedWorkout, + ctx: MarkdownPostProcessorContext, + sectionInfo: SectionInfo | null, + workoutId: string + ): Promise { + const exercise = currentParsed.exercises[exerciseIndex]; + if (!exercise) return currentParsed; + + const activeSetIndex = this.timerManager.getActiveSetIndex(workoutId); + + // Record actual rest elapsed time before advancing + const timerState = this.timerManager.getTimerState(workoutId); + if (timerState && timerState.restElapsed !== undefined) { + currentParsed = setSetRecordedRest( + currentParsed, + exerciseIndex, + activeSetIndex, + formatDurationHuman(timerState.restElapsed) + ); + } + + // Rest finished, advance to next set + const nextSetIndex = activeSetIndex + 1; + if (nextSetIndex < exercise.sets.length) { + // More sets exist - activate next one + currentParsed = updateSetState(currentParsed, exerciseIndex, nextSetIndex, 'inProgress'); + this.timerManager.exitRest(workoutId, exerciseIndex, nextSetIndex); + await this.updateFileWithParsed(ctx, sectionInfo, currentParsed); + } else { + // Last set done - move to next exercise + currentParsed = updateExerciseState(currentParsed, exerciseIndex, 'completed'); + + const nextPending = this.findNextPendingExercise(exerciseIndex, currentParsed.exercises); + + if (nextPending >= 0) { + // Found next exercise + currentParsed = updateExerciseState(currentParsed, nextPending, 'inProgress'); + this.timerManager.advanceExercise(workoutId, nextPending); + await this.updateFileWithParsed(ctx, sectionInfo, currentParsed); + } else { + // No more exercises - complete entire workout + await this.completeWorkout(currentParsed, ctx, sectionInfo, workoutId); + } + } + + return currentParsed; + } + + /** + * Shared helper for marking workout as complete. + * Used when all exercises are done (called from multiple paths). + */ + private async completeWorkout( + currentParsed: ParsedWorkout, + ctx: MarkdownPostProcessorContext, + sectionInfo: SectionInfo | null, + workoutId: string + ): Promise { + // Mark as completed and record final duration + currentParsed.metadata.state = 'completed'; + const finalState = this.timerManager.getTimerState(workoutId); + if (finalState) { + currentParsed.metadata.duration = formatDurationHuman(finalState.workoutElapsed); + } + // Lock all fields to prevent editing after completion + currentParsed = lockAllFields(currentParsed); + // Persist and stop timers + await this.updateFileWithParsed(ctx, sectionInfo, currentParsed); + this.timerManager.stopWorkoutTimer(workoutId); + } + + /** + * Handles user skipping current set: marks as skipped, advances to next set or exercise. + */ + private async handleExerciseSkip( + exerciseIndex: number, + currentParsed: ParsedWorkout, + ctx: MarkdownPostProcessorContext, + sectionInfo: SectionInfo | null, + workoutId: string + ): Promise { + const exercise = currentParsed.exercises[exerciseIndex]; + if (!exercise) return currentParsed; + + const activeSetIndex = this.timerManager.getActiveSetIndex(workoutId); + + // Mark current set as skipped (user wants to skip, not complete) + currentParsed = updateSetState(currentParsed, exerciseIndex, activeSetIndex, 'skipped'); + + // Check if there are more sets in this exercise + if (activeSetIndex < exercise.sets.length - 1) { + // More sets exist - advance to next + const nextSetIndex = activeSetIndex + 1; + currentParsed = updateSetState(currentParsed, exerciseIndex, nextSetIndex, 'inProgress'); + this.timerManager.advanceSet(workoutId, exerciseIndex, nextSetIndex); + await this.updateFileWithParsed(ctx, sectionInfo, currentParsed); + } else { + // Last set skipped - move to next exercise + currentParsed = updateExerciseState(currentParsed, exerciseIndex, 'completed'); + + const nextPending = this.findNextPendingExercise(exerciseIndex, currentParsed.exercises); + + if (nextPending >= 0) { + currentParsed = updateExerciseState(currentParsed, nextPending, 'inProgress'); + + // Advance timer BEFORE file update so re-render sees reset timer + this.timerManager.advanceExercise(workoutId, nextPending); + + await this.updateFileWithParsed(ctx, sectionInfo, currentParsed); + } else { + // No more exercises, complete workout + await this.completeWorkout(currentParsed, ctx, sectionInfo, workoutId); + } + } + + return currentParsed; + } + + // ==================== CALLBACK CREATION ==================== + /** + * Creates the callback functions that are passed to the renderer. + * These callbacks handle all user interactions (buttons, input changes, etc). + * + * The callbacks use a closure pattern to stay aware of: + * - currentParsed: Current state (updated as user interacts) + * - hasPendingChanges: Flag for debouncing param changes + * + * Key patterns: + * - Workflow handlers (start, finish) call methods above + * - Param changes are batched and only flushed when leaving the field + * - All state changes flow through updateFile() to ensure persistence + */ private createCallbacks( ctx: MarkdownPostProcessorContext, sectionInfo: SectionInfo | null, parsed: ParsedWorkout, workoutId: string ): WorkoutCallbacks { - // Keep a reference to current parsed state + // Closure variable: tracks the latest workout state as user interacts + // Updated by updateFile() after each state change is persisted + // Accessible to all callbacks so they see consistent state let currentParsed = parsed; + + // Closure variable: flag for debouncing parameter changes + // When user edits a param in the UI: + // 1. onParamChange sets hasPendingChanges = true (but doesn't save) + // 2. When user leaves the field, onFlushChanges is called + // 3. flushChanges() checks flag and only saves if true + // This reduces write frequency when user is rapidly typing let hasPendingChanges = false; + // Helper: Persists state to file and updates closure variables + // All major workflow changes (start, finish, exercise transitions) call this + // Ensures file is always in sync with timer state const updateFile = async (newParsed: ParsedWorkout): Promise => { + // Update closure state to latest value currentParsed = newParsed; + // Clear pending changes flag since we just flushed hasPendingChanges = false; - const newContent = serializeWorkout(newParsed); - // Pass title for validation to prevent cross-block contamination - const expectedTitle = currentParsed.metadata.title; - await this.fileUpdater?.updateCodeBlock(ctx.sourcePath, sectionInfo, newContent, expectedTitle); + // Serialize to markdown and write to file (also saves to properties if enabled) + await this.updateFileWithParsed(ctx, sectionInfo, newParsed); }; - // Flush any pending param changes to file + // Helper: Saves pending parameter changes to file if any exist + // Called when user leaves a parameter field (onBlur event) + // If hasPendingChanges is true, calls updateFile to persist + // If false, does nothing (optimization - skip unnecessary writes) const flushChanges = async (): Promise => { if (hasPendingChanges) { + // User made changes - persist them await updateFile(currentParsed); } + // If no pending changes, onFlushChanges is essentially a no-op }; return { + // ===== WORKFLOW HANDLERS (Major state transitions) ===== + + // User clicked "Start Workout" button + // Sets state to 'started', activates first exercise, starts timers onStartWorkout: async (): Promise => { - hasPendingChanges = false; // Will be saved by updateFile below - // Update state to started - currentParsed.metadata.state = 'started'; - currentParsed.metadata.startDate = this.formatStartDate(new Date()); - - // Activate first pending exercise - const firstPending = currentParsed.exercises.findIndex(e => e.state === 'pending'); - if (firstPending >= 0) { - const exercise = currentParsed.exercises[firstPending]; - if (exercise) { - exercise.state = 'inProgress'; - } - } - - await updateFile(currentParsed); - - // Start timers - this.timerManager.startWorkoutTimer(workoutId, firstPending >= 0 ? firstPending : 0); + await this.handleStartWorkout(currentParsed, ctx, sectionInfo, workoutId); }, + // User clicked "Finish Workout" button or all exercises completed + // Captures total elapsed time, locks all fields, marks as 'completed' onFinishWorkout: async (): Promise => { - // Calculate duration - const timerState = this.timerManager.getTimerState(workoutId); - if (timerState) { - currentParsed.metadata.duration = formatDurationHuman(timerState.workoutElapsed); - } - - currentParsed.metadata.state = 'completed'; - - // Lock all fields - currentParsed = lockAllFields(currentParsed); - - await updateFile(currentParsed); - - // Stop timer - this.timerManager.stopWorkoutTimer(workoutId); + await this.handleFinishWorkout(currentParsed, ctx, sectionInfo, workoutId); }, + // Timer completed (either countdown finished or user clicked "Done") + // Routes to either handleRestEnd (if in rest period) or handleSetFinish (if in set) + // Determines what comes next: rest, next set, next exercise, or complete onExerciseFinish: async (exerciseIndex: number): Promise => { - hasPendingChanges = false; // Will be saved by updateFile below - const exercise = currentParsed.exercises[exerciseIndex]; - if (!exercise) return; - - // Record duration + // Check timer state to see if we're in rest period or active exercise + const activeSetIndex = this.timerManager.getActiveSetIndex(workoutId); const timerState = this.timerManager.getTimerState(workoutId); - if (timerState) { - currentParsed = setRecordedDuration( - currentParsed, + + // Route to appropriate handler based on whether rest is active + if (timerState?.isRestActive) { + // In rest period - advance to next set after rest + currentParsed = await this.handleRestEnd(exerciseIndex, currentParsed, ctx, sectionInfo, workoutId); + } else { + // In active set/exercise - finish set and check for rest or next + currentParsed = await this.handleSetFinish( exerciseIndex, - formatDurationHuman(timerState.exerciseElapsed) + activeSetIndex, + currentParsed, + ctx, + sectionInfo, + workoutId, + this.handleRestStart.bind(this, ctx, sectionInfo, workoutId) ); } + }, - // Mark as completed - currentParsed = updateExerciseState(currentParsed, exerciseIndex, 'completed'); - - // Find next pending exercise - const nextPending = currentParsed.exercises.findIndex( - (e, i) => i > exerciseIndex && e.state === 'pending' + // Explicit set finish (alternative to onExerciseFinish when only marking a set complete) + // Called from UI when user clicks "Done" on a specific set + onSetFinish: async (exerciseIndex: number, setIndex: number): Promise => { + currentParsed = await this.handleSetFinish( + exerciseIndex, + setIndex, + // Pass closure state and update it with returned value + currentParsed, + ctx, + sectionInfo, + workoutId, + // Callback for rest handling if this set has rest duration + async (exIdx: number, restDur: number) => { + await this.handleRestStart(ctx, sectionInfo, workoutId, exIdx, restDur); + } ); + }, - if (nextPending >= 0) { - // Activate next exercise - currentParsed = updateExerciseState(currentParsed, nextPending, 'inProgress'); - - // Advance timer BEFORE file update so re-render sees reset timer - this.timerManager.advanceExercise(workoutId, nextPending); + // Rest period is starting (e.g., user clicked "Start Rest" or after auto-rest) + // Initializes countdown timer for the rest period + onRestStart: async (exerciseIndex: number, restDuration: number): Promise => { + await this.handleRestStart(ctx, sectionInfo, workoutId, exerciseIndex, restDuration); + }, - await updateFile(currentParsed); - } else { - // No more exercises, complete workout - currentParsed.metadata.state = 'completed'; - const finalState = this.timerManager.getTimerState(workoutId); - if (finalState) { - currentParsed.metadata.duration = formatDurationHuman(finalState.workoutElapsed); - } - currentParsed = lockAllFields(currentParsed); - await updateFile(currentParsed); - this.timerManager.stopWorkoutTimer(workoutId); - } + // Rest period is ending (countdown finished or user clicked "Skip Rest") + // Advances to next set after recording rest duration + onRestEnd: async (exerciseIndex: number): Promise => { + currentParsed = await this.handleRestEnd(exerciseIndex, currentParsed, ctx, sectionInfo, workoutId); }, + // ===== USER ACTIONS (In-flight modifications) ===== + + // User clicked "Add Set" button during exercise + // Clones the current set as template, records current time, advances timer + // Allows user to do more reps/sets than originally planned onExerciseAddSet: async (exerciseIndex: number): Promise => { - hasPendingChanges = false; // Will be saved by updateFile below + // User wants to add a set to current exercise while working out + // This records the current set duration before creating a new one const exercise = currentParsed.exercises[exerciseIndex]; if (!exercise) return; - // Record duration for current set + const activeSetIndex = this.timerManager.getActiveSetIndex(workoutId); const timerState = this.timerManager.getTimerState(workoutId); if (timerState) { - currentParsed = setRecordedDuration( + currentParsed = setSetRecordedDuration( currentParsed, exerciseIndex, + activeSetIndex, formatDurationHuman(timerState.exerciseElapsed) ); } - // Mark current as completed - currentParsed = updateExerciseState(currentParsed, exerciseIndex, 'completed'); - - // Add new set (inserts after current) + // Mark current set done and activate the new set + currentParsed = updateSetState(currentParsed, exerciseIndex, activeSetIndex, 'completed'); currentParsed = addSet(currentParsed, exerciseIndex); - // The new set is at exerciseIndex + 1, activate it - currentParsed = updateExerciseState(currentParsed, exerciseIndex + 1, 'inProgress'); - - // Advance timer BEFORE file update so re-render sees reset timer - this.timerManager.advanceExercise(workoutId, exerciseIndex + 1); + const newSetIndex = (currentParsed.exercises[exerciseIndex]?.sets.length ?? 1) - 1; + currentParsed = updateSetState(currentParsed, exerciseIndex, newSetIndex, 'inProgress'); + // Update timer to track new set + this.timerManager.advanceSet(workoutId, exerciseIndex, newSetIndex); await updateFile(currentParsed); }, + // User clicked "Add Rest" button to insert unplanned rest + // Creates a Rest exercise at the next position and starts countdown onExerciseAddRest: async (exerciseIndex: number): Promise => { - hasPendingChanges = false; // Will be saved by updateFile below + // User wants to insert a rest period (creates a new rest exercise after current one) const exercise = currentParsed.exercises[exerciseIndex]; const restDuration = currentParsed.metadata.restDuration; if (!exercise || !restDuration) return; - // Record duration for current exercise + const activeSetIndex = this.timerManager.getActiveSetIndex(workoutId); const timerState = this.timerManager.getTimerState(workoutId); + + // Record how long the current set took before rest if (timerState) { - currentParsed = setRecordedDuration( + currentParsed = setSetRecordedDuration( currentParsed, exerciseIndex, + activeSetIndex, formatDurationHuman(timerState.exerciseElapsed) ); } - // Mark current as completed - currentParsed = updateExerciseState(currentParsed, exerciseIndex, 'completed'); - - // Add rest exercise (inserts after current) + // Mark current set done, insert new Rest exercise after current one + currentParsed = updateSetState(currentParsed, exerciseIndex, activeSetIndex, 'completed'); currentParsed = addRest(currentParsed, exerciseIndex, restDuration); - - // The new rest is at exerciseIndex + 1, activate it + // Activate the rest exercise (this is at index exerciseIndex+1) currentParsed = updateExerciseState(currentParsed, exerciseIndex + 1, 'inProgress'); - // Advance timer BEFORE file update so re-render sees reset timer - this.timerManager.advanceExercise(workoutId, exerciseIndex + 1); - + // Update timer to track new rest exercise + this.timerManager.advanceSet(workoutId, exerciseIndex + 1, 0); await updateFile(currentParsed); }, + // User clicked "Skip" button to skip current set without completing it + // Marks as 'skipped' (not 'completed'), advances to next set or exercise onExerciseSkip: async (exerciseIndex: number): Promise => { - hasPendingChanges = false; // Will be saved by updateFile below - // Record duration if any time elapsed - const timerState = this.timerManager.getTimerState(workoutId); - if (timerState && timerState.exerciseElapsed > 0) { - currentParsed = setRecordedDuration( - currentParsed, - exerciseIndex, - formatDurationHuman(timerState.exerciseElapsed) - ); - } - - currentParsed = updateExerciseState(currentParsed, exerciseIndex, 'skipped'); - - // Find next pending - const nextPending = currentParsed.exercises.findIndex( - (e, i) => i > exerciseIndex && e.state === 'pending' - ); - - if (nextPending >= 0) { - currentParsed = updateExerciseState(currentParsed, nextPending, 'inProgress'); - - // Advance timer BEFORE file update so re-render sees reset timer - this.timerManager.advanceExercise(workoutId, nextPending); - - await updateFile(currentParsed); - } else { - // No more exercises, complete workout - currentParsed.metadata.state = 'completed'; - const finalState = this.timerManager.getTimerState(workoutId); - if (finalState) { - currentParsed.metadata.duration = formatDurationHuman(finalState.workoutElapsed); - } - currentParsed = lockAllFields(currentParsed); - - // Stop timer BEFORE file update - this.timerManager.stopWorkoutTimer(workoutId); - - await updateFile(currentParsed); - } + // User wants to skip current set/exercise + currentParsed = await this.handleExerciseSkip(exerciseIndex, currentParsed, ctx, sectionInfo, workoutId); }, + // ===== PARAMETER EDITING (Debounced changes) ===== + + // User is editing an exercise-level param (e.g., weight field, notes) + // Debounced: doesn't save immediately, only sets flag for later flush + // This prevents excessive file writes while user is typing onParamChange: (exerciseIndex: number, paramKey: string, newValue: string): void => { - // Check if value actually changed + // User edited an exercise param (e.g., weight, reps) + // Mark as pending change (debounced - won't save until blur/flush) const exercise = currentParsed.exercises[exerciseIndex]; const param = exercise?.params.find(p => p.key === paramKey); - if (param?.value === newValue) { - return; // No change, skip update - } + if (param?.value === newValue) return; // No change, skip currentParsed = updateParamValue(currentParsed, exerciseIndex, paramKey, newValue); - hasPendingChanges = true; - // Don't save to file yet - wait for flush + hasPendingChanges = true; // Flag for later flush + }, + + // User is editing a set-level param (e.g., reps field, weight field) + // Debounced: doesn't save immediately, only sets flag for later flush + onSetParamChange: (exerciseIndex: number, setIndex: number, paramKey: string, newValue: string): void => { + // User edited a set param (e.g., duration, weight, reps) + // Mark as pending change (debounced - won't save until blur/flush) + const exercise = currentParsed.exercises[exerciseIndex]; + const set = exercise?.sets[setIndex]; + const param = set?.params.find(p => p.key === paramKey); + if (param?.value === newValue) return; // No change, skip + currentParsed = updateSetParamValue(currentParsed, exerciseIndex, setIndex, paramKey, newValue); + hasPendingChanges = true; // Flag for later flush }, + // User left a parameter field (blur event) - flush any pending changes + // Called after onParamChange/onSetParamChange to persist edited params + // Implementation is the flushChanges helper defined above onFlushChanges: flushChanges, + // ===== TIMER CONTROLS (Pause/Resume) ===== + + // User clicked "Pause" button - temporarily stops timer without finishing + // Exercise remains "in-progress", elapsed time recorded at pause point onPauseExercise: (): void => { + // User paused the timer (stops counting but doesn't finish/advance) this.timerManager.pauseExercise(workoutId); }, + // User clicked "Resume" after pausing - continues timer from pause point + // Resumes from the same elapsed time, not restarting onResumeExercise: (): void => { + // User resumed the paused timer to continue counting this.timerManager.resumeExercise(workoutId); }, + // ===== SPECIAL ACTIONS ===== + + // User clicked "Load Sample Workout" button to populate empty workout + // Generates a demo workout with various exercise types for exploration onAddSample: async (): Promise => { + // User clicked "Load Sample" - creates a demo workout to explore features const sampleWorkout = createSampleWorkout(); const newContent = serializeWorkout(sampleWorkout); + // Replace current workout with sample await this.fileUpdater?.updateCodeBlock( ctx.sourcePath, sectionInfo, @@ -313,6 +731,10 @@ export default class WorkoutLogPlugin extends Plugin { }; } + /** + * Formats a date into workout metadata format: YYYY-MM-DD HH:MM + * Used for recording workout start time. + */ private formatStartDate(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); diff --git a/src/parser/exercise.test.ts b/src/parser/exercise.test.ts new file mode 100644 index 0000000..46b2a5c --- /dev/null +++ b/src/parser/exercise.test.ts @@ -0,0 +1,583 @@ +import { + parseExercise, + parseSet, + parseDurationToSeconds, + formatDuration, + formatDurationHuman, + getStateChar, + serializeExercise, + serializeSet +} from './exercise'; +import { ExerciseState } from '../types'; + +/** + * Tests for the core `parseExercise` function. + * Ensures that markdown list items are correctly converted into `Exercise` objects, + * extracting states, names, and parameters accurately. + */ +describe('parseExercise', () => { + // Tests ensuring the fundamental shape of an exercise line is properly decoded + describe('Basic Parsing & Structure', () => { + it('should parse a basic exercise line', () => { + const line = '- [ ] Bench Press | Weight: [100] kg'; + const exercise = parseExercise(line, 0); + + expect(exercise).not.toBeNull(); + expect(exercise!.name).toBe('Bench Press'); + expect(exercise!.state).toBe('pending'); + expect(exercise!.params).toHaveLength(1); + expect(exercise!.params[0].key).toBe('Weight'); + expect(exercise!.params[0].value).toBe('100'); + expect(exercise!.params[0].unit).toBe('kg'); + }); + + it('should handle inprogress exercise with multiple params', () => { + const line = '- [\\] Squats | Weight: [200] kg | Reps: [10] | Duration: 60s'; + const exercise = parseExercise(line, 0); + + expect(exercise!.params).toHaveLength(3); + expect(exercise!.state).toBe('inProgress'); + expect(exercise!.params[0].key).toBe('Weight'); + expect(exercise!.params[1].key).toBe('Reps'); + expect(exercise!.params[2].key).toBe('Duration'); + }); + + it('should handle complete exercise with multiple params and system parameters', () => { + const line = '- [x] Squats | Weight: [200] kg | Reps: [10] | Duration: 60s | ~time: 4s | ~rest: 3s'; + const exercise = parseExercise(line, 0); + + expect(exercise!.params).toHaveLength(3); + expect(exercise!.state).toBe('completed'); + expect(exercise!.params[0].key).toBe('Weight'); + expect(exercise!.params[1].key).toBe('Reps'); + expect(exercise!.params[2].key).toBe('Duration'); + }); + + it('should preserve lineIndex', () => { + const line = '- [ ] Bench Press'; + const exercise = parseExercise(line, 42); + + expect(exercise!.lineIndex).toBe(42); + }); + }); + + // Tests handling the different markdown checkbox character maps + describe('State Handling', () => { + it('should parse exercise with completed state', () => { + const line = '- [x] Running | Duration: 30m'; + const exercise = parseExercise(line, 0); + expect(exercise!.state).toBe('completed'); + }); + + it('should parse exercise with in-progress state', () => { + const line = '- [\\] Squats | Weight: [150] lbs'; + const exercise = parseExercise(line, 0); + expect(exercise!.state).toBe('inProgress'); + }); + + it('should parse exercise with skipped state', () => { + const line = '- [-] Swimming | Duration: 1h'; + const exercise = parseExercise(line, 0); + expect(exercise!.state).toBe('skipped'); + }); + }); + + // Tests ensuring parameters fall into the right routing bins + describe('Parameter Extraction & Logic', () => { + it('should handle case-insensitive parameter keys', () => { + const line = '- [ ] Bench | wEiGhT: [100] kg'; + const exercise = parseExercise(line, 0); + + expect(exercise!.params[0].key).toBe('wEiGhT'); // Preserves original case + expect(exercise!.params[0].value).toBe('100'); + }); + + it('should handle targetDuration from editable Duration param', () => { + const line = '- [ ] Cardio | Duration: [120]'; + const exercise = parseExercise(line, 0); + expect(exercise!.targetDuration).toBe(120); + }); + + it('should handle recordedDuration from non-editable Duration param', () => { + const line = '- [x] Cardio | Duration: 125 s'; + const exercise = parseExercise(line, 0); + + // Duration values are parsed as targetDuration in seconds + expect(exercise!.targetDuration).toBe(125); + }); + + it('should handle Duration with compound format like 3m2s', () => { + const line = '- [x] Workout | Duration: 3m2s'; + const exercise = parseExercise(line, 0); + + // Duration is converted to seconds: 3m2s = 182 seconds + expect(exercise!.targetDuration).toBe(182); + }); + }); + + // Tests ensuring parsing fails safely instead of crashing + describe('Malformed Inputs & Edge Cases', () => { + it('should parse exercise names with special characters and emojis', () => { + const line = '- [ ] Super-Squat 🏋️‍♂️ | Weight: [100] kg'; + const exercise = parseExercise(line, 0); + expect(exercise!.name).toBe('Super-Squat 🏋️‍♂️'); + }); + + it('should return null for malformed checkboxes', () => { + expect(parseExercise('- [xx] Bench', 0)).toBeNull(); + expect(parseExercise('[ ] Bench', 0)).toBeNull(); + expect(parseExercise('- [] Bench', 0)).toBeNull(); + }); + + it('should return null for invalid exercise line', () => { + const line = 'invalid line'; + const exercise = parseExercise(line, 0); + expect(exercise).toBeNull(); + }); + + it('should return null if no checkbox found', () => { + const line = '- Bench Press | Weight: [100] kg'; + const exercise = parseExercise(line, 0); + expect(exercise).toBeNull(); + }); + + it('should parse exercise with empty name', () => { + const line = '- [ ] | Weight: [100] kg'; + const exercise = parseExercise(line, 0); + + // Parser allows empty names (they just have no name field) + expect(exercise!.name).toBe(''); + }); + }); +}); + +/** + * Tests for parsing individual set strings into ExerciseSet objects. + * Covers indentation handling, state mapping, and param extraction. + */ +describe('parseSet', () => { + describe('Basic Parsing & Structure', () => { + it('should parse a basic set line', () => { + const line = ' - [ ] | Weight: [100] kg | Reps: [10]'; + const set = parseSet(line, 0); + + expect(set).not.toBeNull(); + expect(set!.state).toBe('pending'); + expect(set!.params).toHaveLength(2); + }); + + it('should parse set without indentation (trim applied)', () => { + const line = '- [\\] | Reps: [8]'; + const set = parseSet(line, 0); + + expect(set!.state).toBe('inProgress'); + expect(set!.params).toHaveLength(1); + }); + + it('should preserve lineIndex', () => { + const line = ' - [ ] | Weight: [50] kg'; + const set = parseSet(line, 99); + + expect(set!.lineIndex).toBe(99); + }); + }); + + describe('State & Parameter Handling', () => { + it('should handle set with completed state', () => { + const line = ' - [x] | Weight: 100 kg | Reps: 10'; + const set = parseSet(line, 0); + expect(set!.state).toBe('completed'); + }); + + it('should extract system parameters ~time and ~rest in sets', () => { + const line = ' - [x] | ~time: 60s | ~rest: 90s'; + const set = parseSet(line, 0); + expect(set!.recordedTime).toBe('60s'); + expect(set!.recordedRest).toBe('90s'); + expect(set!.params).toHaveLength(0); // System params are extracted, not kept in params array + }); + + it('should handle set with duration', () => { + const line = ' - [x] | Duration: 45s'; + const set = parseSet(line, 0); + + expect(set!.params).toHaveLength(1); + expect(set!.params[0].key).toBe('Duration'); + expect(set!.params[0].value).toBe('45s'); + }); + }); + + describe('Malformed Inputs & Edge Cases', () => { + it('should return null for invalid set line', () => { + const line = ' invalid set'; + const set = parseSet(line, 0); + expect(set).toBeNull(); + }); + }); +}); + +/** + * Tests for translating human-readable strings into second-based integers. + */ +describe('parseDurationToSeconds', () => { + describe('Standard Formats', () => { + it('should parse simple seconds format', () => { + expect(parseDurationToSeconds('60s')).toBe(60); + expect(parseDurationToSeconds('30s')).toBe(30); + }); + + it('should parse colon format MM:SS', () => { + expect(parseDurationToSeconds('1:30')).toBe(90); + expect(parseDurationToSeconds('5:45')).toBe(345); + expect(parseDurationToSeconds('01:30')).toBe(90); + }); + + it('should parse minutes and seconds format', () => { + expect(parseDurationToSeconds('1m 30s')).toBe(90); + expect(parseDurationToSeconds('1m30s')).toBe(90); + expect(parseDurationToSeconds('3m2s')).toBe(182); + expect(parseDurationToSeconds('2m')).toBe(120); + }); + + it('should parse just seconds without unit', () => { + expect(parseDurationToSeconds('120')).toBe(120); + expect(parseDurationToSeconds('45')).toBe(45); + }); + + it('should handle minutes without seconds', () => { + expect(parseDurationToSeconds('1m')).toBe(60); + expect(parseDurationToSeconds('5m')).toBe(300); + }); + }); + + describe('Edge Cases & Anomalies', () => { + it('should handle decimal seconds', () => { + expect(parseDurationToSeconds('60')).toBe(60); + }); + + it('should handle values over 60 seconds', () => { + expect(parseDurationToSeconds('120s')).toBe(120); + expect(parseDurationToSeconds('1m 90s')).toBe(150); + }); + + it('should handle zero values', () => { + expect(parseDurationToSeconds('0s')).toBe(0); + expect(parseDurationToSeconds('0m 0s')).toBe(0); + expect(parseDurationToSeconds('0')).toBe(0); + }); + + it('should return 0 for invalid format', () => { + expect(parseDurationToSeconds('invalid')).toBe(0); + expect(parseDurationToSeconds('')).toBe(0); + expect(parseDurationToSeconds('abc123')).toBe(0); + }); + + it('should handle whitespace', () => { + expect(parseDurationToSeconds(' 60s ')).toBe(60); + expect(parseDurationToSeconds('1m 30s')).toBe(90); + }); + + it('should handle 3m2 format (no unit at end, assumes seconds)', () => { + expect(parseDurationToSeconds('3m2')).toBe(182); + }); + }); +}); + +describe('formatDuration', () => { + it('should format seconds to MM:SS', () => { + expect(formatDuration(0)).toBe('0:00'); + expect(formatDuration(30)).toBe('0:30'); + expect(formatDuration(60)).toBe('1:00'); + expect(formatDuration(90)).toBe('1:30'); + expect(formatDuration(3661)).toBe('61:01'); + }); + + it('should pad seconds with zeros', () => { + expect(formatDuration(5)).toBe('0:05'); + expect(formatDuration(125)).toBe('2:05'); + }); +}); + +describe('formatDurationHuman', () => { + it('should format only seconds', () => { + expect(formatDurationHuman(0)).toBe('0s'); + expect(formatDurationHuman(30)).toBe('30s'); + expect(formatDurationHuman(59)).toBe('59s'); + }); + + it('should format only minutes', () => { + expect(formatDurationHuman(60)).toBe('1m'); + expect(formatDurationHuman(300)).toBe('5m'); + }); + + it('should format minutes and seconds', () => { + expect(formatDurationHuman(90)).toBe('1m 30s'); + expect(formatDurationHuman(125)).toBe('2m 5s'); + expect(formatDurationHuman(661)).toBe('11m 1s'); + }); +}); + +describe('getStateChar', () => { + it('should map states to characters', () => { + expect(getStateChar('pending')).toBe(' '); + expect(getStateChar('inProgress')).toBe('\\'); + expect(getStateChar('completed')).toBe('x'); + expect(getStateChar('skipped')).toBe('-'); + }); +}); + +describe('serializeExercise', () => { + it('should serialize basic exercise', () => { + const exercise = { + state: 'pending' as ExerciseState, + name: 'Bench Press', + params: [ + { key: 'Weight', value: '100', editable: true, unit: 'kg' } + ], + sets: [], + lineIndex: 0 + }; + + const result = serializeExercise(exercise); + expect(result).toBe('- [ ] Bench Press | Weight: [100] kg'); + }); + + it('should serialize exercise with multiple params', () => { + const exercise = { + state: 'completed' as ExerciseState, + name: 'Squats', + params: [ + { key: 'Weight', value: '200', editable: true, unit: 'kg' }, + { key: 'Reps', value: '10', editable: false, unit: undefined } + ], + sets: [], + lineIndex: 0 + }; + + const result = serializeExercise(exercise); + expect(result).toBe('- [x] Squats | Weight: [200] kg | Reps: 10'); + }); + + it('should serialize exercise with in-progress state', () => { + const exercise = { + state: 'inProgress' as ExerciseState, + name: 'Running', + params: [], + sets: [], + lineIndex: 0 + }; + + const result = serializeExercise(exercise); + expect(result).toBe('- [\\] Running'); + }); + + it('should handle params without units', () => { + const exercise = { + state: 'pending' as ExerciseState, + name: 'Cardio', + params: [ + { key: 'Duration', value: '60', editable: true, unit: undefined } + ], + sets: [], + lineIndex: 0 + }; + + const result = serializeExercise(exercise); + expect(result).toBe('- [ ] Cardio | Duration: [60]'); + }); +}); + +describe('serializeSet', () => { + it('should serialize basic set', () => { + const set = { + state: 'pending' as ExerciseState, + params: [ + { key: 'Weight', value: '100', editable: true, unit: 'kg' }, + { key: 'Reps', value: '8', editable: true, unit: undefined } + ], + lineIndex: 0 + }; + + const result = serializeSet(set); + expect(result).toBe(' - [ ] | Weight: [100] kg | Reps: [8]'); + }); + + it('should serialize completed set', () => { + const set = { + state: 'completed' as ExerciseState, + params: [ + { key: 'Weight', value: '150', editable: false, unit: 'lbs' } + ], + lineIndex: 0 + }; + + const result = serializeSet(set); + expect(result).toBe(' - [x] | Weight: 150 lbs'); + }); + + it('should serialize set with no params', () => { + const set = { + state: 'inProgress' as ExerciseState, + params: [], + lineIndex: 0 + }; + + const result = serializeSet(set); + expect(result).toBe(' - [\\]'); + }); + + it('should serialize set with duration', () => { + const set = { + state: 'pending' as ExerciseState, + params: [ + { key: 'Duration', value: '3m2s', editable: false, unit: undefined } + ], + lineIndex: 0 + }; + + const result = serializeSet(set); + expect(result).toBe(' - [ ] | Duration: 3m2s'); + }); +}); + +/** + * Verification of exact data fidelity when transitioning from Markdown string to internal objects + * and back to markdown strings. Validates handling of formatting quirks. + */ +describe('Integration & Idempotency Tests', () => { + describe('Round-Trip Serialization', () => { + it('should parse and serialize exercise without data loss', () => { + const original = '- [x] Bench Press | Weight: [100] kg | Reps: [12]'; + const exercise = parseExercise(original, 0); + const serialized = serializeExercise(exercise!); + + expect(serialized).toBe(original); + }); + + it('should parse and serialize set without data loss', () => { + const original = ' - [ ] | Weight: [80] kg | Reps: [10]'; + const set = parseSet(original, 0); + const serialized = serializeSet(set!); + + expect(serialized).toBe(original); + }); + + it('should maintain strict idempotency (Serialize -> Parse -> Serialize)', () => { + const mockExercise = { + state: 'completed' as ExerciseState, + name: 'Complex Squat 🏋️‍♂️', + params: [ + { key: 'Weight', value: '100', editable: true, unit: 'kg' }, + { key: 'Reps', value: '10', editable: false, unit: undefined } + ], + sets: [], + lineIndex: 0 + }; + + const serialized1 = serializeExercise(mockExercise); + const parsed = parseExercise(serialized1, 0); + const serialized2 = serializeExercise(parsed!); + + expect(serialized1).toBe(serialized2); + }); + }); + + describe('Complex Parameter Edge Cases', () => { + it('should handle complex exercise with all parameter types', () => { + const line = '- [\\] Deadlift | Weight: [225] kg | Reps: [5] | Duration: 3m2s'; + const exercise = parseExercise(line, 0); + + expect(exercise).not.toBeNull(); + expect(exercise!.state).toBe('inProgress'); + expect(exercise!.name).toBe('Deadlift'); + // Weight, Reps, and Duration are all in params + expect(exercise!.params).toHaveLength(3); + // Duration is also extracted into targetDuration (in seconds) + expect(exercise!.targetDuration).toBe(182); // 3m2s = 182 seconds + }); + + it('should roundtrip duration formats through parsing', () => { + const formats = ['60s', '1:30', '1m 30s', '1m30s', '3m2s', '3m2']; + formats.forEach(format => { + const seconds = parseDurationToSeconds(format); + expect(seconds).toBeGreaterThan(0); + }); + }); + + it('should handle params with concatenated values like 10kg', () => { + const line = '- [ ] Curls | Weight: 10kg'; + const exercise = parseExercise(line, 0); + + expect(exercise!.params).toHaveLength(1); + expect(exercise!.params[0].key).toBe('Weight'); + expect(exercise!.params[0].value).toBe('10'); + expect(exercise!.params[0].unit).toBe('kg'); + expect(exercise!.params[0].editable).toBe(false); + }); + + it('should handle params with concatenated decimal values like 5.5lbs', () => { + const line = '- [ ] Exercise | Weight: 5.5lbs'; + const exercise = parseExercise(line, 0); + + expect(exercise!.params[0].value).toBe('5.5'); + expect(exercise!.params[0].unit).toBe('lbs'); + }); + }); + + describe('Parameter Whitelisting & Filtering', () => { + it('should parse exercise without any params', () => { + const line = '- [x] Rest'; + const exercise = parseExercise(line, 0); + + expect(exercise!.name).toBe('Rest'); + expect(exercise!.params).toHaveLength(0); + }); + + it('should ignore params that are not on the allowed list', () => { + // Only Duration, Weight, Reps, and Rest are allowed + // Sets and Notes should be filtered out + const line = '- [ ] Exercise | Sets: 3 | Notes: [my note]'; + const exercise = parseExercise(line, 0); + + // Should have no params since Sets and Notes are not allowed + expect(exercise!.params).toHaveLength(0); + }); + + it('should allow only whitelisted parameters', () => { + // All allowed params should be parsed correctly + const line = '- [ ] Exercise | Duration: [60s] | Weight: 20 lbs | Reps: [10] | Rest: 30s | Unknown: 5'; + const exercise = parseExercise(line, 0); + + // Should have 4 params (Duration, Weight, Reps, Rest), Unknown should be filtered out + expect(exercise!.params).toHaveLength(4); + expect(exercise!.params[0].key).toBe('Duration'); + expect(exercise!.params[1].key).toBe('Weight'); + expect(exercise!.params[2].key).toBe('Reps'); + expect(exercise!.params[3].key).toBe('Rest'); + }); + + it('should safely ignore malformed parameters', () => { + // Weight missing a colon, Reps missing a value + const line = '- [ ] Exercise | Weight [100] kg | Reps: | Rest: []'; + const exercise = parseExercise(line, 0); + + // Weight and Reps should be ignored due to formatting issues + // Rest: [] is technically parsed but with an empty string value + expect(exercise!.params).toHaveLength(1); + expect(exercise!.params[0].key).toBe('Rest'); + expect(exercise!.params[0].value).toBe(''); + }); + }); + + describe('System Parameter Extraction', () => { + it('should allow system-managed totals parameters (~time and ~rest)', () => { + // ~time and ~rest are system-managed (locked) parameters + // These are extracted into separate fields, not stored in params array + const line = '- [x] Exercise | ~rest: 5m | ~time: 30m'; + const exercise = parseExercise(line, 0); + + expect(exercise!.recordedRest).toBe('5m'); + expect(exercise!.recordedTime).toBe('30m'); + }); + }); +}); diff --git a/src/parser/exercise.ts b/src/parser/exercise.ts index 4c968e7..5491d76 100644 --- a/src/parser/exercise.ts +++ b/src/parser/exercise.ts @@ -1,8 +1,9 @@ -import { Exercise, ExerciseState, ExerciseParam } from '../types'; - -// Checkbox patterns: [ ] pending, [\] inProgress, [x] completed, [-] skipped -const EXERCISE_PATTERN = /^-\s*\[(.)\]\s*(.+)$/; +import { Exercise, ExerciseSet, ExerciseState, ExerciseParam, Token } from '../types'; +/** + * Maps checkbox characters to exercise/set states. + * Format: [state] where state = ' ' (pending), '\' (inProgress), 'x' (completed), '-' (skipped) + */ const STATE_MAP: Record = { ' ': 'pending', '\\': 'inProgress', @@ -10,6 +11,10 @@ const STATE_MAP: Record = { '-': 'skipped' }; +/** + * Reverse mapping: converts ExerciseState back to checkbox character. + * Used during serialization to write state back to markdown. + */ const STATE_CHAR_MAP: Record = { 'pending': ' ', 'inProgress': '\\', @@ -17,42 +22,83 @@ const STATE_CHAR_MAP: Record = { 'skipped': '-' }; -// Parse value with optional brackets: [value] = editable, value = locked -// Also handle Duration special case for timer -const PARAM_PATTERN = /^([^:]+):\s*(\[([^\]]*)\]|([^\s\[]+))(\s+(.+))?$/; +/** + * Tokenize a markdown checkbox line (exercise or set). + * Expected format: "- [STATE] REMAINDER" where STATE is a single character. + * + * Returns: + * - { stateChar, remainder } if valid checkbox found + * - null if line doesn't match checkbox pattern + */ +function tokenizeExerciseLine(line: string): { stateChar: string; remainder: string } | null { + // Check for leading dash + if (!line.startsWith('-')) return null; -export function parseExercise(line: string, lineIndex: number): Exercise | null { - const match = line.match(EXERCISE_PATTERN); - if (!match) return null; + // Find opening bracket + const openBracketIndex = line.indexOf('['); + if (openBracketIndex === -1) return null; + + // Find closing bracket + const closeBracketIndex = line.indexOf(']', openBracketIndex); + if (closeBracketIndex === -1) return null; + + // Extract state character + const stateChar = line.substring(openBracketIndex + 1, closeBracketIndex); + if (stateChar.length !== 1) return null; + + // Get remainder after closing bracket + const afterBracket = line.substring(closeBracketIndex + 1).trimStart(); + if (!afterBracket) return null; - const stateChar = match[1] ?? ' '; - const rest = match[2] ?? ''; + return { stateChar, remainder: afterBracket }; +} + +/** + * Parse a markdown exercise line into an Exercise object. + * + * Format: "- [STATE] Exercise Name | Key: [value] unit | Key: value" + * + * Special handling: + * - Duration params: editable [60s] = countdown target, locked 45s = countdown timer + auto advance + * - All other params are stored in exercise.params + * - Sets array is empty (filled by parent parseWorkout) + */ +export function parseExercise(line: string, lineIndex: number): Exercise | null { + const tokenized = tokenizeExerciseLine(line); + if (!tokenized) return null; - const state = STATE_MAP[stateChar] ?? 'pending'; + const state = STATE_MAP[tokenized.stateChar] ?? 'pending'; // Split by | to get name and params - const parts = rest.split('|').map(p => p.trim()); + const parts = tokenized.remainder.split('|').map(p => p.trim()); const name = parts[0] ?? ''; const paramStrings = parts.slice(1); const params: ExerciseParam[] = []; let targetDuration: number | undefined; - let recordedDuration: string | undefined; + let targetRest: number | undefined; + let recordedTime: string | undefined; + let recordedRest: string | undefined; for (const paramStr of paramStrings) { const param = parseParam(paramStr); if (param) { - params.push(param); - - // Special handling for Duration key if (param.key.toLowerCase() === 'duration') { - if (param.editable) { - // Editable duration = countdown target - targetDuration = parseDurationToSeconds(param.value); - } else { - // Locked duration = recorded time - recordedDuration = param.value + (param.unit ? ` ${param.unit}` : ''); - } + targetDuration = parseDurationToSeconds(param.value); + params.push(param); + } + else if (param.key.toLowerCase() === 'rest') { + targetRest = parseDurationToSeconds(param.value); + params.push(param); + } + else if (param.key.toLowerCase() === '~time') { + recordedTime = param.value; + } + else if (param.key.toLowerCase() === '~rest') { + recordedRest = param.value; + } + else { + params.push(param); } } } @@ -61,47 +107,221 @@ export function parseExercise(line: string, lineIndex: number): Exercise | null state, name, params, - targetDuration, - recordedDuration, + sets: [], // Set by parent parseWorkout, not by this function + targetDuration, // Seconds: used for countdown timers + targetRest, // Seconds: used for rest periods + recordedTime, // Seconds: actual time recorded after completion + recordedRest, // Seconds: actual rest time recorded after completion lineIndex }; } -function parseParam(paramStr: string): ExerciseParam | null { - // Handle simple format: Key: value or Key: [value] or Key: [value] unit +/** + * Parse a markdown set line (indented) into an ExerciseSet object. + * + * Format: " - [STATE] | Key: [value] unit | Key: value" + * + * Special handling: + * - ~time and ~rest are extracted into recordedTime/recordedRest fields + * (these are system-computed values, not user params) + * - All other params are stored in set.params + */ +export function parseSet(line: string, lineIndex: number): ExerciseSet | null { + const trimmed = line.trim(); + const tokenized = tokenizeExerciseLine(trimmed); + if (!tokenized) return null; + + const state = STATE_MAP[tokenized.stateChar] ?? 'pending'; + + // Split by | to get params (no name for sets) + const parts = tokenized.remainder.split('|').map(p => p.trim()); + const paramStrings = parts; + + const params: ExerciseParam[] = []; + let targetDuration: number | undefined; + let targetRest: number | undefined; + let recordedTime: string | undefined; + let recordedRest: string | undefined; + + for (const paramStr of paramStrings) { + const param = parseParam(paramStr); + if (param) { + if (param.key.toLowerCase() === 'duration') { + targetDuration = parseDurationToSeconds(param.value); + params.push(param); + } + else if (param.key.toLowerCase() === 'rest') { + targetRest = parseDurationToSeconds(param.value); + params.push(param); + } + else if (param.key.toLowerCase() === '~time') { + recordedTime = param.value; + } + else if (param.key.toLowerCase() === '~rest') { + recordedRest = param.value; + } + else { + params.push(param); + } + } + } + + return { + state, + params, + lineIndex, + targetDuration, // Seconds: used for countdown timers + targetRest, // Seconds: used for rest periods + recordedTime, // Actual elapsed time during set + recordedRest // Actual elapsed rest period after set + }; +} + +/** + * Tokenize a parameter string into semantic tokens. + * + * Format: "Key: [value] unit" or "Key: value unit" + * where [brackets] indicate editable values, no brackets = locked/readonly. + * + * Complex parsing logic: + * 1. Extract key (everything before first ":") + * 2. Check for bracketed value [content] + * 3. Parse unbracketed value with optional unit + * - May be concatenated like "10kg" or "60s" + * - May be separated like "10 kg" or "60 s" + * 4. Extract remaining text as unit + * + * Returns array of tokens with types: 'key', 'bracket'|'value', 'unit' + */ +function tokenizeParam(paramStr: string): Token[] { + const tokens: Token[] = []; + + // 1. Parse key (everything before colon) const colonIndex = paramStr.indexOf(':'); - if (colonIndex === -1) return null; + if (colonIndex === -1) return []; const key = paramStr.substring(0, colonIndex).trim(); - const rest = paramStr.substring(colonIndex + 1).trim(); - - // Check for bracketed value - const bracketMatch = rest.match(/^\[([^\]]*)\](.*)$/); - if (bracketMatch) { - const value = bracketMatch[1] ?? ''; - const afterBracket = (bracketMatch[2] ?? '').trim(); - return { - key, - value, - editable: true, - unit: afterBracket || undefined - }; + tokens.push({ type: 'key', value: key }); + + // Step 2: Parse value and unit (everything after the colon) + // Value may be bracketed [editable] or locked (no brackets) + let remainder = paramStr.substring(colonIndex + 1).trim(); + + // Check for bracketed value (editable) + if (remainder.startsWith('[')) { + const closeBracketIndex = remainder.indexOf(']'); + if (closeBracketIndex !== -1) { + const value = remainder.substring(1, closeBracketIndex); + tokens.push({ type: 'bracket', value }); + remainder = remainder.substring(closeBracketIndex + 1).trim(); + } + } else if (remainder.length > 0) { + // Unbracketed (locked) value - try to separate value and unit + const spaceIndex = remainder.indexOf(' '); + if (spaceIndex === -1) { + // No space - might be concatenated value+unit like "100kg" or "60s" + // No space - check if value and unit are concatenated (e.g., "10s", "60m") + // Find where numeric portion ends (digits and optional decimal point) + let numericEnd = 0; + for (let i = 0; i < remainder.length; i++) { + const char = remainder.charAt(i); + if (char === '.' || (char >= '0' && char <= '9')) { + numericEnd = i + 1; + } else { + break; + } + } + + if (numericEnd > 0 && numericEnd < remainder.length) { + // Split: numeric value + non-numeric unit (e.g., "100" + "kg") + tokens.push({ type: 'value', value: remainder.substring(0, numericEnd) }); + remainder = remainder.substring(numericEnd); + } else { + // All numeric or all non-numeric, treat entire string as value + tokens.push({ type: 'value', value: remainder }); + return tokens; + } + } else { + // Space found - everything before space is value, after is unit + tokens.push({ type: 'value', value: remainder.substring(0, spaceIndex) }); + remainder = remainder.substring(spaceIndex).trim(); + } + } + + // Step 3: Parse unit (any remaining text after value) + if (remainder) { + tokens.push({ type: 'unit', value: remainder }); + } + + return tokens; +} + +/** + * Convert tokenized parameter into an ExerciseParam object. + * + * Validation: + * - Requires key and value tokens + * - Applies parameter whitelist (Duration, Weight, Reps, Rest, ~time, ~rest) + * - Unknown params are silently ignored + * + * Special logic for Duration/~time/~rest: + * - Combines numeric value + text unit into single value string + * - Examples: "60s", "1m 30s", "45.5kg" + * + * Returns: + * - ExerciseParam with key, value, editable flag, and optional unit + * - null if validation fails or param not whitelisted + */ +function parseParam(paramStr: string): ExerciseParam | null { + const tokens = tokenizeParam(paramStr); + if (tokens.length === 0) return null; + + const keyToken = tokens.find(t => t.type === 'key'); + const valueToken = tokens.find(t => t.type === 'bracket' || t.type === 'value'); + + if (!keyToken || !valueToken) return null; + + // Only allow these parameters - all others are ignored + // ~time and ~rest are system-managed totals (not user-editable) + const allowedParams = ['duration', 'weight', 'reps', 'rest', '~time', '~rest']; + const paramKeyLower = keyToken.value.toLowerCase(); + if (!allowedParams.includes(paramKeyLower)) { + console.log(`Ignoring unrecognized parameter: ${keyToken.value}`); + return null; // Ignore unrecognized parameters } - // No brackets - split on first space for value and unit - const parts = rest.split(/\s+/); - const value = parts[0] ?? ''; - const unit = parts.slice(1).join(' ') || undefined; + const unitToken = tokens.find(t => t.type === 'unit'); + + let finalValue = valueToken.value; + let finalUnit = unitToken?.value; + + // Duration formats use compound notation (e.g., "1m 30s"), so combine numeric + unit + // ~time and ~rest also follow duration format from serialization + const isDurationParam = ['duration', 'rest', '~time', '~rest'].includes(keyToken.value.toLowerCase()); + if (isDurationParam && finalUnit) { + finalValue = finalValue + finalUnit; + finalUnit = undefined; + } return { - key, - value, - editable: false, - unit + key: keyToken.value, + value: finalValue, + editable: valueToken.type === 'bracket', + unit: finalUnit }; } -// Parse duration string like "60s", "1:30", "1m 30s" to seconds +/** + * Convert duration string to total seconds. + * + * Supports multiple formats: + * - "60s" → 60 + * - "1:30" or "01:30" → 90 + * - "1m 30s" or "1m30s" → 90 + * - "45" → 45 (assumed seconds) + * + * Returns 0 if format is unrecognized. + */ export function parseDurationToSeconds(durationStr: string): number { const str = durationStr.trim(); @@ -136,14 +356,23 @@ export function parseDurationToSeconds(durationStr: string): number { return 0; } -// Format seconds to display string +/** + * Format seconds as MM:SS (padded for display). + * Example: 90 → "1:30" + */ export function formatDuration(seconds: number): string { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toString().padStart(2, '0')}`; } -// Format seconds to human readable string (e.g., "11m 33s") +/** + * Format seconds as human-readable string. + * Examples: + * - 30 → "30s" + * - 90 → "1m 30s" + * - 120 → "2m" + */ export function formatDurationHuman(seconds: number): string { const mins = Math.floor(seconds / 60); const secs = seconds % 60; @@ -156,15 +385,65 @@ export function formatDurationHuman(seconds: number): string { return `${mins}m ${secs}s`; } +/** + * Get the checkbox character for a given exercise state. + * Inverse of STATE_MAP - used during serialization. + */ export function getStateChar(state: ExerciseState): string { return STATE_CHAR_MAP[state]; } +/** + * Serialize an Exercise object back to markdown. + * + * Format: "- [STATE] Exercise Name | Key: [value] unit | Key: value" + * + * Logic: + * - Converts state to checkbox character + * - Outputs params with editable values in brackets + * - Locked params (no brackets) are system-managed (like ~time, ~rest) + */ export function serializeExercise(exercise: Exercise): string { const stateChar = getStateChar(exercise.state); let line = `- [${stateChar}] ${exercise.name}`; + // Append each parameter with proper formatting for (const param of exercise.params) { + line += ' | '; + line += `${param.key}: `; + if (param.editable) { + // User-editable values shown in brackets + line += `[${param.value}]`; + } else { + // Locked (system-managed) values without brackets + line += param.value; + } + if (param.unit) { + line += ` ${param.unit}`; + } + } + + return line; +} + +/** + * Serialize an ExerciseSet object back to markdown. + * + * Format: " - [STATE] | Key: [value] unit | Key: value" + * + * Logic: + * - Converts state to checkbox character + * - Outputs params with editable values in brackets + * - Locked params (no brackets) are system-managed (like ~time, ~rest) + * - Indents with 2 spaces to nest under exercise + */ +export function serializeSet(set: ExerciseSet): string { + // Indent all set lines 2 spaces relative to exercise line (indent = " " prefix) + const stateChar = getStateChar(set.state); + let line = ` - [${stateChar}]`; + + // Append each parameter using same format as exercise params + for (const param of set.params) { line += ' | '; line += `${param.key}: `; if (param.editable) { diff --git a/src/parser/index.test.ts b/src/parser/index.test.ts new file mode 100644 index 0000000..8709751 --- /dev/null +++ b/src/parser/index.test.ts @@ -0,0 +1,200 @@ +import { parseWorkout } from './index'; + +describe('parseWorkout', () => { + it('should parse minimal workout with metadata separator', () => { + const source = 'title: Test\nstate: planned\n---\n- [ ] Exercise'; + const result = parseWorkout(source); + + expect(result.metadata.title).toBe('Test'); + expect(result.metadata.state).toBe('planned'); + expect(result.exercises).toHaveLength(1); + expect(result.exercises[0].name).toBe('Exercise'); + }); + + it('should parse empty workout', () => { + const source = ''; + const result = parseWorkout(source); + + expect(result.metadata.state).toBe('planned'); + expect(result.exercises).toHaveLength(0); + }); + + it('should parse workout with no metadata', () => { + const source = '- [ ] Bench Press\n - [ ] | Weight: [100] kg'; + const result = parseWorkout(source); + + expect(result.metadata.state).toBe('planned'); + expect(result.exercises).toHaveLength(1); + expect(result.metadataEndIndex).toBe(-1); + }); + + it('should parse single exercise with single set', () => { + const source = '---\n- [ ] Bench Press\n - [ ] | Weight: [100] kg'; + const result = parseWorkout(source); + + expect(result.exercises).toHaveLength(1); + expect(result.exercises[0].name).toBe('Bench Press'); + expect(result.exercises[0].sets).toHaveLength(1); + expect(result.exercises[0].sets[0].params).toHaveLength(1); + }); + + it('should parse exercise with multiple sets', () => { + const source = '---\n- [ ] Squats\n - [ ] | Weight: [100] kg\n - [ ] | Weight: [110] kg\n - [ ] | Weight: [120] kg'; + const result = parseWorkout(source); + + expect(result.exercises).toHaveLength(1); + expect(result.exercises[0].sets).toHaveLength(3); + expect(result.exercises[0].sets[0].params[0].value).toBe('100'); + expect(result.exercises[0].sets[1].params[0].value).toBe('110'); + expect(result.exercises[0].sets[2].params[0].value).toBe('120'); + }); + + it('should parse multiple exercises', () => { + const source = '---\n- [ ] Bench Press\n - [ ] | Weight: [100] kg\n- [ ] Squats\n - [ ] | Weight: [150] kg'; + const result = parseWorkout(source); + + expect(result.exercises).toHaveLength(2); + expect(result.exercises[0].name).toBe('Bench Press'); + expect(result.exercises[1].name).toBe('Squats'); + }); + + it('should convert exercise params to first set if no sets defined', () => { + const source = '---\n- [ ] Running | Duration: 30m'; + const result = parseWorkout(source); + + expect(result.exercises[0].params).toHaveLength(0); + expect(result.exercises[0].sets).toHaveLength(1); + expect(result.exercises[0].sets[0].params).toHaveLength(1); + expect(result.exercises[0].sets[0].params[0].key).toBe('Duration'); + }); + + it('should handle exercise with params followed by sets', () => { + const source = '---\n- [ ] Bench\n - [ ] | Weight: [100] kg\n - [ ] | Weight: [110] kg'; + const result = parseWorkout(source); + + // Exercise params should be empty, sets should have params + expect(result.exercises[0].params).toHaveLength(0); + expect(result.exercises[0].sets).toHaveLength(2); + }); + + it('should skip empty lines', () => { + const source = '---\n\n- [ ] Exercise\n\n - [ ] | Weight: [50] kg\n\n'; + const result = parseWorkout(source); + + expect(result.exercises).toHaveLength(1); + expect(result.exercises[0].sets).toHaveLength(1); + }); + + it('should parse sets with indentation', () => { + const source = '---\n- [ ] Deadlift\n - [x] | Weight: 200 kg\n - this should be ignored (too much indent)\n - [ ] | Weight: 210 kg'; + const result = parseWorkout(source); + + expect(result.exercises[0].sets).toHaveLength(2); + }); + + it('should parse exercise in different states', () => { + const source = '---\n- [ ] Pending\n - [ ] | Weight: [50] kg\n- [x] Completed\n - [x] | Weight: [50] kg\n- [\\] InProgress\n - [\\] | Weight: [50] kg\n- [-] Skipped\n - [-] | Weight: [50] kg'; + const result = parseWorkout(source); + + expect(result.exercises).toHaveLength(4); + expect(result.exercises[0].state).toBe('pending'); + expect(result.exercises[1].state).toBe('completed'); + expect(result.exercises[2].state).toBe('inProgress'); + expect(result.exercises[3].state).toBe('skipped'); + }); + + it('should preserve metadata and exercise separation', () => { + const source = 'title: My Workout\nstate: started\n---\n- [ ] Exercise'; + const result = parseWorkout(source); + + expect(result.metadataEndIndex).toBe(2); + expect(result.metadata.title).toBe('My Workout'); + expect(result.exercises).toHaveLength(1); + }); + + it('should handle no separator', () => { + const source = 'title: My Workout\n- [ ] Exercise'; + const result = parseWorkout(source); + + expect(result.metadataEndIndex).toBe(-1); + // When there's no separator, all lines are treated as exercises + expect(result.metadata.title).toBeUndefined(); // title line is not metadata without separator + expect(result.exercises).toHaveLength(1); // The "- [ ] Exercise" line is parsed + }); + + it('should store rawLines', () => { + const source = 'title: Test\n---\n- [ ] Ex'; + const result = parseWorkout(source); + + expect(result.rawLines).toBeDefined(); + expect(result.rawLines.length).toBe(3); + }); + + it('should handle complex workout with all features', () => { + const source = 'title: Full Body\nstate: started\nrestDuration: 60s\n---\n- [ ] Bench Press | Weight: [100] kg\n - [x] | Weight: 100 kg | Reps: 8\n - [ ] | Weight: [105] kg | Reps: [6]\n- [\\] Squats | Reps: [10]\n - [x] | Weight: 100 kg | Reps: 10\n - [\\] | Weight: [120] kg | Reps: [8]'; + const result = parseWorkout(source); + + expect(result.metadata.title).toBe('Full Body'); + expect(result.metadata.state).toBe('started'); + expect(result.metadata.restDuration).toBe(60); + expect(result.exercises).toHaveLength(2); + expect(result.exercises[0].name).toBe('Bench Press'); + expect(result.exercises[0].sets).toHaveLength(2); + expect(result.exercises[1].name).toBe('Squats'); + expect(result.exercises[1].sets).toHaveLength(2); + }); + + it('should handle exercises where first set gets params', () => { + const source = '---\n- [ ] Running | Duration: [600]'; + const result = parseWorkout(source); + + // When there are params on exercise with no explicit sets, + // those params should move to the first set + expect(result.exercises[0].params).toHaveLength(0); + expect(result.exercises[0].sets).toHaveLength(1); + expect(result.exercises[0].sets[0].params).toHaveLength(1); + expect(result.exercises[0].sets[0].params[0].key).toBe('Duration'); + expect(result.exercises[0].sets[0].params[0].value).toBe('600'); + }); + + it('should handle multiple exercises where middle one has no sets', () => { + const source = '---\n- [ ] Ex1\n - [ ] | Weight: [50] kg\n- [ ] Ex2 | Weight: [60] kg\n- [ ] Ex3\n - [ ] | Weight: [70] kg'; + const result = parseWorkout(source); + + expect(result.exercises).toHaveLength(3); + expect(result.exercises[1].params).toHaveLength(0); + expect(result.exercises[1].sets).toHaveLength(1); + expect(result.exercises[1].sets[0].params[0].value).toBe('60'); + }); + + it('should parse exercises with targetDuration', () => { + const source = '---\n- [ ] Cardio | Duration: [60]'; + const result = parseWorkout(source); + + expect(result.exercises[0].targetDuration).toBe(60); + }); + + it('should parse exercises with recordedDuration', () => { + const source = '---\n- [x] Cardio | Duration: 65s'; + const result = parseWorkout(source); + + // Duration params (locked or editable) are parsed as targetDuration in seconds + expect(result.exercises[0].targetDuration).toBe(65); + }); + + it('should handle tabs and spaces for indentation', () => { + const source = '---\n- [ ] Exercise\n\t- [ ] | Weight: [50] kg'; + const result = parseWorkout(source); + + expect(result.exercises[0].sets).toHaveLength(1); + }); + + it('should preserve line indices', () => { + const source = '---\n- [ ] Ex1\n - [ ] | W: [50] kg\n- [ ] Ex2'; + const result = parseWorkout(source); + + // Line indices are relative to the start of exercise lines + expect(result.exercises[0].lineIndex).toBe(0); + expect(result.exercises[1].lineIndex).toBe(2); + }); +}); diff --git a/src/parser/index.ts b/src/parser/index.ts index c6a7104..7844ccb 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -1,11 +1,37 @@ -import { ParsedWorkout } from '../types'; +import { ParsedWorkout, Exercise, ExerciseSet } from '../types'; import { parseMetadata } from './metadata'; -import { parseExercise } from './exercise'; +import { parseExercise, parseSet } from './exercise'; +/** + * Parse a markdown workout block into structured data. + * + * Workout Format: + * ``` + * title: Workout Name + * state: planned|started|completed + * startDate: 2026-01-08 + * duration: 11m 33s + * --- + * - [ ] Exercise Name | Key: [value] unit | Key: value + * - [ ] | Key: [value] | Key: value + * - [ ] | Key: [value] | Key: value + * - [ ] Another Exercise Name | Key: [value] unit | Key: value + * - [ ] | Key: [value] | Key: value + * - [ ] Single Exercise Name | Key: [value] unit | Key: value + * ``` + * + * Structure: + * - Metadata section (lines before ---): workout-level config + * - Exercises section (lines after ---): list of exercises with nested sets + * - Each exercise can have multiple numbered sets (indented) + * - If exercise has no explicit sets, params become the default set + * + * Returns a ParsedWorkout with metadata, exercises, and line indices for updating. + */ export function parseWorkout(source: string): ParsedWorkout { const rawLines = source.split('\n'); - // Find the separator between metadata and exercises + // Find the separator line (---) that divides metadata from exercises let separatorIndex = -1; for (let i = 0; i < rawLines.length; i++) { if (rawLines[i]?.trim() === '---') { @@ -14,27 +40,81 @@ export function parseWorkout(source: string): ParsedWorkout { } } - // Parse metadata (lines before ---) + // Parse metadata section (all lines before the ---) + // If no separator found, assume no metadata const metadataLines = separatorIndex > 0 ? rawLines.slice(0, separatorIndex) : []; const metadata = parseMetadata(metadataLines); - // Parse exercises (lines after ---) + // Parse exercises section (all lines after the ---) + // Each line is either: + // - An exercise (no indent): "- [state] Exercise Name | params" + // - A set (indented): " - [state] | params" const exerciseStartIndex = separatorIndex >= 0 ? separatorIndex + 1 : 0; const exerciseLines = rawLines.slice(exerciseStartIndex); - const exercises = []; + const exercises: Exercise[] = []; + let currentExercise: Exercise | null = null; + for (let i = 0; i < exerciseLines.length; i++) { const line = exerciseLines[i]; - if (!line) continue; + if (!line || !line.trim()) continue; + + const isIndented = line.match(/^\s+/); + + if (isIndented) { + // Indented line = Set + // Add to current exercise's sets array + if (currentExercise) { + const set = parseSet(line, i); + if (set) { + currentExercise.sets.push(set); + } + } + } else { + // Non-indented line = Exercise + // Special case: if previous exercise has no sets (all params were exercise-level), + // create a default set from those params to maintain structure + if (currentExercise && currentExercise.sets.length === 0) { + currentExercise.sets.push({ + state: currentExercise.state, + params: currentExercise.params, + lineIndex: currentExercise.lineIndex, + targetDuration: currentExercise.targetDuration, + targetRest: currentExercise.targetRest, + recordedTime: currentExercise.recordedTime, + recordedRest: currentExercise.recordedRest + }); + currentExercise.params = []; + } - const exercise = parseExercise(line, i); - if (exercise) { - exercises.push(exercise); + const exercise = parseExercise(line, i); + if (exercise) { + currentExercise = { + ...exercise, + sets: [] + }; + exercises.push(currentExercise); + } } } + // Handle the last exercise: if no sets were added, create a default set from exercise params + // This ensures every exercise has at least one set for the UI to render + if (currentExercise && currentExercise.sets.length === 0) { + currentExercise.sets.push({ + state: currentExercise.state, + params: currentExercise.params, + lineIndex: currentExercise.lineIndex, + targetDuration: currentExercise.targetDuration, + targetRest: currentExercise.targetRest, + recordedTime: currentExercise.recordedTime, + recordedRest: currentExercise.recordedRest + }); + currentExercise.params = []; + } + return { metadata, exercises, @@ -43,5 +123,9 @@ export function parseWorkout(source: string): ParsedWorkout { }; } +// Re-export metadata parsing and serialization export { parseMetadata, serializeMetadata } from './metadata'; -export { parseExercise, serializeExercise, formatDuration, formatDurationHuman, parseDurationToSeconds, getStateChar } from './exercise'; + +// Re-export exercise and set parsing, serialization, and utility functions +// These provide granular access for direct parsing of individual lines (used in tests) +export { parseExercise, parseSet, serializeExercise, serializeSet, formatDuration, formatDurationHuman, parseDurationToSeconds, getStateChar } from './exercise'; diff --git a/src/parser/metadata.test.ts b/src/parser/metadata.test.ts new file mode 100644 index 0000000..540c08b --- /dev/null +++ b/src/parser/metadata.test.ts @@ -0,0 +1,254 @@ +import { parseMetadata, serializeMetadata } from './metadata'; +import { WorkoutState } from '../types'; + +describe('parseMetadata', () => { + it('should parse empty metadata', () => { + const result = parseMetadata([]); + expect(result.state).toBe('planned'); + expect(result.title).toBeUndefined(); + }); + + it('should parse title', () => { + const lines = ['title: Full Body Workout']; + const result = parseMetadata(lines); + + expect(result.title).toBe('Full Body Workout'); + }); + + it('should parse state - planned', () => { + const lines = ['state: planned']; + const result = parseMetadata(lines); + + expect(result.state).toBe('planned'); + }); + + it('should parse state - started', () => { + const lines = ['state: started']; + const result = parseMetadata(lines); + + expect(result.state).toBe('started'); + }); + + it('should parse state - completed', () => { + const lines = ['state: completed']; + const result = parseMetadata(lines); + + expect(result.state).toBe('completed'); + }); + + it('should ignore invalid state and use default', () => { + const lines = ['state: invalid']; + const result = parseMetadata(lines); + + expect(result.state).toBe('planned'); + }); + + it('should parse startDate', () => { + const lines = ['startDate: 2026-01-08 15:45']; + const result = parseMetadata(lines); + + expect(result.startDate).toBe('2026-01-08 15:45'); + }); + + it('should parse duration', () => { + const lines = ['duration: 45m 30s']; + const result = parseMetadata(lines); + + expect(result.duration).toBe('45m 30s'); + }); + + it('should parse restDuration in seconds', () => { + const lines = ['restDuration: 60s']; + const result = parseMetadata(lines); + + expect(result.restDuration).toBe(60); + }); + + it('should parse restDuration in MM:SS format', () => { + const lines = ['restDuration: 1:30']; + const result = parseMetadata(lines); + + expect(result.restDuration).toBe(90); + }); + + it('should parse saveToProperties as true', () => { + const lines = ['saveToProperties: true']; + const result = parseMetadata(lines); + + expect(result.saveToProperties).toBe(true); + }); + + it('should parse saveToProperties as false', () => { + const lines = ['saveToProperties: false']; + const result = parseMetadata(lines); + + expect(result.saveToProperties).toBe(false); + }); + + it('should handle multiple metadata fields', () => { + const lines = [ + 'title: Full Body', + 'state: started', + 'startDate: 2026-01-08', + 'duration: 30m', + 'restDuration: 90s', + 'saveToProperties: true' + ]; + const result = parseMetadata(lines); + + expect(result.title).toBe('Full Body'); + expect(result.state).toBe('started'); + expect(result.startDate).toBe('2026-01-08'); + expect(result.duration).toBe('30m'); + expect(result.restDuration).toBe(90); + expect(result.saveToProperties).toBe(true); + }); + + it('should ignore lines without colons', () => { + const lines = ['invalid line', 'title: My Workout']; + const result = parseMetadata(lines); + + expect(result.title).toBe('My Workout'); + }); + + it('should ignore empty values', () => { + const lines = ['title: ', 'startDate: 2026-01-08']; + const result = parseMetadata(lines); + + expect(result.title).toBeUndefined(); + expect(result.startDate).toBe('2026-01-08'); + }); + + it('should be case-insensitive for keys', () => { + const lines = ['TITLE: My Workout', 'STATE: completed', 'RestDuration: 120s']; + const result = parseMetadata(lines); + + expect(result.title).toBe('My Workout'); + expect(result.state).toBe('completed'); + expect(result.restDuration).toBe(120); + }); + + it('should ignore invalid restDuration', () => { + const lines = ['restDuration: invalid']; + const result = parseMetadata(lines); + + expect(result.restDuration).toBeUndefined(); + }); + + it('should trim whitespace from fields', () => { + const lines = [' title : My Workout ', ' state : started ']; + const result = parseMetadata(lines); + + expect(result.title).toBe('My Workout'); + expect(result.state).toBe('started'); + }); +}); + +describe('serializeMetadata', () => { + it('should serialize with only state (minimal)', () => { + const metadata = { state: 'planned' as WorkoutState }; + const result = serializeMetadata(metadata); + + expect(result).toContain('state: planned'); + expect(result.length).toBe(1); + }); + + it('should serialize title', () => { + const metadata = { + state: 'planned' as WorkoutState, + title: 'Full Body Workout' + }; + const result = serializeMetadata(metadata); + + expect(result).toContain('title: Full Body Workout'); + expect(result).toContain('state: planned'); + }); + + it('should serialize startDate', () => { + const metadata = { + state: 'started' as WorkoutState, + startDate: '2026-01-08 15:45' + }; + const result = serializeMetadata(metadata); + + expect(result).toContain('startDate: 2026-01-08 15:45'); + }); + + it('should serialize duration', () => { + const metadata = { + state: 'completed' as WorkoutState, + duration: '45m 30s' + }; + const result = serializeMetadata(metadata); + + expect(result).toContain('duration: 45m 30s'); + }); + + it('should serialize restDuration as human readable', () => { + const metadata = { + state: 'planned' as WorkoutState, + restDuration: 90 + }; + const result = serializeMetadata(metadata); + + expect(result).toContain('restDuration: 1m 30s'); + }); + + it('should serialize saveToProperties', () => { + const metadata = { + state: 'planned' as WorkoutState, + saveToProperties: true + }; + const result = serializeMetadata(metadata); + + expect(result).toContain('saveToProperties: true'); + }); + + it('should serialize all fields', () => { + const metadata = { + state: 'completed' as WorkoutState, + title: 'Full Body', + startDate: '2026-01-08', + duration: '30m', + restDuration: 120, + saveToProperties: true + }; + const result = serializeMetadata(metadata); + + expect(result).toContain('title: Full Body'); + expect(result).toContain('state: completed'); + expect(result).toContain('startDate: 2026-01-08'); + expect(result).toContain('duration: 30m'); + expect(result).toContain('restDuration: 2m'); + expect(result).toContain('saveToProperties: true'); + }); + + it('should serialize saveToProperties as false', () => { + const metadata = { + state: 'planned' as WorkoutState, + saveToProperties: false + }; + const result = serializeMetadata(metadata); + + expect(result).toContain('saveToProperties: false'); + }); + + it('should roundtrip parse and serialize', () => { + const lines = [ + 'title: Test Workout', + 'state: completed', + 'startDate: 2026-01-08 10:00', + 'duration: 60m', + 'restDuration: 2m 30s' + ]; + const parsed = parseMetadata(lines); + const serialized = serializeMetadata(parsed); + const reparsed = parseMetadata(serialized); + + expect(reparsed.title).toBe(parsed.title); + expect(reparsed.state).toBe(parsed.state); + expect(reparsed.startDate).toBe(parsed.startDate); + expect(reparsed.duration).toBe(parsed.duration); + expect(reparsed.restDuration).toBe(parsed.restDuration); + }); +}); diff --git a/src/parser/metadata.ts b/src/parser/metadata.ts index 53fd366..d63ced1 100644 --- a/src/parser/metadata.ts +++ b/src/parser/metadata.ts @@ -1,63 +1,135 @@ +/** + * Metadata parsing and serialization for workout blocks. + * + * Handles the header section of a workout (before the --- separator): + * title, state, startDate, duration, restDuration, saveToProperties + * + * Key features: + * - parseMetadata() extracts key: value pairs from markdown lines + * - serializeMetadata() converts WorkoutMetadata back to markdown lines + * - State validation against VALID_STATES + * - Duration parsing/formatting (supports compound notation like "1m 30s") + */ + import { WorkoutMetadata, WorkoutState } from '../types'; import { parseDurationToSeconds, formatDurationHuman } from './exercise'; +/** + * Valid workout states. Only these values are accepted during parsing. + * - 'planned': workout setup, not started + * - 'started': workout in progress + * - 'completed': workout finished + */ const VALID_STATES: WorkoutState[] = ['planned', 'started', 'completed']; +/** + * Parse metadata lines from a workout block. + * + * Format: Each line is "key: value" where key is case-insensitive. + * + * Supported keys: + * - title: string (workout name) + * - state: 'planned' | 'started' | 'completed' (must be valid) + * - startDate: string (ISO date or datetime) + * - duration: string (human-readable format like "1m 30s") + * - restDuration: string (rest duration as seconds, parsed from human format) + * - saveToProperties: 'true' | 'false' (boolean as string) + * + * Invalid lines (no colon, unknown keys) are silently ignored. + * Unknown state values are rejected (defaults to 'planned'). + */ export function parseMetadata(lines: string[]): WorkoutMetadata { + // Start with default state; other fields optional const metadata: WorkoutMetadata = { state: 'planned' }; for (const line of lines) { + // Skip lines without colon separator const colonIndex = line.indexOf(':'); if (colonIndex === -1) continue; + // Extract key (case-insensitive) and value const key = line.substring(0, colonIndex).trim().toLowerCase(); const value = line.substring(colonIndex + 1).trim(); switch (key) { case 'title': + // Store workout name if provided if (value) metadata.title = value; break; case 'state': + // Only accept valid state values; reject unknown states if (VALID_STATES.includes(value as WorkoutState)) { metadata.state = value as WorkoutState; } break; case 'startdate': + // Store ISO date/datetime when workout started if (value) metadata.startDate = value; break; case 'duration': + // Store as-is; actual elapsed time recorded during workout if (value) metadata.duration = value; break; case 'restduration': + // Parse human-readable format to seconds (e.g., "1m 30s" → 90) if (value) { const seconds = parseDurationToSeconds(value); if (seconds > 0) metadata.restDuration = seconds; } break; + case 'savetoproperties': + // Parse string 'true'/'false' to boolean + metadata.saveToProperties = value.toLowerCase() === 'true'; + break; } } return metadata; } +/** + * Convert WorkoutMetadata back to markdown header lines. + * + * Output format: "key: value" lines, one per field. + * - state: always included + * - other fields: only if defined (optional fields) + * - restDuration: converted from seconds to human format (e.g., 90 → "1m 30s") + * - saveToProperties: converted from boolean to lowercase string + * + * Returns array of lines (without the --- separator). + */ export function serializeMetadata(metadata: WorkoutMetadata): string[] { const lines: string[] = []; + // Optional: title if (metadata.title !== undefined) { lines.push(`title: ${metadata.title}`); } + + // Required: state (always present) lines.push(`state: ${metadata.state}`); + + // Optional: startDate (ISO format) if (metadata.startDate !== undefined) { lines.push(`startDate: ${metadata.startDate}`); } + + // Optional: duration (actual elapsed time as string) if (metadata.duration !== undefined) { lines.push(`duration: ${metadata.duration}`); } + + // Optional: restDuration (stored as seconds, convert to human format) if (metadata.restDuration !== undefined) { lines.push(`restDuration: ${formatDurationHuman(metadata.restDuration)}`); } + // Optional: saveToProperties (convert boolean to string) + if (metadata.saveToProperties !== undefined) { + lines.push(`saveToProperties: ${metadata.saveToProperties}`); + } + return lines; } diff --git a/src/renderer/controls.test.ts b/src/renderer/controls.test.ts new file mode 100644 index 0000000..de3284b --- /dev/null +++ b/src/renderer/controls.test.ts @@ -0,0 +1,540 @@ +import { renderWorkoutControls } from './controls'; +import { WorkoutCallbacks, ParsedWorkout } from '../types'; +import * as serializer from '../serializer'; + +// Mock the serializer module +jest.mock('../serializer', () => ({ + serializeWorkoutAsTemplate: jest.fn(() => 'mocked template content') +})); + +// Mock navigator.clipboard +const mockClipboard = { + writeText: jest.fn() +}; +Object.assign(navigator, { clipboard: mockClipboard }); + +// MockElement class to simulate DOM without jsdom +class MockElement { + tag: string; + className: string = ''; + children: MockElement[] = []; + parent: MockElement | null = null; + listeners: { [key: string]: Function[] } = {}; + _textContent: string = ''; + document: any; + styleMap: Map = new Map(); + attributes: Map = new Map(); + + constructor(tag: string) { + this.tag = tag; + } + + get textContent(): string { + // Aggregate text from this element and all children + let text = this._textContent; + for (const child of this.children) { + text += child.textContent; + } + return text; + } + + set textContent(value: string) { + this._textContent = value; + } + + createDiv(options?: { cls?: string; text?: string }): MockElement { + return this.createEl('div', options); + } + + createEl(tag: string, options?: { cls?: string; text?: string }): MockElement { + const el = new MockElement(tag); + el.parent = this; + if (options?.cls) el.className = options.cls; + if (options?.text) el.textContent = options.text; + this.children.push(el); + return el; + } + + createSpan(options?: { cls?: string; text?: string }): MockElement { + return this.createEl('span', options); + } + + querySelector(selector: string): MockElement | null { + if (selector.startsWith('.')) { + const className = selector.substring(1); + const found = this._findByClass(className); + return found; + } + if (selector === 'span:last-child') { + if (this.children.length > 0) { + const lastChild = this.children[this.children.length - 1]; + if (lastChild.tag === 'span') return lastChild; + } + return null; + } + const tagName = selector; + return this._findByTag(tagName); + } + + querySelectorAll(selector: string): MockElement[] { + if (selector.startsWith('.')) { + const className = selector.substring(1); + return this._findAllByClass(className); + } + const tagName = selector; + return this._findAllByTag(tagName); + } + + private _findByClass(className: string): MockElement | null { + if (this.className.split(' ').includes(className)) return this; + for (const child of this.children) { + const found = child._findByClass(className); + if (found) return found; + } + return null; + } + + private _findAllByClass(className: string): MockElement[] { + const results: MockElement[] = []; + if (this.className.split(' ').includes(className)) { + results.push(this); + } + for (const child of this.children) { + results.push(...child._findAllByClass(className)); + } + return results; + } + + private _findByTag(tagName: string): MockElement | null { + if (this.tag === tagName) return this; + for (const child of this.children) { + const found = child._findByTag(tagName); + if (found) return found; + } + return null; + } + + private _findAllByTag(tagName: string): MockElement[] { + const results: MockElement[] = []; + if (this.tag === tagName) { + results.push(this); + } + for (const child of this.children) { + results.push(...child._findAllByTag(tagName)); + } + return results; + } + + addEventListener(event: string, handler: Function): void { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(handler); + } + + click(): void { + if (this.listeners['click']) { + for (const handler of this.listeners['click']) { + handler(new Event('click')); + } + } + } + + addClass(cls: string): void { + const classes = this.className.split(' ').filter(c => c); + if (!classes.includes(cls)) { + classes.push(cls); + } + this.className = classes.join(' '); + } + + removeClass(cls: string): void { + const classes = this.className.split(' ').filter(c => c && c !== cls); + this.className = classes.join(' '); + } + + setAttribute(key: string, value: string): void { + this.attributes.set(key, value); + } + + removeAttribute(key: string): void { + this.attributes.delete(key); + } + + hasAttribute(key: string): boolean { + return this.attributes.has(key); + } + + setStyle(prop: string, value: string): void { + this.styleMap.set(prop, value); + } + + getStyle(prop: string): string | undefined { + return this.styleMap.get(prop); + } +} + +describe('renderWorkoutControls', () => { + let container: MockElement; + let mockCallbacks: WorkoutCallbacks; + let mockWorkout: ParsedWorkout; + + beforeEach(() => { + container = new MockElement('div'); + mockCallbacks = { + onExerciseStateChange: jest.fn(), + onSetStateChange: jest.fn(), + onParamChange: jest.fn(), + onStartWorkout: jest.fn(), + onPauseWorkout: jest.fn(), + onResumeWorkout: jest.fn(), + onSkipExercise: jest.fn(), + onStartExerciseTimer: jest.fn(), + onExerciseRest: jest.fn(), + onExerciseRestPause: jest.fn(), + onExerciseRestResume: jest.fn(), + onExerciseRestDone: jest.fn(), + onExerciseFinish: jest.fn(), + onFlushChanges: jest.fn(), + onAddSample: jest.fn(), + onSetRecordedDuration: jest.fn(), + onChangeRestDuration: jest.fn(), + onAddRest: jest.fn(), + onAddSet: jest.fn() + }; + mockWorkout = { + metadata: { title: 'Test Workout', state: 'planned' }, + exercises: [ + { + name: 'Push Ups', + state: 'pending', + params: [], + sets: [{ state: 'pending', params: [], lineIndex: 1 }], + lineIndex: 0 + } + ], + rawLines: [], + metadataEndIndex: -1 + }; + jest.clearAllMocks(); + }); + + describe('Planned state', () => { + it('should create controls container with correct class', () => { + renderWorkoutControls(container, 'planned', mockCallbacks, mockWorkout); + expect(container.querySelector('.workout-controls')).toBeTruthy(); + }); + + it('should render start button', () => { + renderWorkoutControls(container, 'planned', mockCallbacks, mockWorkout); + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + expect(button?.tag).toBe('button'); + }); + + it('should add correct styling classes to start button', () => { + renderWorkoutControls(container, 'planned', mockCallbacks, mockWorkout); + const button = container.querySelector('button'); + expect(button?.className).toContain('workout-btn'); + expect(button?.className).toContain('workout-btn-primary'); + expect(button?.className).toContain('workout-btn-large'); + }); + + it('should render play icon in button', () => { + renderWorkoutControls(container, 'planned', mockCallbacks, mockWorkout); + const icon = container.querySelector('.workout-btn-icon'); + expect(icon).toBeTruthy(); + expect(icon?.textContent).toBe('▶'); + }); + + it('should render start button text', () => { + renderWorkoutControls(container, 'planned', mockCallbacks, mockWorkout); + const button = container.querySelector('button'); + expect(button?.textContent).toContain('Start Workout'); + }); + + it('should call onStartWorkout when button clicked', async () => { + renderWorkoutControls(container, 'planned', mockCallbacks, mockWorkout); + const button = container.querySelector('button') as MockElement; + + button.click(); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCallbacks.onStartWorkout).toHaveBeenCalledTimes(1); + }); + + it('should prevent double-click on start button', async () => { + renderWorkoutControls(container, 'planned', mockCallbacks, mockWorkout); + const button = container.querySelector('button') as MockElement; + + // Click twice rapidly + button.click(); + button.click(); + await new Promise(resolve => setTimeout(resolve, 10)); + + // Should only be called once due to isProcessing flag + expect(mockCallbacks.onStartWorkout).toHaveBeenCalledTimes(1); + }); + + it('should add processing class during start', async () => { + const slowCallback = jest.fn(async () => { + await new Promise(resolve => setTimeout(resolve, 5)); + }); + mockCallbacks.onStartWorkout = slowCallback; + + renderWorkoutControls(container, 'planned', mockCallbacks, mockWorkout); + const button = container.querySelector('button') as MockElement; + + const clickPromise = Promise.resolve().then(() => button.click()); + await new Promise(resolve => setTimeout(resolve, 2)); + + expect(button.className).toContain('workout-btn-processing'); + }); + + it('should disable button during start', async () => { + let resolveCallback: (() => void) | undefined; + mockCallbacks.onStartWorkout = jest.fn(async () => { + await new Promise(resolve => { + resolveCallback = resolve; + }); + }); + + renderWorkoutControls(container, 'planned', mockCallbacks, mockWorkout); + const button = container.querySelector('button') as MockElement; + + button.click(); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(button.hasAttribute('disabled')).toBe(true); + + // Cleanup + if (resolveCallback) resolveCallback(); + await new Promise(resolve => setTimeout(resolve, 20)); + }); + + it('should remove processing class after start completes', async () => { + renderWorkoutControls(container, 'planned', mockCallbacks, mockWorkout); + const button = container.querySelector('button') as MockElement; + + button.click(); + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(button.className).not.toContain('workout-btn-processing'); + }); + + it('should remove disabled attribute after start completes', async () => { + renderWorkoutControls(container, 'planned', mockCallbacks, mockWorkout); + const button = container.querySelector('button') as MockElement; + + button.click(); + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(button.hasAttribute('disabled')).toBe(false); + }); + + it('should recover from callback execution', async () => { + mockCallbacks.onStartWorkout = jest.fn(async () => { + await new Promise(resolve => setTimeout(resolve, 5)); + }); + + renderWorkoutControls(container, 'planned', mockCallbacks, mockWorkout); + const button = container.querySelector('button') as MockElement; + + button.click(); + await new Promise(resolve => setTimeout(resolve, 30)); + + // Button should be re-enabled after callback completes + expect(button.hasAttribute('disabled')).toBe(false); + expect(button.className).not.toContain('workout-btn-processing'); + }); + + it('should return the controls element', () => { + const result = renderWorkoutControls(container, 'planned', mockCallbacks, mockWorkout); + expect(result).toBeInstanceOf(MockElement); + expect(result.className).toContain('workout-controls'); + }); + }); + + describe('Completed state', () => { + it('should create controls container with correct class', () => { + renderWorkoutControls(container, 'completed', mockCallbacks, mockWorkout); + expect(container.querySelector('.workout-controls')).toBeTruthy(); + }); + + it('should render completed label', () => { + renderWorkoutControls(container, 'completed', mockCallbacks, mockWorkout); + const label = container.querySelector('.workout-completed-label'); + expect(label).toBeTruthy(); + }); + + it('should render checkmark icon in completed label', () => { + renderWorkoutControls(container, 'completed', mockCallbacks, mockWorkout); + const label = container.querySelector('.workout-completed-label'); + const icon = label?.querySelector('.workout-btn-icon'); + expect(icon?.textContent).toBe('✓'); + }); + + it('should render completed text', () => { + renderWorkoutControls(container, 'completed', mockCallbacks, mockWorkout); + const label = container.querySelector('.workout-completed-label'); + expect(label?.textContent).toContain('Completed'); + }); + + it('should render copy as template button', () => { + renderWorkoutControls(container, 'completed', mockCallbacks, mockWorkout); + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + expect(button?.textContent).toContain('Copy as Template'); + }); + + it('should render clipboard icon in copy button', () => { + renderWorkoutControls(container, 'completed', mockCallbacks, mockWorkout); + const button = container.querySelector('button'); + const icon = button?.querySelector('.workout-btn-icon'); + expect(icon?.textContent).toBe('📋'); + }); + + it('should add workout-btn class to copy button', () => { + renderWorkoutControls(container, 'completed', mockCallbacks, mockWorkout); + const button = container.querySelector('button'); + expect(button?.className).toContain('workout-btn'); + }); + + it('should call serializeWorkoutAsTemplate when copy button clicked', async () => { + renderWorkoutControls(container, 'completed', mockCallbacks, mockWorkout); + const button = container.querySelector('button') as MockElement; + + button.click(); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(serializer.serializeWorkoutAsTemplate).toHaveBeenCalledWith(mockWorkout); + }); + + it('should copy serialized template to clipboard', async () => { + renderWorkoutControls(container, 'completed', mockCallbacks, mockWorkout); + const button = container.querySelector('button') as MockElement; + + button.click(); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockClipboard.writeText).toHaveBeenCalledWith('```workout\nmocked template content\n```'); + }); + + it('should change button text to "Copied!" after copy', async () => { + renderWorkoutControls(container, 'completed', mockCallbacks, mockWorkout); + const button = container.querySelector('button') as MockElement; + const textSpan = button?.querySelector('span:last-child'); + + button.click(); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(textSpan?.textContent).toBe('Copied!'); + }); + + it('should restore original button text after delay', async () => { + renderWorkoutControls(container, 'completed', mockCallbacks, mockWorkout); + const button = container.querySelector('button') as MockElement; + const textSpan = button?.querySelector('span:last-child'); + + button.click(); + await new Promise(resolve => setTimeout(resolve, 1600)); + + expect(textSpan?.textContent).toBe('Copy as Template'); + }); + + it('should handle clipboard write errors gracefully', async () => { + mockClipboard.writeText.mockResolvedValueOnce(undefined); + + renderWorkoutControls(container, 'completed', mockCallbacks, mockWorkout); + const button = container.querySelector('button') as MockElement; + + // Should not throw + button.click(); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockClipboard.writeText).toHaveBeenCalled(); + }); + + it('should handle missing text span gracefully', async () => { + renderWorkoutControls(container, 'completed', mockCallbacks, mockWorkout); + const button = container.querySelector('button') as MockElement; + + // Override querySelector to return null + const originalQuerySelector = button.querySelector.bind(button); + button.querySelector = jest.fn((selector: string) => { + if (selector === 'span:last-child') return null; + return originalQuerySelector(selector); + }); + + // Should not throw + button.click(); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockClipboard.writeText).toHaveBeenCalled(); + }); + + it('should return the controls element', () => { + const result = renderWorkoutControls(container, 'completed', mockCallbacks, mockWorkout); + expect(result).toBeInstanceOf(MockElement); + expect(result.className).toContain('workout-controls'); + }); + }); + + describe('Started state', () => { + it('should not render any controls for started state', () => { + renderWorkoutControls(container, 'started', mockCallbacks, mockWorkout); + const controlsEl = container.querySelector('.workout-controls'); + expect(controlsEl?.children.length).toBe(0); + }); + + it('should return empty controls container for started state', () => { + const result = renderWorkoutControls(container, 'started', mockCallbacks, mockWorkout); + expect(result.children.length).toBe(0); + }); + }); + + describe('General functionality', () => { + it('should append controls to container', () => { + const result = renderWorkoutControls(container, 'planned', mockCallbacks, mockWorkout); + expect(container.children).toContain(result); + }); + + it('should return HTMLElement', () => { + const result = renderWorkoutControls(container, 'planned', mockCallbacks, mockWorkout); + expect(result).toBeInstanceOf(MockElement); + }); + + it('should work with different workout exercises', async () => { + const multiExerciseWorkout: ParsedWorkout = { + metadata: { title: 'Full Body', state: 'completed' }, + exercises: [ + { + name: 'Push Ups', + state: 'completed', + params: [], + sets: [{ state: 'completed', params: [], lineIndex: 1 }], + lineIndex: 0 + }, + { + name: 'Pull Ups', + state: 'completed', + params: [], + sets: [{ state: 'completed', params: [], lineIndex: 4 }], + lineIndex: 3 + } + ], + rawLines: [], + metadataEndIndex: -1 + }; + + renderWorkoutControls(container, 'completed', mockCallbacks, multiExerciseWorkout); + const button = container.querySelector('button') as MockElement; + expect(button?.textContent).toContain('Copy as Template'); + + button.click(); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(serializer.serializeWorkoutAsTemplate).toHaveBeenCalledWith(multiExerciseWorkout); + }); + }); +}); diff --git a/src/renderer/controls.ts b/src/renderer/controls.ts index 9ec1e2d..a168b28 100644 --- a/src/renderer/controls.ts +++ b/src/renderer/controls.ts @@ -1,6 +1,39 @@ +/** + * Workout-level control rendering. + * + * Renders state-dependent control buttons at the workout level: + * - Planned state: "Start Workout" button to begin the workout + * - Completed state: "Completed" label + "Copy as Template" button + * + * Key features: + * - Double-click prevention on "Start Workout" via isProcessing flag + * - Visual feedback (disabled state, processing class) during async operations + * - Copy-to-clipboard with user confirmation ("Copied!" feedback) + * - Template serialization for reusing completed workouts as templates + */ + import { WorkoutState, WorkoutCallbacks, ParsedWorkout } from '../types'; import { serializeWorkoutAsTemplate } from '../serializer'; +/** + * Render workout-level control buttons based on workout state. + * + * Displays different controls depending on state: + * + * STATE: planned + * - Shows "Start Workout" button (▶ icon) + * - Button triggers onStartWorkout callback + * - Includes double-click prevention with isProcessing flag + * - Visual feedback: disabled state + processing class during async start + * + * STATE: completed + * - Shows "Completed" label (✓ icon) + * - Shows "Copy as Template" button (📋 icon) + * - Button copies template to clipboard with "Copied!" visual feedback (1.5s) + * - Template serialization converts completed workout to reusable template + * + * Returns: Controls container element + */ export function renderWorkoutControls( container: HTMLElement, state: WorkoutState, @@ -10,20 +43,25 @@ export function renderWorkoutControls( const controlsEl = container.createDiv({ cls: 'workout-controls' }); if (state === 'planned') { + // Render "Start Workout" button const startBtn = controlsEl.createEl('button', { cls: 'workout-btn workout-btn-primary workout-btn-large' }); startBtn.createSpan({ cls: 'workout-btn-icon', text: '▶' }); startBtn.createSpan({ text: 'Start Workout' }); - // Use a flag to prevent double-triggers on mobile + // Double-click prevention: track if operation is in progress let isProcessing = false; const handleStart = async () => { + // Bail if already processing if (isProcessing) return; + isProcessing = true; startBtn.addClass('workout-btn-processing'); startBtn.setAttribute('disabled', 'true'); + try { + // Trigger workout start callback await callbacks.onStartWorkout(); } finally { // Button will be gone after re-render, but reset just in case @@ -34,20 +72,25 @@ export function renderWorkoutControls( }; startBtn.addEventListener('click', handleStart); } else if (state === 'completed') { - // Completed label + // Render "Completed" status label const completedLabel = controlsEl.createSpan({ cls: 'workout-completed-label' }); completedLabel.createSpan({ cls: 'workout-btn-icon', text: '✓' }); completedLabel.createSpan({ text: 'Completed' }); - // Copy as template button + // Render "Copy as Template" button const copyBtn = controlsEl.createEl('button', { cls: 'workout-btn' }); copyBtn.createSpan({ cls: 'workout-btn-icon', text: '📋' }); copyBtn.createSpan({ text: 'Copy as Template' }); + + // Click handler: serialize to template and copy to clipboard copyBtn.addEventListener('click', async () => { + // Serialize completed workout as reusable template const template = serializeWorkoutAsTemplate(parsed); + + // Copy markdown code block to clipboard await navigator.clipboard.writeText('```workout\n' + template + '\n```'); - // Visual feedback + // Show "Copied!" confirmation for 1.5 seconds const textSpan = copyBtn.querySelector('span:last-child'); if (textSpan) { const originalText = textSpan.textContent; diff --git a/src/renderer/emptyState.test.ts b/src/renderer/emptyState.test.ts new file mode 100644 index 0000000..e32cc9b --- /dev/null +++ b/src/renderer/emptyState.test.ts @@ -0,0 +1,555 @@ +import { renderEmptyState } from './emptyState'; + +// MockElement class to simulate DOM without jsdom +class MockElement { + tag: string; + className: string = ''; + children: MockElement[] = []; + parent: MockElement | null = null; + listeners: { [key: string]: Function[] } = {}; + _textContent: string = ''; + document: any; + styleMap: Map = new Map(); + attributes: Map = new Map(); + + constructor(tag: string) { + this.tag = tag; + } + + get textContent(): string { + // Aggregate text from this element and all children + let text = this._textContent; + for (const child of this.children) { + text += child.textContent; + } + return text; + } + + set textContent(value: string) { + this._textContent = value; + } + + createDiv(options?: { cls?: string; text?: string }): MockElement { + return this.createEl('div', options); + } + + createEl(tag: string, options?: { cls?: string; text?: string }): MockElement { + const el = new MockElement(tag); + el.parent = this; + if (options?.cls) el.className = options.cls; + if (options?.text) el.textContent = options.text; + this.children.push(el); + return el; + } + + createSpan(options?: { cls?: string; text?: string }): MockElement { + return this.createEl('span', options); + } + + querySelector(selector: string): MockElement | null { + if (selector.startsWith('.')) { + const className = selector.substring(1); + const found = this._findByClass(className); + return found; + } + const tagName = selector; + return this._findByTag(tagName); + } + + querySelectorAll(selector: string): MockElement[] { + if (selector.startsWith('.')) { + const className = selector.substring(1); + return this._findAllByClass(className); + } + const tagName = selector; + return this._findAllByTag(tagName); + } + + private _findByClass(className: string): MockElement | null { + if (this.className.split(' ').includes(className)) return this; + for (const child of this.children) { + const found = child._findByClass(className); + if (found) return found; + } + return null; + } + + private _findAllByClass(className: string): MockElement[] { + const results: MockElement[] = []; + if (this.className.split(' ').includes(className)) { + results.push(this); + } + for (const child of this.children) { + results.push(...child._findAllByClass(className)); + } + return results; + } + + private _findByTag(tagName: string): MockElement | null { + if (this.tag === tagName) return this; + for (const child of this.children) { + const found = child._findByTag(tagName); + if (found) return found; + } + return null; + } + + private _findAllByTag(tagName: string): MockElement[] { + const results: MockElement[] = []; + if (this.tag === tagName) { + results.push(this); + } + for (const child of this.children) { + results.push(...child._findAllByTag(tagName)); + } + return results; + } + + addEventListener(event: string, handler: Function): void { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(handler); + } + + click(): void { + if (this.listeners['click']) { + for (const handler of this.listeners['click']) { + handler(new Event('click')); + } + } + } + + addClass(cls: string): void { + const classes = this.className.split(' ').filter(c => c); + if (!classes.includes(cls)) { + classes.push(cls); + } + this.className = classes.join(' '); + } + + removeClass(cls: string): void { + const classes = this.className.split(' ').filter(c => c && c !== cls); + this.className = classes.join(' '); + } + + setAttribute(key: string, value: string): void { + this.attributes.set(key, value); + } + + removeAttribute(key: string): void { + this.attributes.delete(key); + } + + hasAttribute(key: string): boolean { + return this.attributes.has(key); + } + + setStyle(prop: string, value: string): void { + this.styleMap.set(prop, value); + } + + getStyle(prop: string): string | undefined { + return this.styleMap.get(prop); + } +} + +describe('renderEmptyState', () => { + let container: MockElement; + let callback: jest.Mock; + + beforeEach(() => { + container = new MockElement('div'); + callback = jest.fn().mockResolvedValue(undefined); + }); + + describe('Container structure', () => { + it('should create empty state container element', () => { + renderEmptyState(container, callback); + expect(container.querySelector('.workout-empty-state')).toBeTruthy(); + }); + + it('should append empty state to container', () => { + renderEmptyState(container, callback); + expect(container.children.length).toBe(1); + }); + + it('should use correct root class', () => { + const result = renderEmptyState(container, callback); + expect(result.className).toBe('workout-empty-state'); + }); + + it('should return the empty state element', () => { + const result = renderEmptyState(container, callback); + expect(result).toBeInstanceOf(MockElement); + expect(result.tag).toBe('div'); + }); + + it('should return element that is in container', () => { + const result = renderEmptyState(container, callback); + expect(container.children).toContain(result); + }); + }); + + describe('Message section', () => { + it('should render message container', () => { + renderEmptyState(container, callback); + const message = container.querySelector('.workout-empty-message'); + expect(message).toBeTruthy(); + }); + + it('should use correct message class', () => { + renderEmptyState(container, callback); + const message = container.querySelector('.workout-empty-message'); + expect(message?.className).toBe('workout-empty-message'); + }); + + it('should render message as div', () => { + renderEmptyState(container, callback); + const message = container.querySelector('.workout-empty-message'); + expect(message?.tag).toBe('div'); + }); + + it('should display empty workout message', () => { + renderEmptyState(container, callback); + const message = container.querySelector('.workout-empty-message'); + expect(message?.textContent).toContain('This workout is empty'); + }); + + it('should have message text in span', () => { + renderEmptyState(container, callback); + const message = container.querySelector('.workout-empty-message'); + const span = message?.querySelector('span'); + expect(span?.textContent).toBe('This workout is empty'); + }); + + it('should have exactly one span in message', () => { + renderEmptyState(container, callback); + const message = container.querySelector('.workout-empty-message'); + const spans = message?.querySelectorAll('span'); + expect(spans?.length).toBe(1); + }); + }); + + describe('Action container', () => { + it('should render action container', () => { + renderEmptyState(container, callback); + const action = container.querySelector('.workout-empty-action'); + expect(action).toBeTruthy(); + }); + + it('should use correct action class', () => { + renderEmptyState(container, callback); + const action = container.querySelector('.workout-empty-action'); + expect(action?.className).toBe('workout-empty-action'); + }); + + it('should render action as div', () => { + renderEmptyState(container, callback); + const action = container.querySelector('.workout-empty-action'); + expect(action?.tag).toBe('div'); + }); + + it('should contain button in action container', () => { + renderEmptyState(container, callback); + const action = container.querySelector('.workout-empty-action'); + const button = action?.querySelector('button'); + expect(button).toBeTruthy(); + }); + }); + + describe('Button structure', () => { + it('should render button element', () => { + renderEmptyState(container, callback); + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + expect(button?.tag).toBe('button'); + }); + + it('should have workout-btn class', () => { + renderEmptyState(container, callback); + const button = container.querySelector('button'); + expect(button?.className).toContain('workout-btn'); + }); + + it('should have workout-btn-primary class', () => { + renderEmptyState(container, callback); + const button = container.querySelector('button'); + expect(button?.className).toContain('workout-btn-primary'); + }); + + it('should have workout-btn-large class', () => { + renderEmptyState(container, callback); + const button = container.querySelector('button'); + expect(button?.className).toContain('workout-btn-large'); + }); + + it('should have all three button classes', () => { + renderEmptyState(container, callback); + const button = container.querySelector('button'); + expect(button?.className).toBe('workout-btn workout-btn-primary workout-btn-large'); + }); + + it('should have exactly two children in button', () => { + renderEmptyState(container, callback); + const button = container.querySelector('button'); + expect(button?.children.length).toBe(2); + }); + }); + + describe('Button icon', () => { + it('should render icon span', () => { + renderEmptyState(container, callback); + const icon = container.querySelector('.workout-btn-icon'); + expect(icon).toBeTruthy(); + }); + + it('should use correct icon class', () => { + renderEmptyState(container, callback); + const icon = container.querySelector('.workout-btn-icon'); + expect(icon?.className).toBe('workout-btn-icon'); + }); + + it('should render icon as span', () => { + renderEmptyState(container, callback); + const icon = container.querySelector('.workout-btn-icon'); + expect(icon?.tag).toBe('span'); + }); + + it('should display sparkle emoji', () => { + renderEmptyState(container, callback); + const icon = container.querySelector('.workout-btn-icon'); + expect(icon?.textContent).toBe('✨'); + }); + + it('should be first child of button', () => { + renderEmptyState(container, callback); + const button = container.querySelector('button'); + const firstChild = button?.children[0]; + expect(firstChild?.className).toContain('workout-btn-icon'); + }); + }); + + describe('Button label', () => { + it('should render label span', () => { + renderEmptyState(container, callback); + const button = container.querySelector('button'); + const label = button?.children[1]; + expect(label?.tag).toBe('span'); + }); + + it('should display correct button text', () => { + renderEmptyState(container, callback); + const button = container.querySelector('button'); + expect(button?.textContent).toContain('Add Sample Workout'); + }); + + it('should have label as second child of button', () => { + renderEmptyState(container, callback); + const button = container.querySelector('button'); + const secondChild = button?.children[1]; + expect(secondChild?.textContent).toBe('Add Sample Workout'); + }); + + it('should not have class on label span', () => { + renderEmptyState(container, callback); + const button = container.querySelector('button'); + const label = button?.children[1]; + expect(label?.className).toBe(''); + }); + }); + + describe('Callback functionality', () => { + it('should call callback when button clicked', async () => { + renderEmptyState(container, callback); + const button = container.querySelector('button') as MockElement; + + button.click(); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should only call callback once per click', async () => { + renderEmptyState(container, callback); + const button = container.querySelector('button') as MockElement; + + button.click(); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should call callback with no arguments', async () => { + renderEmptyState(container, callback); + const button = container.querySelector('button') as MockElement; + + button.click(); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(callback).toHaveBeenCalledWith(); + }); + + it('should await callback completion', async () => { + let callbackExecuted = false; + const slowCallback = jest.fn(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + callbackExecuted = true; + }); + + renderEmptyState(container, slowCallback); + const button = container.querySelector('button') as MockElement; + + button.click(); + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(callbackExecuted).toBe(true); + }); + + it('should allow multiple clicks', async () => { + renderEmptyState(container, callback); + const button = container.querySelector('button') as MockElement; + + button.click(); + await new Promise(resolve => setTimeout(resolve, 5)); + button.click(); + await new Promise(resolve => setTimeout(resolve, 5)); + + expect(callback).toHaveBeenCalledTimes(2); + }); + }); + + describe('DOM hierarchy', () => { + it('should have correct element hierarchy', () => { + const result = renderEmptyState(container, callback); + + expect(result.className).toContain('workout-empty-state'); + expect(result.children.length).toBe(2); + + const message = result.children[0]; + expect(message.className).toContain('workout-empty-message'); + + const action = result.children[1]; + expect(action.className).toContain('workout-empty-action'); + + const button = action.children[0]; + expect(button?.tag).toBe('button'); + }); + + it('should have message before action in DOM order', () => { + const result = renderEmptyState(container, callback); + const message = result.children[0]; + const action = result.children[1]; + + expect(message.className).toContain('workout-empty-message'); + expect(action.className).toContain('workout-empty-action'); + }); + + it('should have icon before label in button', () => { + renderEmptyState(container, callback); + const button = container.querySelector('button'); + const icon = button?.children[0]; + const label = button?.children[1]; + + expect(icon?.className).toContain('workout-btn-icon'); + expect(label?.textContent).toBe('Add Sample Workout'); + }); + }); + + describe('Return value', () => { + it('should return MockElement instance', () => { + const result = renderEmptyState(container, callback); + expect(result).toBeInstanceOf(MockElement); + }); + + it('should return a div element', () => { + const result = renderEmptyState(container, callback); + expect(result.tag).toBe('div'); + }); + + it('should return element with workout-empty-state class', () => { + const result = renderEmptyState(container, callback); + expect(result.className).toContain('workout-empty-state'); + }); + + it('returned element should be in container children', () => { + const result = renderEmptyState(container, callback); + expect(container.children).toContain(result); + }); + + it('should return the actual created element', () => { + const result = renderEmptyState(container, callback); + const queriedElement = container.querySelector('.workout-empty-state'); + expect(result).toBe(queriedElement); + }); + }); + + describe('Multiple renders', () => { + it('should create separate elements for each render', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + renderEmptyState(container, callback1); + renderEmptyState(container, callback2); + + expect(container.children.length).toBe(2); + }); + + it('should each have their own button with independent callbacks', async () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + renderEmptyState(container, callback1); + renderEmptyState(container, callback2); + + const buttons = container.querySelectorAll('button'); + expect(buttons.length).toBe(2); + + (buttons[0] as MockElement).click(); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(callback1).toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + }); + }); + + describe('Edge cases', () => { + it('should work with empty container', () => { + const emptyContainer = new MockElement('div'); + renderEmptyState(emptyContainer, callback); + expect(emptyContainer.children.length).toBe(1); + }); + + it('should work with pre-populated container', () => { + const prePopulated = new MockElement('div'); + prePopulated.createDiv({ text: 'Existing content' }); + + renderEmptyState(prePopulated, callback); + expect(prePopulated.children.length).toBe(2); + }); + + it('should handle callback that returns void', async () => { + const voidCallback = jest.fn(async () => { + // Return undefined implicitly + }); + + renderEmptyState(container, voidCallback); + const button = container.querySelector('button') as MockElement; + + button.click(); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(voidCallback).toHaveBeenCalled(); + }); + + it('should preserve container content when adding empty state', () => { + container.createDiv({ cls: 'existing-element' }); + const originalContent = container.children[0]; + + renderEmptyState(container, callback); + + expect(container.children[0]).toBe(originalContent); + expect(container.children[1].className).toContain('workout-empty-state'); + }); + }); +}); diff --git a/src/renderer/emptyState.ts b/src/renderer/emptyState.ts index fbccc6f..3915b63 100644 --- a/src/renderer/emptyState.ts +++ b/src/renderer/emptyState.ts @@ -1,19 +1,45 @@ +/** + * Empty state rendering for workout blocks. + * + * Displays a placeholder UI when a planned workout has no exercises. + * Provides a prominent button to load a sample workout as a starting point. + * Only shown for planned workouts with no exercises. + */ + export function renderEmptyState( container: HTMLElement, onAddSample: () => Promise ): HTMLElement { + /** + * Render the empty state UI for a workout with no exercises. + * + * Displays: + * - Message: "This workout is empty" + * - Button: "Add Sample Workout" with sparkle emoji (✨) + * + * Parameters: + * - container: Parent DOM element to render into + * - onAddSample: Async callback fired when user clicks "Add Sample Workout" + * + * Returns: Empty state container element + */ const emptyStateEl = container.createDiv({ cls: 'workout-empty-state' }); + // Display empty state message const message = emptyStateEl.createDiv({ cls: 'workout-empty-message' }); message.createSpan({ text: 'This workout is empty' }); + // Create action button container and button const actionContainer = emptyStateEl.createDiv({ cls: 'workout-empty-action' }); const addButton = actionContainer.createEl('button', { cls: 'workout-btn workout-btn-primary workout-btn-large' }); + + // Add button icon (sparkle emoji) and label addButton.createSpan({ cls: 'workout-btn-icon', text: '✨' }); addButton.createSpan({ text: 'Add Sample Workout' }); + // Wire up click handler to load sample workout addButton.addEventListener('click', async () => { await onAddSample(); }); diff --git a/src/renderer/exercise.test.ts b/src/renderer/exercise.test.ts new file mode 100644 index 0000000..215477d --- /dev/null +++ b/src/renderer/exercise.test.ts @@ -0,0 +1,3802 @@ +import { renderExercise, updateExerciseTimer } from './exercise'; +import { Exercise, ExerciseSet, ExerciseParam, TimerState, WorkoutCallbacks } from '../types'; +import * as exerciseParser from '../parser/exercise'; + +// Mock the exercise parser +jest.mock('../parser/exercise', () => ({ + formatDuration: jest.fn((seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }), + formatDurationHuman: jest.fn((seconds: number) => { + if (seconds < 60) return `${seconds}s`; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; + }), + parseDurationToSeconds: jest.fn((duration: string) => { + // Simple parser: "1m 30s" -> 90, "30s" -> 30 + const match = duration.match(/(?:(\d+)m)?\s*(?:(\d+)s)?/); + if (!match) return 0; + const mins = parseInt(match[1] || '0', 10); + const secs = parseInt(match[2] || '0', 10); + return mins * 60 + secs; + }) +})); + +// MockElement class to simulate DOM +class MockElement { + tag: string; + className: string = ''; + children: MockElement[] = []; + parent: MockElement | null = null; + listeners: { [key: string]: Function[] } = {}; + _textContent: string = ''; + styleMap: Map = new Map(); + attributes: Map = new Map(); + + constructor(tag: string) { + this.tag = tag; + } + + get textContent(): string { + let text = this._textContent; + for (const child of this.children) { + text += child.textContent; + } + return text; + } + + set textContent(value: string) { + this._textContent = value; + } + + createDiv(options?: { cls?: string; text?: string }): MockElement { + return this.createEl('div', options); + } + + createEl(tag: string, options?: { cls?: string; text?: string }): MockElement { + const el = new MockElement(tag); + el.parent = this; + if (options?.cls) el.className = options.cls; + if (options?.text) el.textContent = options.text; + this.children.push(el); + return el; + } + + createSpan(options?: { cls?: string; text?: string }): MockElement { + return this.createEl('span', options); + } + + setText(text: string): void { + this._textContent = text; + } + + style = { + setProperty: jest.fn() + }; + + querySelector(selector: string): MockElement | null { + if (selector.startsWith('.')) { + const className = selector.substring(1); + return this._findByClass(className); + } + return this._findByTag(selector); + } + + querySelectorAll(selector: string): MockElement[] { + if (selector.startsWith('.')) { + const className = selector.substring(1); + return this._findAllByClass(className); + } + return this._findAllByTag(selector); + } + + private _findByClass(className: string): MockElement | null { + if (this.className.split(' ').includes(className)) return this; + for (const child of this.children) { + const found = child._findByClass(className); + if (found) return found; + } + return null; + } + + private _findAllByClass(className: string): MockElement[] { + const results: MockElement[] = []; + if (this.className.split(' ').includes(className)) { + results.push(this); + } + for (const child of this.children) { + results.push(...child._findAllByClass(className)); + } + return results; + } + + private _findByTag(tagName: string): MockElement | null { + if (this.tag === tagName) return this; + for (const child of this.children) { + const found = child._findByTag(tagName); + if (found) return found; + } + return null; + } + + private _findAllByTag(tagName: string): MockElement[] { + const results: MockElement[] = []; + if (this.tag === tagName) { + results.push(this); + } + for (const child of this.children) { + results.push(...child._findAllByTag(tagName)); + } + return results; + } + + empty(): void { + this.children = []; + this._textContent = ''; + } + + addEventListener(event: string, handler: Function): void { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(handler); + } + + click(): void { + if (this.listeners['click']) { + for (const handler of this.listeners['click']) { + handler(new Event('click')); + } + } + } + + addClass(className: string): void { + if (!this.className.includes(className)) { + this.className = this.className ? `${this.className} ${className}` : className; + } + } + + removeClass(className: string): void { + this.className = this.className.split(' ').filter(c => c !== className).join(' '); + } + + get classList(): DOMTokenList { + return { + add: (className: string) => this.addClass(className), + contains: (className: string) => this.className.split(' ').includes(className), + remove: (className: string) => { + this.className = this.className.split(' ').filter(c => c !== className).join(' '); + }, + toggle: (className: string) => { + if (this.classList.contains(className)) { + this.classList.remove(className); + } else { + this.addClass(className); + } + }, + toString: () => this.className + } as any; + } +} + +describe('renderExercise & updateExerciseTimer', () => { + let container: MockElement; + let mockCallbacks: WorkoutCallbacks; + const baseMockExercise: Exercise = { + name: 'Push Ups', + state: 'pending', + params: [], + sets: [{ state: 'pending', params: [] }], + lineIndex: 0 + }; + + beforeEach(() => { + container = new MockElement('div'); + mockCallbacks = { + onExerciseStateChange: jest.fn(), + onSetStateChange: jest.fn(), + onParamChange: jest.fn(), + onStartWorkout: jest.fn(), + onPauseWorkout: jest.fn(), + onResumeWorkout: jest.fn(), + onSkipExercise: jest.fn(), + onStartExerciseTimer: jest.fn(), + onExerciseRest: jest.fn(), + onExerciseRestPause: jest.fn(), + onExerciseRestResume: jest.fn(), + onExerciseRestDone: jest.fn(), + onExerciseFinish: jest.fn(), + onFlushChanges: jest.fn(), + onAddSample: jest.fn(), + onSetRecordedDuration: jest.fn(), + onChangeRestDuration: jest.fn(), + onAddRest: jest.fn(), + onAddSet: jest.fn(), + onRestEnd: jest.fn() + }; + jest.clearAllMocks(); + }); + + describe('Container structure', () => { + it('should create exercise element', () => { + renderExercise( + container, + baseMockExercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(container.querySelector('.workout-exercise')).toBeTruthy(); + }); + + it('should append exercise to container', () => { + renderExercise( + container, + baseMockExercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(container.children.length).toBe(1); + }); + + it('should include state class', () => { + renderExercise( + container, + baseMockExercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + const exercise = container.querySelector('.workout-exercise'); + expect(exercise?.className).toContain('state-pending'); + }); + + it('should add active class when isActive is true', () => { + renderExercise( + container, + baseMockExercise, + 0, + true, + 0, + null, + mockCallbacks, + 'started' + ); + const exercise = container.querySelector('.workout-exercise'); + expect(exercise?.className).toContain('active'); + }); + + it('should not add active class when isActive is false', () => { + renderExercise( + container, + baseMockExercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + const exercise = container.querySelector('.workout-exercise'); + expect(exercise?.className).not.toContain('active'); + }); + + it('should set color property from exercise name', () => { + const result = renderExercise( + container, + baseMockExercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + // Check that setProperty was called with --exercise-color + const exercise = container.querySelector('.workout-exercise') as MockElement; + expect((exercise?.style.setProperty as jest.Mock).mock.calls.some( + call => call[0] === '--exercise-color' + )).toBe(true); + }); + }); + + describe('Exercise icon display', () => { + it('should display pending icon', () => { + renderExercise( + container, + { ...baseMockExercise, state: 'pending' }, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + const icon = container.querySelector('.workout-exercise-icon'); + expect(icon?.textContent).toBe('○'); + }); + + it('should display in-progress icon', () => { + renderExercise( + container, + { ...baseMockExercise, state: 'inProgress' }, + 0, + true, + 0, + null, + mockCallbacks, + 'started' + ); + const icon = container.querySelector('.workout-exercise-icon'); + expect(icon?.textContent).toBe('◐'); + }); + + it('should display completed icon', () => { + renderExercise( + container, + { ...baseMockExercise, state: 'completed' }, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + const icon = container.querySelector('.workout-exercise-icon'); + expect(icon?.textContent).toBe('✓'); + }); + + it('should display skipped icon', () => { + renderExercise( + container, + { ...baseMockExercise, state: 'skipped' }, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + const icon = container.querySelector('.workout-exercise-icon'); + expect(icon?.textContent).toBe('—'); + }); + }); + + describe('Exercise name display', () => { + it('should display exercise name', () => { + renderExercise( + container, + baseMockExercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + const name = container.querySelector('.workout-exercise-name'); + expect(name?.textContent).toBe('Push Ups'); + }); + + it('should display long exercise names', () => { + const longName = 'Push Ups with Extended Pause at Bottom'; + renderExercise( + container, + { ...baseMockExercise, name: longName }, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + const name = container.querySelector('.workout-exercise-name'); + expect(name?.textContent).toBe(longName); + }); + + it('should have correct name class', () => { + renderExercise( + container, + baseMockExercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + const name = container.querySelector('.workout-exercise-name'); + expect(name?.className).toContain('workout-exercise-name'); + }); + }); + + describe('Return value', () => { + it('should return ExerciseElements object', () => { + const result = renderExercise( + container, + baseMockExercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result).toBeDefined(); + expect(result.container).toBeDefined(); + expect(result.inputs).toBeDefined(); + expect(result.setInputs).toBeDefined(); + }); + + it('should return container element', () => { + const result = renderExercise( + container, + baseMockExercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.container).toBeInstanceOf(MockElement); + expect(result.container.className).toContain('workout-exercise'); + }); + + it('should have inputs map', () => { + const result = renderExercise( + container, + baseMockExercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.inputs).toBeInstanceOf(Map); + }); + + it('should have setInputs map', () => { + const result = renderExercise( + container, + baseMockExercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.setInputs).toBeInstanceOf(Map); + }); + }); + + describe('Multiple exercises in container', () => { + it('should render multiple exercises', () => { + renderExercise( + container, + { ...baseMockExercise, name: 'Exercise 1' }, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + renderExercise( + container, + { ...baseMockExercise, name: 'Exercise 2' }, + 1, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(container.children.length).toBe(2); + }); + + it('should maintain state for each exercise', () => { + renderExercise( + container, + { ...baseMockExercise, state: 'pending', name: 'Exercise 1' }, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + renderExercise( + container, + { ...baseMockExercise, state: 'completed', name: 'Exercise 2' }, + 1, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + const exercises = container.querySelectorAll('.workout-exercise'); + expect(exercises[0].className).toContain('state-pending'); + expect(exercises[1].className).toContain('state-completed'); + }); + }); + + describe('Helper functions and edge cases', () => { + it('should render exercise with reps and weight parameters', () => { + const exercise: Exercise = { + name: 'Bench Press', + state: 'pending', + params: [ + { key: 'Reps', value: '10', editable: true, unit: '' }, + { key: 'Weight', value: '185', editable: false, unit: 'lbs' } + ], + sets: [], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.inputs.size).toBeGreaterThan(0); + }); + + it('should render exercise with rest parameter', () => { + const exercise: Exercise = { + name: 'Cardio', + state: 'pending', + params: [ + { key: 'Rest', value: '2m', editable: true, unit: '' } + ], + sets: [], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.container).toBeDefined(); + }); + + it('should handle sets with reps and weight parameters', () => { + const exercise: Exercise = { + name: 'Squats', + state: 'pending', + params: [], + sets: [ + { + state: 'pending', + params: [ + { key: 'Reps', value: '12', editable: true, unit: '' }, + { key: 'Weight', value: '225', editable: false, unit: 'lbs' } + ] + }, + { + state: 'pending', + params: [ + { key: 'Reps', value: '10', editable: true, unit: '' }, + { key: 'Weight', value: '235', editable: false, unit: 'lbs' } + ] + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.setInputs.size).toBe(2); + }); + + it('should render set with recorded duration in completed state', () => { + const exercise: Exercise = { + name: 'Exercise', + state: 'completed', + params: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Duration', value: '5m 30s', editable: false, unit: '' } + ] + } + ], + lineIndex: 0, + recordedTime: '5m 30s' + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + expect(result.container).toBeDefined(); + }); + + it('should render set with rest duration', () => { + const exercise: Exercise = { + name: 'Exercise', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [ + { key: 'Rest', value: '60s', editable: true, unit: '' } + ] + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + null, + mockCallbacks, + 'started' + ); + expect(result.container).toBeDefined(); + }); + + it('should render rest phase with timer state', () => { + const exercise: Exercise = { + name: 'Exercise', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [ + { key: 'Rest', value: '90s', editable: true, unit: '' } + ] + } + ], + lineIndex: 0 + }; + const timerState: TimerState = { + workoutElapsed: 0, + exerciseElapsed: 0, + isOvertime: false, + isRestActive: true, + restElapsed: 30, // Assuming 30s elapsed in rest + restRemaining: 60 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + timerState, + mockCallbacks, + 'started' + ); + expect(result.setTimerEl).toBeDefined(); + }); + + it('should render rest phase in yellow zone', () => { + const exercise: Exercise = { + name: 'Exercise', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [ + { key: 'Rest', value: '90s', editable: true, unit: '' } + ] + } + ], + lineIndex: 0 + }; + const timerState: TimerState = { + workoutElapsed: 0, + exerciseElapsed: 0, + isOvertime: false, + isRestActive: true, + restElapsed: 45, // Assuming 45s elapsed in rest + restRemaining: 45 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + timerState, + mockCallbacks, + 'started' + ); + expect(result.setTimerEl).toBeDefined(); + }); + + it('should render rest phase in red zone', () => { + const exercise: Exercise = { + name: 'Exercise', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [ + { key: 'Rest', value: '90s', editable: true, unit: '' } + ] + } + ], + lineIndex: 0 + }; + const timerState: TimerState = { + workoutElapsed: 0, + exerciseElapsed: 0, + isOvertime: false, + isRestActive: true, + restElapsed: 70, // Assuming 70s elapsed in rest + restRemaining: 20 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + timerState, + mockCallbacks, + 'started' + ); + expect(result.setTimerEl).toBeDefined(); + }); + + it('should handle rest overtime', () => { + const exercise: Exercise = { + name: 'Exercise', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [ + { key: 'Rest', value: '60s', editable: true, unit: '' } + ] + } + ], + lineIndex: 0 + }; + const timerState: TimerState = { + workoutElapsed: 0, + exerciseElapsed: 0, + isOvertime: true, + isRestActive: true, + restElapsed: 75, // Assuming 75s elapsed in rest + restRemaining: -15 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + timerState, + mockCallbacks, + 'started' + ); + expect(result.setTimerEl).toBeDefined(); + }); + + it('should render exercise with multiple parameter types', () => { + const exercise: Exercise = { + name: 'Complex Exercise', + state: 'pending', + params: [ + { key: 'Distance', value: '10', editable: true, unit: 'km' }, + { key: 'Time', value: '60', editable: false, unit: 'min' }, + { key: 'Intensity', value: 'High', editable: true, unit: '' } + ], + sets: [], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.inputs.size).toBeGreaterThan(0); + }); + + it('should render completed exercise with all totals', () => { + const exercise: Exercise = { + name: 'Completed Workout', + state: 'completed', + params: [ + { key: 'Reps', value: '30', editable: false, unit: '' } + ], + sets: [ + { state: 'completed', params: [], recordedTime: '3m' }, + { state: 'completed', params: [], recordedTime: '2m 45s' } + ], + lineIndex: 0, + recordedTime: '5m 45s' + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + expect(result.container).toBeDefined(); + }); + + it('should handle set with both Duration and Rest parameters', () => { + const exercise: Exercise = { + name: 'Complex Set', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [ + { key: 'Duration', value: '45s', editable: false, unit: '' }, + { key: 'Rest', value: '60s', editable: true, unit: '' }, + { key: 'Reps', value: '15', editable: true, unit: '' } + ] + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + null, + mockCallbacks, + 'started' + ); + expect(result.setInputs.size).toBeGreaterThan(0); + }); + + it('should render exercise with no timer (pending state)', () => { + const exercise: Exercise = { + name: 'No Timer Exercise', + state: 'pending', + params: [], + sets: [], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.timerEl).toBeDefined(); + }); + + it('should render set with skipped state and rest', () => { + const exercise: Exercise = { + name: 'Skipped With Rest', + state: 'pending', + params: [], + sets: [ + { + state: 'skipped', + params: [ + { key: 'Rest', value: '60s', editable: true, unit: '' } + ] + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.container).toBeDefined(); + }); + + it('should render in-progress exercise with multiple sets and timers', () => { + const exercise: Exercise = { + name: 'Multi-set Exercise', + state: 'in-progress', + params: [], + sets: [ + { state: 'completed', params: [] }, + { state: 'in-progress', params: [], targetRest: 45 }, + { state: 'pending', params: [] } + ], + lineIndex: 0 + }; + const timerState: TimerState = { + workoutElapsed: 0, + exerciseElapsed: 30, + isOvertime: false, + isRestActive: false, + restRemaining: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 1, + timerState, + mockCallbacks, + 'started' + ); + expect(result.setTimerEl).toBeDefined(); + }); + + it('should render exercise with completed recorded time', () => { + const exercise: Exercise = { + name: 'Recorded Exercise', + state: 'completed', + params: [], + sets: [], + lineIndex: 0, + recordedTime: '10m 25s' + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + expect(result.timerEl).toBeDefined(); + }); + + it('should render exercise with target duration indicator', () => { + const exercise: Exercise = { + name: 'Duration Exercise', + state: 'pending', + params: [], + sets: [], + targetDuration: 120, + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.timerEl).toBeDefined(); + }); + + it('should render exercise state indicator correctly', () => { + const states: Array<'pending' | 'in-progress' | 'completed' | 'skipped'> = [ + 'pending', + 'in-progress', + 'completed', + 'skipped' + ]; + + for (const state of states) { + container = new MockElement('div'); + const exercise: Exercise = { + name: `Exercise ${state}`, + state, + params: [], + sets: [], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + state === 'in-progress', + -1, + null, + mockCallbacks, + state === 'completed' ? 'completed' : state === 'in-progress' ? 'started' : 'planned' + ); + expect(result.container.className).toContain(`state-${state}`); + } + }); + + it('should handle exercise name to hue color generation', () => { + const names = ['Squats', 'Bench Press', 'Deadlifts', 'Pull-ups', 'Rows']; + + for (const name of names) { + container = new MockElement('div'); + const exercise: Exercise = { + name, + state: 'pending', + params: [], + sets: [], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.container).toBeDefined(); + } + }); + + it('should render set in-progress state with timer', () => { + const exercise: Exercise = { + name: 'Set Timer Test', + state: 'in-progress', + params: [], + sets: [ + { state: 'in-progress', params: [] } + ], + lineIndex: 0 + }; + const timerState: TimerState = { + workoutElapsed: 0, + exerciseElapsed: 30, + isOvertime: false, + isRestActive: false, + restRemaining: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + timerState, + mockCallbacks, + 'started' + ); + expect(result.setTimerEl).toBeDefined(); + }); + + it('should render completed set with recorded duration', () => { + const exercise: Exercise = { + name: 'Recorded Set', + state: 'completed', + params: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Duration', value: '8m 15s', editable: false, unit: '' } + ] + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + expect(result.container).toBeDefined(); + }); + + it('should handle editable vs non-editable parameter display', () => { + const exercise: Exercise = { + name: 'Params Test', + state: 'started', + params: [ + { key: 'Distance', value: '5', editable: true, unit: 'km' }, + { key: 'Target', value: '10', editable: false, unit: 'km' } + ], + sets: [], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'started' + ); + expect(result.inputs.has('Distance')).toBe(true); + }); + }); + + describe('Exercise with sets and parameters', () => { + it('should render sets with editable parameters', () => { + const exercise: Exercise = { + name: 'Squats', + state: 'pending', + params: [], + sets: [ + { + state: 'pending', + params: [ + { key: 'Reps', value: '10', editable: true, unit: '' }, + { key: 'Weight', value: '185', editable: true, unit: 'lbs' } + ] + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.container).toBeDefined(); + expect(result.setInputs.size).toBeGreaterThan(0); + }); + + it('should render sets with recorded duration', () => { + const exercise: Exercise = { + name: 'Run', + state: 'completed', + params: [], + sets: [ + { + state: 'completed', + params: [], + recordedTime: '15m 30s' + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + expect(result.container).toBeDefined(); + }); + + it('should render sets with rest duration', () => { + const exercise: Exercise = { + name: 'Intervals', + state: 'pending', + params: [], + sets: [ + { + state: 'pending', + params: [], + targetRest: 120 + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.container).toBeDefined(); + }); + + it('should render in-progress set with timer', () => { + const exercise: Exercise = { + name: 'Treadmill', + state: 'in-progress', + params: [{ key: 'Time', value: '30', editable: false, unit: 'm' }], + sets: [ + { + state: 'in-progress', + params: [] + } + ], + targetDuration: 1800, + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + null, + mockCallbacks, + 'started' + ); + expect(result.setTimerEl).toBeDefined(); + }); + + it('should render set with rest phase', () => { + const exercise: Exercise = { + name: 'Complex Strength', + state: 'started', + params: [], + sets: [ + { state: 'completed', params: [] }, + { state: 'in-progress', params: [], targetRest: 60 } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 1, + { workoutElapsed: 0, exerciseElapsed: 0, isOvertime: false, isRestActive: true, restElapsed: 10, restRemaining: 50 }, + mockCallbacks, + 'started' + ); + expect(result.container).toBeDefined(); + }); + + it('should render multiple sets with different states', () => { + const exercise: Exercise = { + name: 'Full Body', + state: 'in-progress', + params: [], + sets: [ + { state: 'completed', params: [] }, + { state: 'in-progress', params: [] }, + { state: 'pending', params: [] }, + { state: 'skipped', params: [] } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 1, + null, + mockCallbacks, + 'started' + ); + expect(result.container).toBeDefined(); + }); + + it('should render exercise with calculated totals', () => { + const exercise: Exercise = { + name: 'Weighted Exercise', + state: 'pending', + params: [ + { key: 'Reps', value: '8', editable: false, unit: '' }, + { key: 'Weight', value: '185', editable: false, unit: 'lbs' } + ], + sets: [ + { state: 'pending', params: [] }, + { state: 'pending', params: [] }, + { state: 'pending', params: [] } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.container).toBeDefined(); + }); + + it('should handle set parameters with various editable states', () => { + const exercise: Exercise = { + name: 'Mixed Config Exercise', + state: 'pending', + params: [], + sets: [ + { + state: 'pending', + params: [ + { key: 'Reps', value: '10', editable: true, unit: '' }, + { key: 'Weight', value: '100', editable: false, unit: 'lbs' }, + { key: 'RPE', value: '8', editable: true, unit: '' } + ] + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.container).toBeDefined(); + expect(result.setInputs.size).toBeGreaterThan(0); + }); + + it('should render completed exercise with recorded and rest duration', () => { + const exercise: Exercise = { + name: 'Completed Strength', + state: 'completed', + params: [ + { key: 'Reps', value: '5', editable: false, unit: '' }, + { key: 'Weight', value: '225', editable: false, unit: 'lbs' } + ], + sets: [ + { + state: 'completed', + params: [], + recordedTime: '2m 10s', + recordedRest: '120s' + } + ], + recordedTime: '2m 30s', + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + expect(result.container).toBeDefined(); + }); + }); + + describe('Edge cases', () => { + it('should handle exercises with no sets', () => { + const exerciseNoSets: Exercise = { + ...baseMockExercise, + sets: [] + }; + const result = renderExercise( + container, + exerciseNoSets, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result).toBeDefined(); + }); + + it('should handle exercises with no params', () => { + const exerciseNoParams: Exercise = { + ...baseMockExercise, + params: [], + sets: [{ state: 'pending', params: [] }] + }; + const result = renderExercise( + container, + exerciseNoParams, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result).toBeDefined(); + }); + + it('should handle very high exercise index', () => { + const result = renderExercise( + container, + baseMockExercise, + 9999, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result).toBeDefined(); + }); + + it('should handle exercises with multiple sets', () => { + const exerciseMultipleSets: Exercise = { + name: 'Squats', + state: 'pending', + params: [], + sets: [ + { state: 'pending', params: [] }, + { state: 'pending', params: [] }, + { state: 'pending', params: [] } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exerciseMultipleSets, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result).toBeDefined(); + }); + + it('should handle exercises with reps and weight params', () => { + const exerciseWithReps: Exercise = { + name: 'Bench Press', + state: 'pending', + params: [ + { key: 'Reps', value: '8', editable: false, unit: '' }, + { key: 'Weight', value: '185', editable: false, unit: 'lbs' } + ], + sets: [{ state: 'pending', params: [] }], + lineIndex: 0 + }; + const result = renderExercise( + container, + exerciseWithReps, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result).toBeDefined(); + }); + + it('should handle editable exercise params', () => { + const exerciseEditable: Exercise = { + name: 'Dumbbell Rows', + state: 'pending', + params: [ + { key: 'Reps', value: '10', editable: true, unit: '' }, + { key: 'Weight', value: '65', editable: true, unit: 'lbs' } + ], + sets: [{ state: 'pending', params: [] }], + lineIndex: 0 + }; + const result = renderExercise( + container, + exerciseEditable, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.inputs.size).toBeGreaterThan(0); + }); + + it('should handle exercises with target duration', () => { + const exerciseWithDuration: Exercise = { + ...baseMockExercise, + targetDuration: 300, + sets: [] + }; + const result = renderExercise( + container, + exerciseWithDuration, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result).toBeDefined(); + }); + + it('should handle completed exercises with recorded duration', () => { + const completedExercise: Exercise = { + ...baseMockExercise, + state: 'completed', + recordedTime: '5m 30s', + sets: [] + }; + const result = renderExercise( + container, + completedExercise, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + expect(result).toBeDefined(); + }); + + it('should render set with all parameter types', () => { + const exercise: Exercise = { + name: 'Advanced Exercise', + state: 'pending', + params: [], + sets: [ + { + state: 'pending', + params: [ + { key: 'Reps', value: '10', editable: true, unit: '' }, + { key: 'Weight', value: '50', editable: false, unit: 'kg' }, + { key: 'Distance', value: '5', editable: true, unit: 'km' }, + { key: 'Duration', value: '45', editable: false, unit: 's' } + ] + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.setInputs.size).toBeGreaterThan(0); + }); + + it('should render exercise with skipped state', () => { + const skippedExercise: Exercise = { + name: 'Skipped Exercise', + state: 'pending', + params: [], + sets: [{ state: 'skipped', params: [] }], + lineIndex: 0 + }; + const result = renderExercise( + container, + skippedExercise, + 1, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.container).toBeDefined(); + }); + + it('should render exercise with mixed set states', () => { + const mixedExercise: Exercise = { + name: 'Mixed Sets Exercise', + state: 'in-progress', + params: [], + sets: [ + { state: 'completed', params: [] }, + { state: 'completed', params: [], recordedTime: '2m' }, + { state: 'in-progress', params: [] }, + { state: 'pending', params: [] }, + { state: 'skipped', params: [] } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + mixedExercise, + 0, + true, + 2, + null, + mockCallbacks, + 'started' + ); + expect(result.container).toBeDefined(); + }); + + it('should handle exercise with rest timing', () => { + const exerciseWithRest: Exercise = { + name: 'Rest Exercise', + state: 'started', + params: [], + sets: [ + { state: 'in-progress', params: [], targetRest: 90 }, + { state: 'pending', params: [], targetRest: 90 } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exerciseWithRest, + 0, + true, + 0, + { workoutElapsed: 0, exerciseElapsed: 15, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, + mockCallbacks, + 'started' + ); + expect(result.container).toBeDefined(); + }); + + it('should render exercise with no target duration', () => { + const exercise: Exercise = { + name: 'Count-up Exercise', + state: 'started', + params: [], + sets: [{ state: 'in-progress', params: [] }], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + null, + mockCallbacks, + 'started' + ); + expect(result.container).toBeDefined(); + }); + + it('should render exercise at different indices', () => { + const exercise: Exercise = { + name: 'Indexed Exercise', + state: 'pending', + params: [], + sets: [{ state: 'pending', params: [] }], + lineIndex: 0 + }; + + // Test various indices + for (let i = 0; i < 5; i++) { + container = new MockElement('div'); + const result = renderExercise( + container, + exercise, + i, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result).toBeDefined(); + } + }); + + it('should handle exercise with single set', () => { + const singleSetExercise: Exercise = { + name: 'Single Set', + state: 'pending', + params: [{ key: 'Time', value: '30', editable: false, unit: 'm' }], + sets: [{ state: 'pending', params: [] }], + lineIndex: 0 + }; + const result = renderExercise( + container, + singleSetExercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.container).toBeDefined(); + }); + + it('should handle exercise with many sets', () => { + const manySets: Exercise = { + name: 'Many Sets Exercise', + state: 'pending', + params: [], + sets: Array(10).fill(null).map(() => ({ state: 'pending', params: [] })), + lineIndex: 0 + }; + const result = renderExercise( + container, + manySets, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + expect(result.container).toBeDefined(); + }); + }); +}); + +describe('updateExerciseTimer', () => { + let timerEl: MockElement; + + beforeEach(() => { + timerEl = new MockElement('span'); + jest.clearAllMocks(); + }); + + describe('Timer update with count-up', () => { + it('should display formatted elapsed time', () => { + const timerState: TimerState = { + workoutElapsed: 120, + exerciseElapsed: 45, // Assuming this is the relevant elapsed time for count-up + isOvertime: false, + isRestActive: false, + restElapsed: 0, + exerciseRemaining: undefined, + restRemaining: 0 + }; + + updateExerciseTimer(timerEl, timerState); + expect(exerciseParser.formatDuration).toHaveBeenCalledWith(45); + }); + + it('should clear previous content', () => { + timerEl.textContent = 'Previous Content'; + const timerState: TimerState = { + workoutElapsed: 120, + exerciseElapsed: 45, // Assuming this is the relevant elapsed time for count-up + isOvertime: false, + isRestActive: false, + restElapsed: 0, + exerciseRemaining: undefined, + restRemaining: 0 + }; + + updateExerciseTimer(timerEl, timerState); + expect(timerEl.textContent).not.toContain('Previous'); + }); + + it('should handle zero elapsed time', () => { + const timerState: TimerState = { + workoutElapsed: 0, + exerciseElapsed: 0, // Assuming this is the relevant elapsed time for count-up + isOvertime: false, + isRestActive: false, + restElapsed: 0, + exerciseRemaining: undefined, + restRemaining: 0 + }; + + updateExerciseTimer(timerEl, timerState); + expect(timerEl.textContent).toBeTruthy(); + }); + }); + + describe('Timer update with countdown', () => { + it('should display countdown timer', () => { + const timerState: TimerState = { + workoutElapsed: 120, + exerciseElapsed: 45, // Assuming this is the relevant elapsed time for countdown + isOvertime: false, + isRestActive: false, + restElapsed: 0, + exerciseRemaining: undefined, + restRemaining: 0 + }; + const targetDuration = 120; + + updateExerciseTimer(timerEl, timerState, targetDuration); + // Should calculate remaining time + expect(timerEl.textContent).toBeTruthy(); + }); + + it('should handle target duration provided', () => { + const timerState: TimerState = { + workoutElapsed: 120, + exerciseElapsed: 45, // Assuming this is the relevant elapsed time for countdown + isOvertime: false, + isRestActive: false, + restElapsed: 0, + exerciseRemaining: undefined, + restRemaining: 0 + }; + const targetDuration = 60; + + updateExerciseTimer(timerEl, timerState, targetDuration); + // Should show time remaining from target + expect(timerEl.textContent).toBeTruthy(); + }); + + it('should display overtime when elapsed exceeds target', () => { + const timerState: TimerState = { + workoutElapsed: 150, + exerciseElapsed: 125, // Assuming this is the relevant elapsed time for countdown + isOvertime: true, // Should be true if overtime + isRestActive: false, + restElapsed: 0, + exerciseRemaining: undefined, + restRemaining: 0 + }; + const targetDuration = 60; + + updateExerciseTimer(timerEl, timerState, targetDuration); + expect(timerEl.className).toContain('overtime'); + }); + + it('should add overtime class on overtime', () => { + const timerState: TimerState = { + workoutElapsed: 200, + exerciseElapsed: 200, // Assuming this is the relevant elapsed time for countdown + isOvertime: true, // Should be true if overtime + isRestActive: false, + restElapsed: 0, + exerciseRemaining: undefined, + restRemaining: 0 + }; + const targetDuration = 60; + + updateExerciseTimer(timerEl, timerState, targetDuration); + expect(timerEl.classList.contains('overtime')).toBe(true); + }); + + it('should handle exact target duration match', () => { + const timerState: TimerState = { + workoutElapsed: 100, + exerciseElapsed: 60, // Assuming this is the relevant elapsed time for countdown + isOvertime: false, + isRestActive: false, + restElapsed: 0, + exerciseRemaining: undefined, + restRemaining: 0 + }; + const targetDuration = 60; + + updateExerciseTimer(timerEl, timerState, targetDuration); + expect(timerEl.textContent).toBeTruthy(); + }); + }); + + describe('Coverage improvements - Controls and buttons', () => { + let container: MockElement; + let mockCallbacks: WorkoutCallbacks; + + beforeEach(() => { + container = new MockElement('div'); + mockCallbacks = { + onExerciseStateChange: jest.fn(), + onSetStateChange: jest.fn(), + onParamChange: jest.fn(), + onSetParamChange: jest.fn(), + onStartWorkout: jest.fn(), + onFinishWorkout: jest.fn(), + onExerciseFinish: jest.fn(), + onSetFinish: jest.fn(), + onFlushChanges: jest.fn(), + onAddSample: jest.fn(), + onExerciseAddSet: jest.fn(), + onExerciseAddRest: jest.fn(), + onExerciseSkip: jest.fn(), + onPauseExercise: jest.fn(), + onResumeExercise: jest.fn(), + onRestEnd: jest.fn() + }; + }); + + it('should show Add Rest button when restDuration is defined', () => { + const exercise: Exercise = { + name: 'Exercise with Rest', + state: 'in-progress', + params: [], + sets: [{ state: 'in-progress', params: [] }], + lineIndex: 0 + }; + const restDuration = 60; // restDuration metadata + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + { workoutElapsed: 0, exerciseElapsed: 10, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, // timerState + mockCallbacks, // callbacks + 'started', // workoutState + restDuration, // restDuration + 1 // totalExercises + ); + expect(result.container).toBeDefined(); + }); + + it('should display next button as "Next Set" when not on last set', () => { + const exercise: Exercise = { + name: 'Multi-Set Exercise', + state: 'in-progress', + params: [], + sets: [ + { state: 'completed', params: [] }, + { state: 'in-progress', params: [] }, + { state: 'pending', params: [] } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 1, + { workoutElapsed: 0, exerciseElapsed: 30, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, + mockCallbacks, + 'started', + undefined, + 2 // totalExercises (not last) + ); + expect(mockCallbacks.onSetFinish).toBeDefined(); + }); + + it('should display next button as "Next" when on last set but not last exercise', () => { + const exercise: Exercise = { + name: 'Exercise', + state: 'in-progress', + params: [], + sets: [ + { state: 'completed', params: [] }, + { state: 'in-progress', params: [] } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 1, + { workoutElapsed: 0, exerciseElapsed: 30, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, + mockCallbacks, + 'started', + undefined, + 2 // totalExercises (there's another after this) + ); + expect(mockCallbacks.onSetFinish).toBeDefined(); + }); + + it('should display next button as "Done" when on last set of last exercise', () => { + const exercise: Exercise = { + name: 'Final Exercise', + state: 'in-progress', + params: [], + sets: [ + { state: 'completed', params: [] }, + { state: 'in-progress', params: [] } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 2, // Last exercise index + true, + 1, + { workoutElapsed: 0, exerciseElapsed: 30, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, + mockCallbacks, + 'started', + undefined, + 3 // totalExercises (3 total, on exercise 2 which is last) + ); + expect(mockCallbacks.onSetFinish).toBeDefined(); + }); + + it('should display next button as "Start Next" when in rest phase', () => { + const exercise: Exercise = { + name: 'Exercise', + state: 'in-progress', + params: [], + sets: [{ state: 'in-progress', params: [] }], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + { workoutElapsed: 0, exerciseElapsed: 0, isOvertime: false, isRestActive: true, restElapsed: 30, restRemaining: 20 }, + mockCallbacks, + 'started', + undefined, + 1 // totalExercises + ); + expect(mockCallbacks.onRestEnd).toBeDefined(); + }); + + it('should toggle pause button state on click', () => { + const exercise: Exercise = { + name: 'Exercise', + state: 'in-progress', + params: [], + sets: [{ state: 'in-progress', params: [] }], + lineIndex: 0 + }; + renderExercise( + container, + exercise, + 0, + true, + 0, + { workoutElapsed: 0, exerciseElapsed: 0, isOvertime: false, isRestActive: true, restElapsed: 60, restRemaining: 0 }, // timerState + mockCallbacks, // callbacks + 'started', // workoutState + undefined, // restDuration + undefined // totalExercises + ); + // Pause button should be present and functional + expect(mockCallbacks.onPauseExercise).toBeDefined(); + }); + + it('should call onExerciseSkip on skip button click', () => { + const exercise: Exercise = { + name: 'Exercise', + state: 'in-progress', + params: [], + sets: [{ state: 'in-progress', params: [] }], + lineIndex: 0 + }; + renderExercise( + container, + exercise, + 0, + true, + 0, + { workoutElapsed: 0, exerciseElapsed: 30, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, // timerState + mockCallbacks, // callbacks + 'started', // workoutState + undefined, // restDuration + undefined // totalExercises + ); + expect(mockCallbacks.onExerciseSkip).toBeDefined(); + }); + + it('should call onExerciseAddSet when "Add Set" clicked', () => { + const exercise: Exercise = { + name: 'Exercise', + state: 'in-progress', + params: [], + sets: [{ state: 'in-progress', params: [] }], + lineIndex: 0 + }; + renderExercise( + container, + exercise, + 0, + true, + 0, + { workoutElapsed: 0, exerciseElapsed: 30, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, // timerState + mockCallbacks, // callbacks + 'started', // workoutState + undefined, // restDuration + undefined // totalExercises + ); + expect(mockCallbacks.onExerciseAddSet).toBeDefined(); + }); + }); + + describe('Coverage improvements - Parameters with units', () => { + let container: MockElement; + let mockCallbacks: WorkoutCallbacks; + + beforeEach(() => { + container = new MockElement('div'); + mockCallbacks = { + onExerciseStateChange: jest.fn(), + onSetStateChange: jest.fn(), + onParamChange: jest.fn(), + onSetParamChange: jest.fn(), + onStartWorkout: jest.fn(), + onFinishWorkout: jest.fn(), + onExerciseFinish: jest.fn(), + onSetFinish: jest.fn(), + onFlushChanges: jest.fn(), + onAddSample: jest.fn(), + onExerciseAddSet: jest.fn(), + onExerciseAddRest: jest.fn(), + onExerciseSkip: jest.fn(), + onPauseExercise: jest.fn(), + onResumeExercise: jest.fn(), + onRestEnd: jest.fn() + }; + }); + + it('should display exercise parameters with units when not completed', () => { + const exercise: Exercise = { + name: 'Weighted Exercise', + state: 'in-progress', + params: [ + { key: 'Weight', value: '185', editable: false, unit: 'lbs' }, + { key: 'Reps', value: '10', editable: true, unit: '' } + ], + sets: [{ state: 'in-progress', params: [] }], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + { workoutElapsed: 0, exerciseElapsed: 0, isOvertime: false, isRestActive: true, restElapsed: 250, restRemaining: 50 }, // timerState + mockCallbacks, // callbacks + 'started', // workoutState + undefined, // restDuration + undefined // totalExercises + ); + expect(result.inputs.size).toBeGreaterThan(0); + }); + + it('should display set parameters with units', () => { + const exercise: Exercise = { + name: 'Set Params Exercise', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [ + { key: 'Reps', value: '12', editable: true, unit: '' }, + { key: 'Weight', value: '225', editable: false, unit: 'lbs' } + ] + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + { workoutElapsed: 0, exerciseElapsed: 0, isOvertime: false, isRestActive: true, restElapsed: 150, restRemaining: 150 }, // timerState + mockCallbacks, // callbacks + 'started', // workoutState + undefined, // restDuration + undefined // totalExercises + ); + expect(result.setInputs.size).toBe(1); + }); + + it('should display completed exercise with recorded duration', () => { + const exercise: Exercise = { + name: 'Completed Exercise', + state: 'completed', + params: [ + { key: 'Weight', value: '185', editable: false, unit: 'lbs' } + ], + sets: [ + { + state: 'completed', + params: [], + recordedTime: '5m 30s' + } + ], + recordedTime: '5m 30s', + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + expect(result.container).toBeDefined(); + }); + + it('should display total rest time when exercise is completed', () => { + const exercise: Exercise = { + name: 'Completed with Rest', + state: 'completed', + params: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Rest', value: '60s', editable: false, unit: '' } + ], + recordedTime: '3m' + }, + { + state: 'completed', + params: [ + { key: 'Rest', value: '60s', editable: false, unit: '' } + ], + recordedTime: '3m' + } + ], + recordedTime: '6m 2m', + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + expect(result.container).toBeDefined(); + }); + }); + + describe('Coverage improvements - Rest phase color coding', () => { + let container: MockElement; + let mockCallbacks: WorkoutCallbacks; + + beforeEach(() => { + container = new MockElement('div'); + mockCallbacks = { + onExerciseStateChange: jest.fn(), + onSetStateChange: jest.fn(), + onParamChange: jest.fn(), + onSetParamChange: jest.fn(), + onStartWorkout: jest.fn(), + onFinishWorkout: jest.fn(), + onExerciseFinish: jest.fn(), + onSetFinish: jest.fn(), + onFlushChanges: jest.fn(), + onAddSample: jest.fn(), + onExerciseAddSet: jest.fn(), + onExerciseAddRest: jest.fn(), + onExerciseSkip: jest.fn(), + onPauseExercise: jest.fn(), + onResumeExercise: jest.fn(), + onRestEnd: jest.fn() + }; + }); + + it('should show rest phase in green zone (>66% remaining)', () => { + const exercise: Exercise = { + name: 'Exercise', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [ + { key: 'Rest', value: '300s', editable: false, unit: '' } + ] + } + ], + lineIndex: 0 + }; + const timerState: TimerState = { + workoutElapsed: 30, + exerciseElapsed: 30, + isOvertime: false, + isRestActive: true, + restElapsed: 50, + restRemaining: 250 // 250/300 = 83% (green) + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + timerState, + mockCallbacks, + 'started' + ); + expect(result.setTimerEl).toBeDefined(); + }); + + it('should show rest phase in yellow zone (33-66% remaining)', () => { + const exercise: Exercise = { + name: 'Exercise', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [ + { key: 'Rest', value: '300s', editable: false, unit: '' } + ] + } + ], + lineIndex: 0 + }; + const timerState: TimerState = { + workoutElapsed: 100, + exerciseElapsed: 100, + isOvertime: false, + isRestActive: true, + restElapsed: 150, + restRemaining: 150 // 150/300 = 50% (yellow) + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + timerState, + mockCallbacks, + 'started' + ); + expect(result.setTimerEl).toBeDefined(); + }); + + it('should show rest phase in red zone (<33% remaining)', () => { + const exercise: Exercise = { + name: 'Exercise', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [ + { key: 'Rest', value: '300s', editable: false, unit: '' } + ] + } + ], + lineIndex: 0 + }; + const timerState: TimerState = { + workoutElapsed: 250, + exerciseElapsed: 250, + isOvertime: false, + isRestActive: true, + restElapsed: 250, + restRemaining: 50 // 50/300 = 17% (red) + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + timerState, + mockCallbacks, + 'started' + ); + expect(result.setTimerEl).toBeDefined(); + }); + + it('should handle rest overtime (negative remaining)', () => { + const exercise: Exercise = { + name: 'Exercise', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [ + { key: 'Rest', value: '60s', editable: false, unit: '' } + ] + } + ], + lineIndex: 0 + }; + const timerState: TimerState = { + workoutElapsed: 75, + exerciseElapsed: 75, + isOvertime: false, + isRestActive: true, + restElapsed: 75, + restRemaining: -15 // Overtime by 15 seconds + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + timerState, + mockCallbacks, + 'started' + ); + expect(result.setTimerEl).toBeDefined(); + }); + + it('should handle rest complete (zero remaining)', () => { + const exercise: Exercise = { + name: 'Exercise', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [ + { key: 'Rest', value: '60s', editable: false, unit: '' } + ] + } + ], + lineIndex: 0 + }; + const timerState: TimerState = { + workoutElapsed: 60, + exerciseElapsed: 60, + isOvertime: false, + isRestActive: true, + restElapsed: 60, + restRemaining: 0 // Exactly at end + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + timerState, + mockCallbacks, + 'started' + ); + expect(result.setTimerEl).toBeDefined(); + }); + + it('should handle rest with zero duration in timer state', () => { + const exercise: Exercise = { + name: 'Exercise', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [] + } + ], + lineIndex: 0 + }; + const timerState: TimerState = { + workoutElapsed: 30, + exerciseElapsed: 30, + isOvertime: false, + isRestActive: true, + restElapsed: 30, + restRemaining: 30 // No rest duration param + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + timerState, + mockCallbacks, + 'started' + ); + expect(result.setTimerEl).toBeDefined(); + }); + + it('should display total recorded time for completed exercise', () => { + const exercise: Exercise = { + name: 'Completed Exercise', + state: 'completed', + params: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Duration', value: '5m 30s', editable: false, unit: '' } + ], + lineIndex: 0 + } + ], + lineIndex: 0, + recordedTime: '5m 30s' + }; + renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + // Check that exercise was rendered + expect(container.children.length).toBeGreaterThan(0); + }); + + it('should display total rest time for completed exercise with rest params', () => { + const exercise: Exercise = { + name: ' Completed with Rest', + state: 'completed', + params: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Rest', value: '60s', editable: true, unit: '' }, + { key: 'Duration', value: '3m', editable: false, unit: '' } + ], + lineIndex: 0 + }, + { + state: 'completed', + params: [ + { key: 'Rest', value: '60s', editable: true, unit: '' }, + { key: 'Duration', value: '3m', editable: false, unit: '' } + ], + lineIndex: 1 + } + ], + lineIndex: 0, + recordedTime: '6m 2m' + }; + renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + expect(container.children.length).toBeGreaterThan(0); + }); + + it('should display exercise params with units in non-completed state', () => { + const exercise: Exercise = { + name: 'Params Exercise', + state: 'started', + params: [ + { key: 'Distance', value: '5', editable: false, unit: 'km' }, + { key: 'Intensity', value: 'High', editable: true, unit: '' } + ], + sets: [], + lineIndex: 0 + }; + renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'started' + ); + expect(container.children.length).toBeGreaterThan(0); + }); + + it('should display set params with units in non-completed state', () => { + const exercise: Exercise = { + name: 'Set Params Exercise', + state: 'started', + params: [], + sets: [ + { + state: 'started', + params: [ + { key: 'Reps', value: '12', editable: true, unit: '' }, + { key: 'Weight', value: '225', editable: false, unit: 'lbs' } + ], + lineIndex: 0 + } + ], + lineIndex: 0 + }; + renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'started' + ); + expect(container.children.length).toBeGreaterThan(0); + }); + + it('should render exercise params with editable inputs when not completed', () => { + const exercise: Exercise = { + name: 'Editable Params', + state: 'started', + params: [ + { key: 'Weight', value: '185', editable: true, unit: 'lbs' } + ], + sets: [{ state: 'started', params: [], lineIndex: 0 }], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'started' + ); + expect(result.inputs.size).toBeGreaterThan(0); + }); + + it('should render set params with editable inputs', () => { + const exercise: Exercise = { + name: 'Set Editable', + state: 'started', + params: [], + sets: [ + { + state: 'started', + params: [ + { key: 'Reps', value: '10', editable:true, unit: '' } + ], + lineIndex: 0 + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'started' + ); + expect(result.setInputs.get(0)?.size).toBeGreaterThan(0); + }); + + it('should display rest info for sets during active workout', () => { + const exercise: Exercise = { + name: 'Rest Info', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [ + { key: 'Rest', value: '90s', editable: false, unit: '' } + ], + lineIndex: 0 + } + ], + lineIndex: 0 + }; + renderExercise( + container, + exercise, + 0, + true, + 0, + { workoutElapsed: 0, exerciseElapsed: 0, isOvertime: true, isRestActive: true, restElapsed: 75, restRemaining: -15 }, // Overtime by 15 seconds + mockCallbacks, + 'started', + undefined, + mockCallbacks, + 'started' + ); + expect(container.children.length).toBeGreaterThan(0); + }); + + it('should show rest display with recorded rest time', () => { + const exercise: Exercise = { + name: 'Recorded Rest', + state: 'completed', + params: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Rest', value: '60s', editable: false, unit: '' } + ], + lineIndex: 0, + recordedRest: '65s' + } + ], + lineIndex: 0 + }; + renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + expect(container.children.length).toBeGreaterThan(0); + }); + + it('should apply green phase class for rest at >66%', () => { + const exercise: Exercise = { + name: 'Green Phase', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [ + { key: 'Rest', value: '300s', editable: false, unit: '' } + ], + lineIndex: 0 + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + { workoutElapsed: 0, exerciseElapsed: 0, isOvertime: false, isRestActive: true, restElapsed: 50, restRemaining: 250 }, // 250/300 = 83% + mockCallbacks, + 'started' + ); + expect(result.container.className).toContain('state-in-progress'); + }); + + it('should test parameter display logic with no units', () => { + const exercise: Exercise = { + name: 'No Unit Params', + state: 'in-progress', + params: [ + { key: 'Reps', value: '8', editable: true, unit: '' } + ], + sets: [ + { + state: 'in-progress', + params: [ + { key: 'Set Reps', value: '10', editable: false, unit: '' } + ], + lineIndex: 0 + } + ], + lineIndex: 0 + }; + renderExercise( + container, + exercise, + 0, + true, + 0, + { workoutElapsed: 0, exerciseElapsed: 10, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, // timerState + mockCallbacks, // callbacks + 'started', // workoutState + undefined, // restDuration + undefined // totalExercises + ); + expect(container.querySelector('.workout-exercise')).toBeTruthy(); + }); + + it('should test exercise with all parameter types combined', () => { + const exercise: Exercise = { + name: 'Complex Params', + state: 'in-progress', + params: [ + { key: 'Reps', value: '20', editable: false, unit: '' }, + { key: 'Weight', value: '100', editable: true, unit: 'lbs' } + ], + sets: [ + { + state: 'in-progress', + params: [ + { key: 'Duration', value: '60s', editable: false, unit: '' }, + { key: 'Rest', value: '45s', editable: true, unit: '' } + ], + lineIndex: 0 + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + { workoutElapsed: 0, exerciseElapsed: 25, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, // timerState + mockCallbacks, // callbacks + 'started', // workoutState + undefined, // restDuration + 3 // totalExercises + ); + expect(result.container).toBeTruthy(); + }); + + it('should render multi-set exercise with specified active set', () => { + const exercise: Exercise = { + name: 'Multi-Set Active', + state: 'in-progress', + params: [], + sets: [ + { state: 'completed', params: [], lineIndex: 0 }, + { state: 'completed', params: [], lineIndex: 1 }, + { state: 'in-progress', params: [], lineIndex: 2 }, + { state: 'pending', params: [], lineIndex: 3 } + ], + lineIndex: 0 + }; + renderExercise( + container, + exercise, + 0, + true, + 2, // active set is index 2 + { workoutElapsed: 0, exerciseElapsed: 30, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, // timerState + mockCallbacks, // callbacks + 'started', // workoutState + undefined, // restDuration + undefined // totalExercises + ); + expect(container.children.length).toBeGreaterThan(0); + }); + + it('should handle set transitions with rest active', () => { + const exercise: Exercise = { + name: 'Set Transition', + state: 'in-progress', + params: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Rest', value: '120s', editable: false, unit: '' } + ], + lineIndex: 0 + }, + { + state: 'in-progress', + params: [], + lineIndex: 1 + } + ], + lineIndex: 0 + }; + renderExercise( + container, + exercise, + 0, + true, + 1, + { workoutElapsed: 0, exerciseElapsed: 0, isOvertime: false, isRestActive: true, restElapsed: 55, restRemaining: 65 }, // timerState + mockCallbacks, // callbacks + 'started', // workoutState + undefined, // restDuration + undefined // totalExercises + ); + expect(container.querySelector('.workout-exercise')).toBeTruthy(); + }); + + it('should check "Next" button text for intermediate set', () => { + const exercise: Exercise = { + name: 'Intermediate Set', + state: 'in-progress', + params: [], + sets: [ + { state: 'completed', params: [], lineIndex: 0 }, + { state: 'in-progress', params: [], lineIndex: 1 }, + { state: 'pending', params: [], lineIndex: 2 } + ], + lineIndex: 0 + }; + renderExercise( + container, + exercise, + 0, + true, + 1, + { workoutElapsed: 0, exerciseElapsed: 20, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, // timerState + mockCallbacks, // callbacks + 'started', // workoutState + undefined, // restDuration + 2 // totalExercises + ); + const buttons = container.querySelectorAll('button'); + const nextBtn = buttons.find(b => b.textContent && (b.textContent.includes('Next') || b.textContent.includes('Done'))); + expect(nextBtn).toBeTruthy(); + }); + }); + + describe('Button event handlers with clicks', () => { + let container: MockElement; + let mockCallbacks: WorkoutCallbacks; + + beforeEach(() => { + container = new MockElement('div'); + mockCallbacks = { + onExerciseStateChange: jest.fn(), + onSetStateChange: jest.fn(), + onParamChange: jest.fn(), + onSetParamChange: jest.fn(), + onStartWorkout: jest.fn(), + onFinishWorkout: jest.fn(), + onExerciseFinish: jest.fn(), + onSetFinish: jest.fn(), + onFlushChanges: jest.fn(), + onAddSample: jest.fn(), + onExerciseAddSet: jest.fn(), + onExerciseAddRest: jest.fn(), + onExerciseSkip: jest.fn(), + onPauseExercise: jest.fn(), + onResumeExercise: jest.fn(), + onRestEnd: jest.fn() + }; + }); + + it('should trigger pause click handler and toggle text', () => { + const exercise: Exercise = { + name: 'Pause Test', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [], + lineIndex: 0 + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + { workoutElapsed: 0, exerciseElapsed: 0, isOvertime: false, isRestActive: true, restElapsed: 0, restRemaining: 30 }, // No rest duration param + mockCallbacks, + 'started', + undefined, + mockCallbacks, + 'started' + ); + + const buttons = result.container.querySelectorAll('button'); + const pauseBtn = buttons.find(b => b.textContent !== undefined && (b.textContent === 'Pause' || b.textContent === 'Resume')); + expect(pauseBtn).toBeTruthy(); + if (pauseBtn) { + pauseBtn.click(); + expect(mockCallbacks.onPauseExercise).toHaveBeenCalledTimes(1); + expect(pauseBtn.textContent).toBe('Resume'); + pauseBtn.click(); + expect(mockCallbacks.onResumeExercise).toHaveBeenCalledTimes(1); + expect(pauseBtn.textContent).toBe('Pause'); + } + }); + + it('should trigger skip click handler', () => { + const exercise: Exercise = { + name: 'Skip Test', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [], + lineIndex: 0 + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + { workoutElapsed: 0, exerciseElapsed: 5, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, + mockCallbacks, + 'started', + undefined, + mockCallbacks, + 'started' + ); + + const buttons = result.container.querySelectorAll('button'); + const skipBtn = buttons.find(b => b.textContent === 'Skip'); + expect(skipBtn).toBeTruthy(); + if (skipBtn) { + skipBtn.click(); + expect(mockCallbacks.onExerciseSkip).toHaveBeenCalledWith(0); + } + }); + + it('should trigger add set click handler', () => { + const exercise: Exercise = { + name: 'Add Set Test', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [], + lineIndex: 0 + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + { workoutElapsed: 0, exerciseElapsed: 5, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, + mockCallbacks, + 'started', + undefined, + mockCallbacks, + 'started' + ); + + const buttons = result.container.querySelectorAll('button'); + const addSetBtn = buttons.find(b => b.textContent === '+ Set'); + expect(addSetBtn).toBeTruthy(); + if (addSetBtn) { + addSetBtn.click(); + expect(mockCallbacks.onExerciseAddSet).toHaveBeenCalledWith(0); + } + }); + + it('should trigger add rest click handler', () => { + const exercise: Exercise = { + name: 'Add Rest Test', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [], + lineIndex: 0 + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 0, + { workoutElapsed: 0, exerciseElapsed: 5, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, // timerState + mockCallbacks, // callbacks + 'started', // workoutState + 10, // restDuration + undefined // totalExercises + ); + + const buttons = result.container.querySelectorAll('button'); + const addRestBtn = buttons.find(b => b.textContent === '+ Rest'); + expect(addRestBtn).toBeTruthy(); + if (addRestBtn) { + addRestBtn.click(); + expect(mockCallbacks.onExerciseAddRest).toHaveBeenCalledWith(0); + } + }); + + it('should trigger next set click handler during workout', () => { + const exercise: Exercise = { + name: 'Next Set Test', + state: 'in-progress', + params: [], + sets: [ + { state: 'completed', params: [], lineIndex: 0 }, + { + state: 'in-progress', + params: [], + lineIndex: 1 + }, + { state: 'pending', params: [], lineIndex: 2 } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 1, + { workoutElapsed: 0, exerciseElapsed: 5, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, // timerState + mockCallbacks, // callbacks + 'started', // workoutState + 10, // restDuration + 2 // totalExercises + ); + + const buttons = result.container.querySelectorAll('button'); + const nextBtn = buttons.find(b => b.textContent === 'Next'); + expect(nextBtn).toBeTruthy(); + if (nextBtn) { + nextBtn.click(); + expect(mockCallbacks.onSetFinish).toHaveBeenCalledWith(0, 1); + } + }); + + it('should trigger next click handler on last set (not final exercise)', () => { + const exercise: Exercise = { + name: 'Next Handler Test', + state: 'in-progress', + params: [], + sets: [ + { state: 'completed', params: [], lineIndex: 0 }, + { + state: 'in-progress', + params: [], + lineIndex: 1 + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 1, + { workoutElapsed: 0, exerciseElapsed: 5, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, // timerState + mockCallbacks, // callbacks + 'started', // workoutState + undefined, // restDuration + 3 // totalExercises - not the last + ); + + const buttons = result.container.querySelectorAll('button'); + const nextBtn = buttons.find(b => b.textContent === 'Next'); + expect(nextBtn).toBeTruthy(); + if (nextBtn) { + nextBtn.click(); + expect(mockCallbacks.onSetFinish).toHaveBeenCalledWith(0, 1); + } + }); + + it('should trigger done click handler on final set', () => { + const exercise: Exercise = { + name: 'Done Handler Test', + state: 'in-progress', + params: [], + sets: [ + { state: 'completed', params: [], lineIndex: 0 }, + { + state: 'in-progress', + params: [], + lineIndex: 1 + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 1, + { workoutElapsed: 0, exerciseElapsed: 5, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, // timerState + mockCallbacks, // callbacks + 'started', // workoutState + undefined, // restDuration + 1 // totalExercises - this is the last + ); + + const buttons = result.container.querySelectorAll('button'); + const doneBtn = buttons.find(b => b.textContent === 'Done'); + expect(doneBtn).toBeTruthy(); + if (doneBtn) { + doneBtn.click(); + expect(mockCallbacks.onSetFinish).toHaveBeenCalledWith(0, 1); + } + }); + + it('should trigger start next click handler during rest', () => { + const exercise: Exercise = { + name: 'Start Next Test', + state: 'in-progress', + params: [], + sets: [ + { state: 'completed', params: [], lineIndex: 0 }, + { + state: 'in-progress', + params: [], + lineIndex: 1 + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 1, + { workoutElapsed: 0, exerciseElapsed: 0, isOvertime: false, isRestActive: true, restElapsed: 120, restRemaining: 30 }, // timerState + mockCallbacks, // callbacks + 'started', // workoutState + undefined, // restDuration + 2 // totalExercises + ); + + const buttons = result.container.querySelectorAll('button'); + const startNextBtn = buttons.find(b => b.textContent === 'Next'); + expect(startNextBtn).toBeTruthy(); + if (startNextBtn) { + startNextBtn.click(); + expect(mockCallbacks.onRestEnd).toHaveBeenCalledWith(0); + } + }); + }); + + describe('Helper function coverage - multi-set exercises', () => { + let container: MockElement; + let mockCallbacks: WorkoutCallbacks; + + beforeEach(() => { + container = new MockElement('div'); + mockCallbacks = { + onExerciseStateChange: jest.fn(), + onSetStateChange: jest.fn(), + onParamChange: jest.fn(), + onSetParamChange: jest.fn(), + onStartWorkout: jest.fn(), + onFinishWorkout: jest.fn(), + onExerciseFinish: jest.fn(), + onSetFinish: jest.fn(), + onFlushChanges: jest.fn(), + onAddSample: jest.fn(), + onExerciseAddSet: jest.fn(), + onExerciseAddRest: jest.fn(), + onExerciseSkip: jest.fn(), + onPauseExercise: jest.fn(), + onResumeExercise: jest.fn(), + onRestEnd: jest.fn() + }; + }); + + it('should exercise hasDisplayableSetParams and getDisplayableSetParams in multi-set', () => { + const exercise: Exercise = { + name: 'Multi-Set Display Params', + state: 'in-progress', + params: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Reps', value: '15', editable: true, unit: '' }, + { key: 'Weight', value: '185', editable: false, unit: 'lbs' }, + { key: 'Duration', value: '60s', editable: false, unit: '' } + ], + lineIndex: 0 + }, + { + state: 'in-progress', + params: [ + { key: 'Reps', value: '14', editable: true, unit: '' }, + { key: 'Duration', value: '60s', editable: false, unit: '' } + ], + lineIndex: 1 + }, + { + state: 'pending', + params: [ + { key: 'Reps', value: '12', editable: true, unit: '' }, + { key: 'Duration', value: '60s', editable: false, unit: '' } + ], + lineIndex: 2 + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 1, + { workoutElapsed: 25, exerciseElapsed: 25, isOvertime: false, isRestActive: false }, + mockCallbacks, + 'started' + ); + expect(result.container.children.length).toBeGreaterThan(0); + }); + + it('should exercise getSetRecordedDuration with completed multi-set', () => { + const exercise: Exercise = { + name: 'Multi-Set Recorded Duration', + state: 'completed', + params: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Duration', value: '3m 20s', editable: false, unit: '' }, + { key: 'Rest', value: '60s', editable: false, unit: '' } + ], + lineIndex: 0, + recordedTime: '3m 25s' + }, + { + state: 'completed', + params: [ + { key: 'Duration', value: '3m 15s', editable: false, unit: '' }, + { key: 'Rest', value: '60s', editable: false, unit: '' } + ], + lineIndex: 1, + recordedTime: '3m 20s' + }, + { + state: 'completed', + params: [ + { key: 'Duration', value: '3m', editable: false, unit: '' } + ], + lineIndex: 2, + recordedTime: '3m 10s' + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + expect(result.container.children.length).toBeGreaterThan(0); + }); + + it('should exercise getSetRestDuration with Rest params in sets', () => { + const exercise: Exercise = { + name: 'Exercise With Rest Params', + state: 'completed', + params: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Rest', value: '60s', editable: false, unit: '' }, + { key: 'Reps', value: '12', editable: false, unit: '' } + ], + lineIndex: 0 + }, + { + state: 'completed', + params: [ + { key: 'Rest', value: '90s', editable: false, unit: '' }, + { key: 'Reps', value: '10', editable: false, unit: '' } + ], + lineIndex: 1, + recordedRest: '95s' + }, + { + state: 'completed', + params: [ + { key: 'Reps', value: '8', editable: false, unit: '' } + ], + lineIndex: 2 + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + expect(result.container.children.length).toBeGreaterThan(0); + }); + + it('should exercise computeExerciseTotals with multi-set completed', () => { + const exercise: Exercise = { + name: 'Totals Exercise', + state: 'completed', + params: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Reps', value: '15', editable: false, unit: '' }, + { key: 'Weight', value: '185', editable: false, unit: 'lbs' }, + { key: 'Duration', value: '60s', editable: false, unit: '' } + ], + lineIndex: 0, + recordedTime: '65s' + }, + { + state: 'completed', + params: [ + { key: 'Reps', value: '12', editable: false, unit: '' }, + { key: 'Weight', value: '185', editable: false, unit: 'lbs' }, + { key: 'Duration', value: '50s', editable: false, unit: '' }, + { key: 'Rest', value: '90s', editable: false, unit: '' } + ], + lineIndex: 1, + recordedTime: '55s', + recordedRest: '92s' + }, + { + state: 'completed', + params: [ + { key: 'Reps', value: '10', editable: false, unit: '' }, + { key: 'Weight', value: '185', editable: false, unit: 'lbs' }, + { key: 'Duration', value: '45s', editable: false, unit: '' }, + { key: 'Rest', value: '90s', editable: false, unit: '' } + ], + lineIndex: 2, + recordedTime: '48s', + recordedRest: '88s' + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + expect(result.container.children.length).toBeGreaterThan(0); + }); + + it('should exercise renderSet implicitly through multi-set rendering', () => { + const exercise: Exercise = { + name: 'renderSet Test', + state: 'in-progress', + params: [ + { key: 'Weight', value: '225', editable: false, unit: 'lbs' } + ], + sets: [ + { + state: 'completed', + params: [ + { key: 'Reps', value: '15', editable: false, unit: '' } + ], + lineIndex: 0 + }, + { + state: 'completed', + params: [ + { key: 'Reps', value: '13', editable: false, unit: '' } + ], + lineIndex: 1 + }, + { + state: 'in-progress', + params: [ + { key: 'Reps', value: '12', editable: false, unit: '' } + ], + lineIndex: 2 + }, + { + state: 'pending', + params: [ + { key: 'Reps', value: '10', editable: false, unit: '' } + ], + lineIndex: 3 + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 2, + { workoutElapsed: 15, exerciseElapsed: 15, isOvertime: false, isRestActive: false }, + mockCallbacks, + 'started' + ); + expect(result.container.children.length).toBeGreaterThan(0); + }); + + it('should exercise nameToHue with various exercise names', () => { + const names = [ + 'Bench Press', + 'Deadlift', + 'Squats', + 'Pull-ups', + 'Overhead Press', + 'Barbell Rows', + 'Dips', + 'Cable Curls' + ]; + for (const name of names) { + const exercise: Exercise = { + name: name, + state: 'pending', + params: [], + sets: [ + { + state: 'pending', + params: [], + lineIndex: 0 + } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'planned' + ); + // Verify that nameToHue was called by checking the style property was set + expect(result.container.style.setProperty).toHaveBeenCalledWith( + '--exercise-color', + expect.stringContaining('hsl(') + ); + } + }); + }); + + describe('Edge cases for helper functions', () => { + let container: MockElement; + let mockCallbacks: WorkoutCallbacks; + + beforeEach(() => { + container = new MockElement('div'); + mockCallbacks = { + onExerciseStateChange: jest.fn(), + onSetStateChange: jest.fn(), + onParamChange: jest.fn(), + onSetParamChange: jest.fn(), + onStartWorkout: jest.fn(), + onFinishWorkout: jest.fn(), + onExerciseFinish: jest.fn(), + onSetFinish: jest.fn(), + onFlushChanges: jest.fn(), + onAddSample: jest.fn(), + onExerciseAddSet: jest.fn(), + onExerciseAddRest: jest.fn(), + onExerciseSkip: jest.fn(), + onPauseExercise: jest.fn(), + onResumeExercise: jest.fn(), + onRestEnd: jest.fn() + }; + }); + + it('should render exercise with displayable set params', () => { + const exercise: Exercise = { + name: 'Display Params', + state: 'in-progress', + params: [], + sets: [ + { + state: 'in-progress', + params: [ + { key: 'Reps', value: '15', editable: true, unit: '' }, + { key: 'Weight', value: '185', editable: false, unit: 'lbs' }, + { key: 'Rest', value: '90s', editable: true, unit: '' }, + { key: 'Duration', value: '60s', editable: false, unit: '' } + ], + lineIndex: 0 + } + ], + lineIndex: 0 + }; + renderExercise( + container, + exercise, + 0, + true, + 0, + { workoutElapsed: 0, exerciseElapsed: 5, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, + mockCallbacks, + 'started', + undefined, + mockCallbacks, + 'started' + ); + expect(container.children.length).toBeGreaterThan(0); + }); + + it('should render exercise with computed totals in completed state', () => { + const exercise: Exercise = { + name: 'Computed Totals', + state: 'completed', + params: [ + { key: 'Reps', value: '50', editable: false, unit: '' }, + { key: 'Weight', value: '200', editable: false, unit: 'lbs' } + ], + sets: [ + { + state: 'completed', + params: [ + { key: 'Duration', value: '3m', editable: false, unit: '' }, + { key: 'Rest', value: '60s', editable: true, unit: '' } + ], + lineIndex: 0 + }, + { + state: 'completed', + params: [ + { key: 'Duration', value: '2m 50s', editable: false, unit: '' }, + { key: 'Rest', value: '60s', editable: true, unit: '' } + ], + lineIndex: 1 + } + ], + lineIndex: 0, + recordedTime: '5m 50m' + }; + renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + expect(container.children.length).toBeGreaterThan(0); + }); + + it('should exercise updateExerciseTimer with various timer states', () => { + const timerEl = new MockElement('span'); + const timerState: TimerState = { + workoutElapsed: 100, + exerciseElapsed: 45, + isRestActive: false, + isOvertime: false, // Added missing property + restRemaining: 0 + }; + updateExerciseTimer(timerEl, timerState, 60); + expect(timerEl.textContent).toBeTruthy(); + }); + + it('should exercise updateExerciseTimer with target duration', () => { + const timerEl = new MockElement('span'); + const timerState: TimerState = { + workoutElapsed: 60, + exerciseElapsed: 30, + isRestActive: false, + isOvertime: false, // Added missing property + restRemaining: 0 // Added missing property + }; + updateExerciseTimer(timerEl, timerState,120); + expect(timerEl.textContent).toBeTruthy(); + }); + + it('should render set recorded rest duration', () => { + const exercise: Exercise = { + name: 'Recorded Rest', + state: 'completed', + params: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Rest', value: '60s', editable: true, unit: '' } + ], + lineIndex: 0, + recordedRest: '65s' + } + ], + lineIndex: 0 + }; + renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + expect(container.children.length).toBeGreaterThan(0); + }); + + it('should render set with recorded duration param', () => { + const exercise: Exercise = { + name: 'Recorded Duration', + state: 'completed', + params: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Duration', value: '4m 20s', editable: false, unit: '' } + ], + lineIndex: 0, + recordedTime: '4m 25s' + } + ], + lineIndex: 0 + }; + renderExercise( + container, + exercise, + 0, + false, + -1, + null, + mockCallbacks, + 'completed' + ); + expect(container.children.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index b61e0be..9660cf4 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -1,14 +1,35 @@ -import { Exercise, ExerciseState, TimerState, WorkoutCallbacks } from '../types'; -import { formatDuration } from '../parser/exercise'; +/** + * Exercise and set rendering for workout blocks. + * + * Handles rendering of exercises and their nested sets with: + * - State indicators (pending, in-progress, completed, skipped) + * - Exercise/set parameters (duration, weight, reps, rest) as editable chips + * - Timer displays (countdown for exercises, count-up for sets) + * - Total aggregations (for multi-set exercises: total reps, weight, recorded time, rest) + * - Set-specific controls (pause, skip, add set, finish, etc.) + * - Rest phase color coding (green > yellow > red as time depletes) + * + * Architecture: + * - renderExercise() is the entry point, handles layout decisions + * - renderSet/renderSetWithTimerElement for multi-set rendering + * - renderSetControls() handles workout interaction buttons + * - updateExerciseTimer() efficiently updates timer during progress + * - Helper functions extract and compute exercise/set data + */ -const STATE_ICONS: Record = { - 'pending': '○', - 'inProgress': '◐', - 'completed': '✓', - 'skipped': '—' -}; +import { Constants, Exercise, ExerciseSet, ExerciseParam, ExerciseState, TimerState, WorkoutCallbacks } from '../types'; +import { formatDuration, parseDurationToSeconds, formatDurationHuman } from '../parser/exercise'; -// Generate a consistent color hue from exercise name (djb2 hash with better distribution) +/** + * Generate a consistent color hue from exercise name using djb2 hash. + * Ensures each exercise gets a unique, visually distinct color. + * Uses golden ratio distribution for better hue spread across color spectrum. + * + * Parameters: + * - name: Exercise name to hash + * + * Returns: Hue value (0-359) suitable for HSL color + */ function nameToHue(name: string): number { let hash = 5381; for (let i = 0; i < name.length; i++) { @@ -20,30 +41,309 @@ function nameToHue(name: string): number { return Math.floor(((normalized * golden) % 1) * 360); } +/** + * Context object for exercise rendering. + * Tracks DOM elements and input fields for later reference and updates. + * + * Properties: + * - container: Main exercise container element + * - timerEl: Timer display for exercise (null if multiple sets) + * - setTimerEl: Timer display for active set (for multi-set exercises) + * - inputs: Exercise-level parameters as editable inputs (by key) + * - setInputs: Set-level parameters grouped by set index, then by key + */ export interface ExerciseElements { container: HTMLElement; timerEl: HTMLElement | null; + setTimerEl: HTMLElement | null; // Timer element for active set inputs: Map; + setInputs: Map>; // Indexed by set index } -// Check if exercise has non-Duration params -function hasDisplayableParams(exercise: Exercise): boolean { - return exercise.params.some(p => p.key.toLowerCase() !== 'duration'); +/** + * Check if a set has displayable parameters (duration, weight, reps). + * Used to decide whether to render parameters section for a set. + * + * Parameters: + * - set: ExerciseSet to check + * + * Returns: true if set contains at least one displayable param + */ +function hasDisplayableSetParams(set: ExerciseSet): boolean { + return set.params.some(p => { + const key = p.key.toLowerCase(); + return key === 'duration' || key === 'weight' || key === 'reps' || key === 'rest'; + }); +} + +/** + * Extract displayable set parameters in a specific order. + * Only returns: duration, weight, reps (if present). + * Other parameters are ignored for set-level display. + * + * Parameters: + * - set: ExerciseSet containing parameters + * + * Returns: Array of params in order [duration, weight, reps] + */ +function getDisplayableSetParams(set: ExerciseSet): ExerciseParam[] { + const paramOrder = ['duration', 'weight', 'reps', 'rest']; + const paramsMap = new Map(); + + // Build map of params by key + for (const param of set.params) { + const key = param.key.toLowerCase(); + if (paramOrder.includes(key)) { + paramsMap.set(key, param); + } + } + + // Return params in specified order + const orderedParams: ExerciseParam[] = []; + for (const key of paramOrder) { + const param = paramsMap.get(key); + if (param) { + orderedParams.push(param); + } + } + + return orderedParams; +} + +/** + * Extract recorded time from a set (system-managed ~time param). + * Recorded times are locked (not editable) params created after set completion. + * + * Parameters: + * - set: ExerciseSet to extract from + * + * Returns: Recorded time string (e.g., "1m 30s") or null if not recorded + */ +function getSetRecordedTime(set: ExerciseSet): string | null { + const durationParam = set.params.find(p => p.key.toLowerCase() === '~time'); + return durationParam ? durationParam.value : null; +} + +/** + * Extract recorded rest from a set (system-managed ~rest param). + * Rest is the period between sets (after completing one set, before starting next). + * + * Parameters: + * - set: ExerciseSet to extract from + * + * Returns: Rest string (e.g., "60s") or null if not set + */ +function getSetRecordedRest(set: ExerciseSet): string | null { + const restParam = set.params.find(p => p.key.toLowerCase() === '~rest'); + return restParam ? restParam.value : null; +} + +/** + * Extract duration from a set's duration parameter. + * Duration is the target or recorded time for completing the set. + * + * Parameters: + * - set: ExerciseSet to extract from + * + * Returns: Duration string (e.g., "60s") or null if not set + */ +function getSetDuration(set: ExerciseSet): string | null { + const durationParam = set.params.find(p => p.key.toLowerCase() === 'duration'); + return durationParam ? durationParam.value : null; +} + +/** + * Render a display-only total parameter (e.g., total weight, total reps). + * Display-only version with = prefix and no editing capability. + * + * Parameters: + * - container: Parent element to render into + * - value: The value to display + * - unit: Optional unit string (e.g., " lbs") + */ +function renderTotalParam( + container: HTMLElement, + value: number | string, + unit?: string +): void { + const paramEl = container.createSpan({ cls: 'workout-param' }); + paramEl.createSpan({ cls: 'workout-param-prefix', text: '=' }); + paramEl.createSpan({ cls: 'workout-param-value', text: String(value) }); + if (unit) { + paramEl.createSpan({ cls: 'workout-param-unit', text: unit }); + } +} + +/** + * Render a parameter element with optional editability. + * Handles prefix icons (based on param key), value display/input, and unit rendering. + * + * Parameters: + * - container: Parent element to render into + * - param: Parameter to render + * - workoutState: Current workout state (determines if param is editable) + * - onInputChange: Callback when input value changes + * + * Returns: HTMLInputElement if editable, undefined otherwise + */ +function renderParamElement( + container: HTMLElement, + param: ExerciseParam, + workoutState: 'planned' | 'started' | 'completed', + onInputChange: (value: string) => void +): HTMLInputElement | undefined { + const paramEl = container.createSpan({ cls: 'workout-param' }); + + // Render prefix icon if icon exists for this param key + const keyLower = param.key.toLowerCase(); + if (Constants.PARAM_PREFIX_ICONS[keyLower]) { + paramEl.createSpan({ cls: 'workout-param-prefix', text: Constants.PARAM_PREFIX_ICONS[keyLower] }); + } + + // Duration and rest are never editable - always display as read-only + // Brackets in markdown determine timer behavior (countdown vs count-up), not editability + const isNeverEditable = keyLower === 'duration' || keyLower === 'rest'; + const isEditable = !isNeverEditable && param.editable && workoutState !== 'completed'; + + if (isEditable) { + const input = paramEl.createEl('input', { + cls: 'workout-param-input', + type: 'text', + value: param.value + }); + input.addEventListener('input', () => onInputChange(input.value)); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') input.blur(); + }); + + // Unit after value + if (param.unit) { + paramEl.createSpan({ cls: 'workout-param-unit', text: ` ${param.unit}` }); + } + + return input; + } else { + paramEl.createSpan({ cls: 'workout-param-value', text: param.value }); + + // Unit after value + if (param.unit) { + paramEl.createSpan({ cls: 'workout-param-unit', text: ` ${param.unit}` }); + } + + return undefined; + } +} + +/** + * Aggregate totals from all sets in an exercise. + * Sums reps, weight, and recorded times across all sets. + * Used for display on multi-set exercise main row. + * + * Parameters: + * - exercise: Exercise to aggregate + * - isCompleted: Whether exercise is completed (affects which fields to sum) + * + * Returns: Object with aggregated totals + * - reps: Total reps (null if no reps params present) + * - weight: Total weight (null if no weight params present) + * - duration: Target duration (from exercise.targetDuration) + * - totalRecordedTime: Sum of recorded durations from all sets + * - totalRest: Sum of rest periods from all sets + */ +function computeExerciseTotals(exercise: Exercise, isCompleted: boolean): { + reps: number | null; + weight: number | null; + duration: number; + totalRecordedTime: number; + totalRest: number; +} { + let totalReps = 0; + let totalWeight = 0; + let totalRecordedTime = 0; + let totalRest = 0; + let repsFound = false; + let weightFound = false; + let targetDuration = exercise.targetDuration || 0; + + for (const set of exercise.sets) { + for (const param of set.params) { + if (param.key.toLowerCase() === 'reps') { + const reps = parseInt(param.value, 10); + if (!isNaN(reps)) { + totalReps += reps; + repsFound = true; + } + } else if (param.key.toLowerCase() === 'weight' || param.key.toLowerCase() === 'load') { + const weight = parseFloat(param.value); + if (!isNaN(weight)) { + totalWeight += weight; + weightFound = true; + } + } else if (param.key.toLowerCase() === 'rest') { + // Sum rest durations from all sets (only editable means user-configured) + if (param.editable) { + const restSeconds = parseDurationToSeconds(param.value); + totalRest += restSeconds; + } + } + } + + // If exercise is completed, sum recorded durations from each set + if (isCompleted) { + for (const param of set.params) { + if (param.key.toLowerCase() === '~time') { + const seconds = parseDurationToSeconds(param.value); + totalRecordedTime += seconds; + } + } + } + } + + return { + reps: repsFound ? totalReps : null, + weight: weightFound ? totalWeight : null, + duration: targetDuration, + totalRecordedTime, + totalRest + }; } +/** + * Render an exercise with all its sets and current state. + * + * Determines layout based on exercise structure: + * - Single set: renders on main row (icon | name | params | timer) + * - Multiple sets: renders as separate indented rows per set + * - Active during workout: displays timer and control buttons + * + * Parameters: + * - container: Parent DOM element to render into + * - exercise: Exercise object with sets and metadata + * - index: Exercise index in parent workout + * - isActive: Whether this exercise is currently active (timer running) + * - activeSetIndex: Index of active set (if isActive) + * - timerState: Current timer state (if isActive) + * - callbacks: User action handlers + * - workoutState: Current workout state (planned/started/completed) + * - restDuration: Default rest duration (from metadata) + * - totalExercises: Total count of exercises (for button labels) + * + * Returns: ExerciseElements with references to rendered elements + */ export function renderExercise( container: HTMLElement, exercise: Exercise, index: number, isActive: boolean, + activeSetIndex: number, timerState: TimerState | null, callbacks: WorkoutCallbacks, workoutState: 'planned' | 'started' | 'completed', - restDuration?: number + restDuration?: number, + totalExercises?: number ): ExerciseElements { - const isSimple = !hasDisplayableParams(exercise); const exerciseEl = container.createDiv({ - cls: `workout-exercise state-${exercise.state}${isActive ? ' active' : ''}${isSimple ? ' simple' : ''}` + cls: `workout-exercise state-${exercise.state}${isActive ? ' active' : ''}` }); // Set color based on exercise name @@ -51,92 +351,370 @@ export function renderExercise( exerciseEl.style.setProperty('--exercise-color', `hsl(${hue}, 65%, 55%)`); const inputs = new Map(); + const setInputs = new Map>(); + let setTimerEl: HTMLElement | null = null; // Track active set timer for timer updates - // Single row: icon | name | params | timer + // Single row layout: icon | name | params | timer const mainRow = exerciseEl.createDiv({ cls: 'workout-exercise-main' }); // State icon const iconEl = mainRow.createSpan({ cls: 'workout-exercise-icon' }); - iconEl.textContent = STATE_ICONS[exercise.state]; + iconEl.textContent = Constants.STATE_ICONS[exercise.state]; // Exercise name const nameEl = mainRow.createSpan({ cls: 'workout-exercise-name' }); nameEl.textContent = exercise.name; - // Params inline (between name and timer) - chip/pill style - if (hasDisplayableParams(exercise)) { - const paramsEl = mainRow.createSpan({ cls: 'workout-exercise-params' }); + // Determine multi-set layout and calculate totals + const hasMultipleSets = exercise.sets.length > 1; + const isCompleted = exercise.state === 'completed'; + const totals = computeExerciseTotals(exercise, isCompleted); + + // Display totals on multi-set exercise main row (only when completed) + if (hasMultipleSets && isCompleted) { + const totalsEl = mainRow.createSpan({ cls: 'workout-exercise-params' }); - for (const param of exercise.params) { - // Skip Duration param (shown in timer) - if (param.key.toLowerCase() === 'duration') continue; + // Show weight + if (totals.weight !== null) { + renderTotalParam(totalsEl, totals.weight, ' lbs'); + } - const paramEl = paramsEl.createSpan({ cls: 'workout-param' }); + // Show total reps + if (totals.reps !== null) { + renderTotalParam(totalsEl, totals.reps); + } - // × prefix for params without units (plain numbers) - if (!param.unit) { - paramEl.createSpan({ cls: 'workout-param-prefix', text: '×' }); - } + // Show total rest time + if (totals.totalRest > 0) { + renderTotalParam(totalsEl, formatDurationHuman(totals.totalRest)); + } - if (param.editable && workoutState !== 'completed') { - const input = paramEl.createEl('input', { - cls: 'workout-param-input', - type: 'text', - value: param.value - }); - // Track changes immediately (updates in-memory state) - input.addEventListener('input', () => { - callbacks.onParamChange(index, param.key, input.value); - }); - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - input.blur(); - } - }); - inputs.set(param.key, input); - } else { - paramEl.createSpan({ cls: 'workout-param-value', text: param.value }); - } + // Show total recorded time when completed + if (totals.totalRecordedTime > 0) { + renderTotalParam(totalsEl, formatDurationHuman(totals.totalRecordedTime)); + } + } - // Unit after value - if (param.unit) { - paramEl.createSpan({ cls: 'workout-param-unit', text: ` ${param.unit}` }); + // For single-set exercises, render set params inline on mainRow (if present) + // Otherwise render exercise-level params + if (!hasMultipleSets && exercise.sets.length > 0) { + const singleSet = exercise.sets[0]; + if (singleSet && hasDisplayableSetParams(singleSet)) { + // Set has displayable params: render those instead of exercise params + const paramsEl = mainRow.createSpan({ cls: 'workout-exercise-params' }); + const setParamInputs = new Map(); + const displayableParams = getDisplayableSetParams(singleSet); + + for (const param of displayableParams) { + const input = renderParamElement( + paramsEl, + param, + workoutState, + (value) => callbacks.onSetParamChange(index, 0, param.key, value) + ); + if (input) setParamInputs.set(param.key, input); + } + setInputs.set(0, setParamInputs); + } else if (exercise.params.length > 0) { + // Set has no displayable params: render exercise-level params instead + const containerEl = mainRow.createSpan({ cls: 'workout-exercise-params' }); + for (const param of exercise.params) { + const input = renderParamElement( + containerEl, + param, + workoutState, + (value) => callbacks.onParamChange(index, param.key, value) + ); + if (input) inputs.set(param.key, input); } } + } else if (!hasMultipleSets && exercise.params.length > 0) { + // No sets: render exercise-level params + const containerEl = mainRow.createSpan({ cls: 'workout-exercise-params' }); + for (const param of exercise.params) { + const input = renderParamElement( + containerEl, + param, + workoutState, + (value) => callbacks.onParamChange(index, param.key, value) + ); + if (input) inputs.set(param.key, input); + } } - // Timer display (right side) - const timerEl = mainRow.createSpan({ cls: 'workout-exercise-timer' }); + // Timer display (right side of mainRow) + // Shown on mainRow if single set, otherwise on active set + // Priority order: completed > active > static target > pending placeholder + let timerEl: HTMLElement | null = null; + + if (!hasMultipleSets) { + timerEl = mainRow.createSpan({ cls: 'workout-exercise-timer' }); - if (exercise.state === 'completed' && exercise.recordedDuration) { - timerEl.textContent = exercise.recordedDuration; - timerEl.createSpan({ cls: 'timer-indicator recorded', text: ' ✓' }); - } else if (isActive && timerState) { - updateExerciseTimer(timerEl, timerState, exercise.targetDuration); - } else if (exercise.targetDuration) { - timerEl.textContent = formatDuration(exercise.targetDuration); - timerEl.createSpan({ cls: 'timer-indicator count-down', text: ' ▼' }); - } else if (exercise.state === 'pending') { - timerEl.textContent = '--'; + const singleSetDuration = exercise.sets[0] ? exercise.sets[0].targetDuration : undefined; + // 1. Exercise completed: show recorded duration with checkmark + // This is the final state after workout finishes + if (exercise.state === 'completed' && exercise.recordedTime) { + timerEl.textContent = exercise.recordedTime; + timerEl.createSpan({ cls: 'timer-indicator recorded', text: Constants.TIMER_ICONS['recorded'] }); + } + // 2. Exercise currently active: show live timer (updates in real-time) + // During workout, timer counts up/down and keeps updating via updateExerciseTimer() + else if (isActive && timerState) { + updateExerciseTimer(timerEl, timerState, exercise.targetDuration); + } + // 3. Static target display (when not active) + else if (singleSetDuration) { + timerEl.textContent = formatDuration(singleSetDuration); + timerEl.createSpan({ cls: 'timer-indicator count-down', text: Constants.TIMER_ICONS['count-down'] }); + } + // 4. Pending exercise with no target: show placeholder + else { + timerEl.textContent = '--'; + } } + // Render sets as indented rows (only for multi-set exercises) + else { + const setsContainer = exerciseEl.createDiv({ cls: 'workout-sets' }); + for (let setIndex = 0; setIndex < exercise.sets.length; setIndex++) { + const set = exercise.sets[setIndex]; + if (!set) continue; - // Controls row (only for active exercise during workout) + const isSetActive = isActive && setIndex === activeSetIndex; + if (isSetActive) { + // Store timer element for active set so we can update it + setTimerEl = renderSetWithTimerElement( + setsContainer, + set, + setIndex, + index, + isSetActive, + isSetActive ? timerState : null, + callbacks, + workoutState, + setInputs + ); + } else { + renderSet( + setsContainer, + set, + setIndex, + index, + isSetActive, + isSetActive ? timerState : null, + callbacks, + workoutState, + setInputs + ); + } + } + } + + // Controls row (only for active set during workout) if (isActive && workoutState === 'started') { - renderExerciseControls(exerciseEl, index, callbacks, restDuration); + renderSetControls(exerciseEl, index, activeSetIndex, exercise.sets.length, callbacks, restDuration, timerState, totalExercises); + } + + return { container: exerciseEl, timerEl, setTimerEl, inputs, setInputs }; +} + +/** + * Render a set row (simple delegation to renderSetWithTimerElement). + * Exists for semantic clarity - differentiates set rendering from set-with-timer rendering. + */ +function renderSet( + container: HTMLElement, + set: ExerciseSet, + setIndex: number, + exerciseIndex: number, + isActive: boolean, + timerState: TimerState | null, + callbacks: WorkoutCallbacks, + workoutState: 'planned' | 'started' | 'completed', + setInputs: Map> +): void { + renderSetWithTimerElement(container, set, setIndex, exerciseIndex, isActive, timerState, callbacks, workoutState, setInputs); +} + +/** + * Render a set row with timer element. + * + * Creates indented set row with: + * - State icon and "Set N" label + * - Set parameters (duration, weight, reps) as chips + * - Rest duration display + * - Timer display (if active) + * - Rest phase color coding (green/yellow/red based on remaining time) + * + * Parameters: + * - container: Parent DOM element + * - set: ExerciseSet to render + * - setIndex: Index of this set (0-based) + * - exerciseIndex: Index of parent exercise + * - isActive: Whether this set is currently active + * - timerState: Current timer state (if active) + * - callbacks: User action handlers + * - workoutState: Workout state (planned/started/completed) + * - setInputs: Map to track input elements by set index and key + * + * Returns: Timer element (for active sets) or null + */ +function renderSetWithTimerElement( + container: HTMLElement, + set: ExerciseSet, + setIndex: number, + exerciseIndex: number, + isActive: boolean, + timerState: TimerState | null, + callbacks: WorkoutCallbacks, + workoutState: 'planned' | 'started' | 'completed', + setInputs: Map> +): HTMLElement | null { + const setEl = container.createDiv({ + cls: `workout-set state-${set.state}${isActive ? ' active' : ''}` + }); + + const setRow = setEl.createDiv({ cls: 'workout-set-main' }); + + // State icon + const iconEl = setRow.createSpan({ cls: 'workout-set-icon' }); + iconEl.textContent = Constants.STATE_ICONS[set.state]; + + // Set label ("Set 1", "Set 2", etc.) + const labelEl = setRow.createSpan({ cls: 'workout-set-label' }); + labelEl.textContent = `Set ${setIndex + 1}`; + + // Set parameters as inline chips + if (hasDisplayableSetParams(set)) { + const paramsEl = setRow.createSpan({ cls: 'workout-set-params' }); + const setParamInputs = new Map(); + const displayableParams = getDisplayableSetParams(set); + + for (const param of displayableParams) { + const input = renderParamElement( + paramsEl, + param, + workoutState, + (value) => callbacks.onSetParamChange(exerciseIndex, setIndex, param.key, value) + ); + if (input) setParamInputs.set(param.key, input); + } + + setInputs.set(setIndex, setParamInputs); } - return { container: exerciseEl, timerEl, inputs }; + // Timer display (right side of mainRow) + // Shown on mainRow if no sets or single set, otherwise on active set + // Priority order: completed > active > static target > pending placeholder + let timerEl: HTMLElement | null = null; + timerEl = setRow.createSpan({ cls: 'workout-set-timer' }); + + // Show recorded duration for completed sets + const recordedDuration = getSetRecordedTime(set); + + if ( workoutState === 'completed' && recordedDuration) { + timerEl.textContent = recordedDuration; + timerEl.createSpan({ cls: 'timer-indicator recorded', text: Constants.TIMER_ICONS['recorded'] }); + } + // Set currently active: show live timer (updates in real-time) + else if (isActive && timerState) { + // Get rest duration for timer logic + const restDurationStr = getSetRecordedRest(set); + + // In rest mode: show rest timer with phase-based color coding + if (timerState.isRestActive && timerState.restRemaining !== undefined) { + const restDuration = restDurationStr ? parseDurationToSeconds(restDurationStr) : 0; + const remaining = timerState.restRemaining; + + // Apply color phase classes based on rest progress + if (restDuration > 0) { + const restProgress = remaining / restDuration; + + if (restProgress > 0.66) { + // Green: 66-100% remaining + setEl.removeClass('rest-phase-yellow'); + setEl.removeClass('rest-phase-red'); + setEl.addClass('rest-phase-green'); + } else if (restProgress > 0.33) { + // Yellow: 33-66% remaining + setEl.removeClass('rest-phase-green'); + setEl.removeClass('rest-phase-red'); + setEl.addClass('rest-phase-yellow'); + } else { + // Red: 0-33% remaining + setEl.removeClass('rest-phase-green'); + setEl.removeClass('rest-phase-yellow'); + setEl.addClass('rest-phase-red'); + } + } + + if (remaining > 0) { + timerEl.textContent = formatDuration(remaining); + timerEl.addClass('rest'); + timerEl.createSpan({ cls: 'timer-indicator rest', text: Constants.TIMER_ICONS['count-down'] }); + } else { + // Rest time exceeded (overtime) + timerEl.textContent = formatDuration(Math.abs(remaining)); + timerEl.removeClass('rest'); + timerEl.addClass('rest-overtime'); + timerEl.createSpan({ cls: 'timer-indicator overtime', text: Constants.TIMER_ICONS['count-up'] }); + } + } else { + // Not in rest: show exercise timer (count-up or countdown) + updateExerciseTimer(timerEl, timerState, undefined); + } + } + else { + const setDurationStr = getSetDuration(set); + const setDuration = setDurationStr ? parseDurationToSeconds(setDurationStr) : 0; + if (setDuration != 0 ) { + timerEl.textContent = formatDuration(setDuration); + timerEl.createSpan({ cls: 'timer-indicator count-down', text: Constants.TIMER_ICONS['count-down'] }); + } else { + // No duration defined: show placeholder + timerEl.textContent = '--'; + } + } + + return timerEl; } -function renderExerciseControls( +/** + * Render workout control buttons for an active exercise. + * + * Displays buttons for: + * - Pause/Resume: Toggle workout timer + * - Skip: Mark exercise as skipped + * - + Set: Add additional set to exercise + * - + Rest: Add rest period after set (if restDuration defined) + * - [Next Set|Next|Done]: Finish current set and advance + * + * Button text adapts based on: + * - Rest state: "Start Next" during rest, otherwise "Next Set"/"Next"/"Done" + * - Position: "Next Set" for mid-exercise, "Next" for last set w/ more exercises, "Done" for final set + * + * Parameters: + * - exerciseEl: Exercise container to add controls to + * - exerciseIndex: Index of current exercise + * - setIndex: Index of current set + * - totalSets: Total number of sets in exercise + * - callbacks: User action handlers + * - restDuration: Default rest duration (if defined, show "+ Rest" button) + * - timerState: Current timer state (for rest detection) + * - totalExercises: Total exercises in workout (for button labeling) + */ +function renderSetControls( exerciseEl: HTMLElement, - index: number, + exerciseIndex: number, + setIndex: number, + totalSets: number, callbacks: WorkoutCallbacks, - restDuration?: number + restDuration?: number, + timerState?: TimerState | null, + totalExercises?: number ): void { const controlsEl = exerciseEl.createDiv({ cls: 'workout-exercise-controls' }); - // Pause/Resume button + // Pause/Resume toggle button const pauseBtn = controlsEl.createEl('button', { cls: 'workout-btn', text: 'Pause' }); pauseBtn.addEventListener('click', () => { if (pauseBtn.textContent === 'Pause') { @@ -148,36 +726,76 @@ function renderExerciseControls( } }); - // Skip button + // Skip button (marks exercise as skipped) const skipBtn = controlsEl.createEl('button', { cls: 'workout-btn', text: 'Skip' }); skipBtn.addEventListener('click', () => { - callbacks.onExerciseSkip(index); + callbacks.onExerciseSkip(exerciseIndex); }); - // Finish group container + // Group for finish buttons const finishGroup = controlsEl.createDiv({ cls: 'workout-btn-group' }); - // Add Set button + // Add Set button (to add more sets to exercise) const addSetBtn = finishGroup.createEl('button', { cls: 'workout-btn', text: '+ Set' }); addSetBtn.addEventListener('click', () => { - callbacks.onExerciseAddSet(index); + callbacks.onExerciseAddSet(exerciseIndex); }); - // Add Rest button (only if restDuration is defined) + // Add Rest button (only if restDuration defined in metadata) if (restDuration !== undefined) { const addRestBtn = finishGroup.createEl('button', { cls: 'workout-btn', text: '+ Rest' }); addRestBtn.addEventListener('click', () => { - callbacks.onExerciseAddRest(index); + callbacks.onExerciseAddRest(exerciseIndex); }); } - // Next button (finish current, move to next) - const nextBtn = finishGroup.createEl('button', { cls: 'workout-btn', text: 'Next' }); + // Determine next button text based on position and state + let nextBtnText: string; + if (timerState?.isRestActive) { + // During rest period, offer to start next + nextBtnText = 'Next'; + } else { + const isLastSet = setIndex === totalSets - 1; + const isLastExercise = typeof totalExercises === 'number' ? (exerciseIndex === totalExercises - 1) : false; + if (isLastSet && !isLastExercise) { + // Last set, but more exercises ahead + nextBtnText = 'Next'; + } else if (isLastSet && isLastExercise) { + // Final set of final exercise + nextBtnText = 'Done'; + } else { + // Not last set + nextBtnText = 'Next'; + } + } + + const nextBtn = finishGroup.createEl('button', { cls: 'workout-btn', text: nextBtnText }); nextBtn.addEventListener('click', () => { - callbacks.onExerciseFinish(index); + if (timerState?.isRestActive) { + // Currently in rest, end it and advance + callbacks.onRestEnd(exerciseIndex); + } else { + // Finishing a set, may start rest or advance + callbacks.onSetFinish(exerciseIndex, setIndex); + } }); } +/** + * Update exercise/set timer display during workout progress. + * + * Displays timer in two modes: + * - Countdown (targetDuration defined): Shows remaining time until target + * - Green icon ▼ when time remaining + * - Warning ⚠ + red "overtime" class when exceeded + * - Count-up (no targetDuration): Shows elapsed time from set start + * - Count-up icon ▲ to distinguish from countdown + * + * Parameters: + * - timerEl: Timer element to update + * - timerState: Current timer state with elapsed times + * - targetDuration: Target duration in seconds (if null/undefined, count-up mode) + */ export function updateExerciseTimer( timerEl: HTMLElement, timerState: TimerState, @@ -185,21 +803,39 @@ export function updateExerciseTimer( ): void { timerEl.empty(); - if (targetDuration !== undefined) { - // Countdown mode + if (targetDuration !== undefined && !timerState.isRestActive) { + // Countdown mode: show remaining time vs target const remaining = targetDuration - timerState.exerciseElapsed; if (remaining > 0) { + // Time remaining: show countdown with ▼ indicator timerEl.textContent = formatDuration(remaining); - timerEl.createSpan({ cls: 'timer-indicator count-down', text: ' ▼' }); + timerEl.createSpan({ cls: 'timer-indicator count-down', text: Constants.TIMER_ICONS['count-down'] }); } else { - // Overtime + // Overtime: show absolute value in red with warning icon timerEl.textContent = formatDuration(Math.abs(remaining)); timerEl.addClass('overtime'); - timerEl.createSpan({ cls: 'timer-indicator overtime', text: ' ⚠' }); + timerEl.createSpan({ cls: 'timer-indicator overtime', text: Constants.TIMER_ICONS['overtime'] }); } - } else { - // Count up mode + } + else if (targetDuration !== undefined && timerState.isRestActive) { + // Countdown mode: show remaining time vs target + const restElapsed = timerState.restElapsed? timerState.restElapsed : 0; + const remaining = targetDuration - restElapsed; + if (remaining > 0) { + // Time remaining: show countdown with ▼ indicator + timerEl.textContent = formatDuration(remaining); + timerEl.addClass('rest'); + timerEl.createSpan({ cls: 'timer-indicator rest', text: Constants.TIMER_ICONS['rest'] }); + } else { + // Overtime: show absolute value in red with warning icon + timerEl.textContent = formatDuration(Math.abs(remaining)); + timerEl.addClass('rest-overtime'); + timerEl.createSpan({ cls: 'timer-indicator rest-overtime', text: Constants.TIMER_ICONS['rest-overtime'] }); + } + } + else { + // Count-up mode: show elapsed time from start (no target limit) timerEl.textContent = formatDuration(timerState.exerciseElapsed); - timerEl.createSpan({ cls: 'timer-indicator count-up', text: ' ▲' }); + timerEl.createSpan({ cls: 'timer-indicator count-up', text: Constants.TIMER_ICONS['count-up'] }); } } diff --git a/src/renderer/header.test.ts b/src/renderer/header.test.ts new file mode 100644 index 0000000..105bcef --- /dev/null +++ b/src/renderer/header.test.ts @@ -0,0 +1,506 @@ +import { renderHeader, updateHeaderTimer } from './header'; +import { WorkoutMetadata, TimerState } from '../types'; +import * as exerciseParser from '../parser/exercise'; + +// Mock the exercise parser +jest.mock('../parser/exercise', () => ({ + formatDuration: jest.fn((seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }), + formatDurationHuman: jest.fn((seconds: number) => { + if (seconds < 60) return `${seconds}s`; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; + }) +})); + +// MockElement class to simulate DOM +class MockElement { + tag: string; + className: string = ''; + children: MockElement[] = []; + parent: MockElement | null = null; + listeners: { [key: string]: Function[] } = {}; + _textContent: string = ''; + styleMap: Map = new Map(); + attributes: Map = new Map(); + + constructor(tag: string) { + this.tag = tag; + } + + get textContent(): string { + let text = this._textContent; + for (const child of this.children) { + text += child.textContent; + } + return text; + } + + set textContent(value: string) { + this._textContent = value; + } + + createDiv(options?: { cls?: string; text?: string }): MockElement { + return this.createEl('div', options); + } + + createEl(tag: string, options?: { cls?: string; text?: string }): MockElement { + const el = new MockElement(tag); + el.parent = this; + if (options?.cls) el.className = options.cls; + if (options?.text) el.textContent = options.text; + this.children.push(el); + return el; + } + + createSpan(options?: { cls?: string; text?: string }): MockElement { + return this.createEl('span', options); + } + + setText(text: string): void { + this._textContent = text; + } + + style = { + setProperty: jest.fn() + }; + + querySelector(selector: string): MockElement | null { + if (selector.startsWith('.')) { + const className = selector.substring(1); + return this._findByClass(className); + } + return this._findByTag(selector); + } + + private _findByClass(className: string): MockElement | null { + if (this.className.split(' ').includes(className)) return this; + for (const child of this.children) { + const found = child._findByClass(className); + if (found) return found; + } + return null; + } + + private _findByTag(tagName: string): MockElement | null { + if (this.tag === tagName) return this; + for (const child of this.children) { + const found = child._findByTag(tagName); + if (found) return found; + } + return null; + } + + empty(): void { + this.children = []; + this._textContent = ''; + } + + addEventListener(event: string, handler: Function): void { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(handler); + } +} + +describe('renderHeader', () => { + let container: MockElement; + const baseMockMetadata: WorkoutMetadata = { + title: 'Test Workout', + state: 'planned', + startDate: undefined, + duration: undefined, + restDuration: undefined + }; + + beforeEach(() => { + container = new MockElement('div'); + jest.clearAllMocks(); + }); + + describe('Container structure', () => { + it('should create header element', () => { + renderHeader(container, baseMockMetadata, null, false); + expect(container.querySelector('.workout-header')).toBeTruthy(); + }); + + it('should append header to container', () => { + renderHeader(container, baseMockMetadata, null, false); + expect(container.children.length).toBe(1); + }); + + it('should use correct header class', () => { + const result = renderHeader(container, baseMockMetadata, null, false); + const header = container.querySelector('.workout-header'); + expect(header?.className).toBe('workout-header'); + }); + }); + + describe('Title rendering', () => { + it('should render title element', () => { + renderHeader(container, baseMockMetadata, null, false); + const title = container.querySelector('.workout-title'); + expect(title).toBeTruthy(); + }); + + it('should display workout title', () => { + renderHeader(container, baseMockMetadata, null, false); + const title = container.querySelector('.workout-title'); + expect(title?.textContent).toBe('Test Workout'); + }); + + it('should use default title if not provided', () => { + const metadata: WorkoutMetadata = { ...baseMockMetadata, title: '' }; + renderHeader(container, metadata, null, false); + const title = container.querySelector('.workout-title'); + expect(title?.textContent).toBe('Workout'); + }); + + it('should display custom titles', () => { + const metadata: WorkoutMetadata = { ...baseMockMetadata, title: 'Upper Body' }; + renderHeader(container, metadata, null, false); + const title = container.querySelector('.workout-title'); + expect(title?.textContent).toBe('Upper Body'); + }); + }); + + describe('Rest duration rendering', () => { + it('should not render rest duration if undefined', () => { + renderHeader(container, baseMockMetadata, null, false); + const restDuration = container.querySelector('.workout-rest-duration'); + expect(restDuration).toBeFalsy(); + }); + + it('should render rest duration when defined', () => { + const metadata: WorkoutMetadata = { ...baseMockMetadata, restDuration: 90 }; + renderHeader(container, metadata, null, false); + const restDuration = container.querySelector('.workout-rest-duration'); + expect(restDuration).toBeTruthy(); + }); + + it('should display formatted rest duration', () => { + const metadata: WorkoutMetadata = { ...baseMockMetadata, restDuration: 90 }; + renderHeader(container, metadata, null, false); + const restDuration = container.querySelector('.workout-rest-duration'); + expect(restDuration?.textContent).toContain('Rest:'); + }); + + it('should use formatDurationHuman for rest display', () => { + const metadata: WorkoutMetadata = { ...baseMockMetadata, restDuration: 120 }; + renderHeader(container, metadata, null, false); + expect(exerciseParser.formatDurationHuman).toHaveBeenCalledWith(120); + }); + }); + + describe('Timer container', () => { + it('should create timer container', () => { + renderHeader(container, baseMockMetadata, null, false); + const timerContainer = container.querySelector('.workout-header-timer'); + expect(timerContainer).toBeTruthy(); + }); + + it('should create timer element inside container', () => { + renderHeader(container, baseMockMetadata, null, false); + const timerContainer = container.querySelector('.workout-header-timer'); + const timer = timerContainer?.querySelector('span'); + expect(timer?.className).toContain('workout-timer'); + }); + }); + + describe('Timer display - Planned state', () => { + it('should display -- :-- for planned state with no timer', () => { + renderHeader(container, baseMockMetadata, null, false); + const timer = container.querySelector('.workout-timer'); + expect(timer?.textContent).toBe('--:--'); + }); + + it('should display -- :-- even with timer state if not running', () => { + const timerState: TimerState = { + workoutElapsed: 120, + exerciseElapsed: 30, + isRestActive: false, + restRemaining: 0 + }; + renderHeader(container, baseMockMetadata, timerState, false); + const timer = container.querySelector('.workout-timer'); + expect(timer?.textContent).toBe('--:--'); + }); + }); + + describe('Timer display - Started state with running timer', () => { + it('should display running timer when active', () => { + const timerState: TimerState = { + workoutElapsed: 125, + exerciseElapsed: 35, + isRestActive: false, + restRemaining: 0 + }; + const metadata: WorkoutMetadata = { ...baseMockMetadata, state: 'started' }; + renderHeader(container, metadata, timerState, true); + const timer = container.querySelector('.workout-timer'); + expect(timer?.textContent).toContain('Total:'); + }); + + it('should use formatDuration for running timer', () => { + const timerState: TimerState = { + workoutElapsed: 125, + exerciseElapsed: 35, + isRestActive: false, + restRemaining: 0 + }; + const metadata: WorkoutMetadata = { ...baseMockMetadata, state: 'started' }; + renderHeader(container, metadata, timerState, true); + expect(exerciseParser.formatDuration).toHaveBeenCalledWith(125); + }); + + it('should show count-up indicator when running', () => { + const timerState: TimerState = { + workoutElapsed: 60, + exerciseElapsed: 20, + isRestActive: false, + restRemaining: 0 + }; + const metadata: WorkoutMetadata = { ...baseMockMetadata, state: 'started' }; + const { timerEl } = renderHeader(container, metadata, timerState, true); + // Check that timer element contains Total text + expect(timerEl.textContent).toContain('Total:'); + }); + }); + + describe('Timer display - Completed state', () => { + it('should display recorded duration when completed', () => { + const metadata: WorkoutMetadata = { + ...baseMockMetadata, + state: 'completed', + duration: '15m 30s' + }; + renderHeader(container, metadata, null, false); + const timer = container.querySelector('.workout-timer'); + expect(timer?.textContent).toContain('15m 30s'); + }); + + it('should show check mark indicator when completed', () => { + const metadata: WorkoutMetadata = { + ...baseMockMetadata, + state: 'completed', + duration: '15m 30s' + }; + const { timerEl } = renderHeader(container, metadata, null, false); + // Timer element should contain duration text + expect(timerEl.textContent).toContain('15m 30s'); + }); + + it('should not display timer indicator if no duration recorded', () => { + const metadata: WorkoutMetadata = { + ...baseMockMetadata, + state: 'completed', + duration: undefined + }; + const { timerEl } = renderHeader(container, metadata, null, false); + const indicators = timerEl.querySelectorAll?.('.workout-timer-indicator') || []; + // Should have default --:-- with no indicator + expect(timerEl.textContent).toBe('--:--'); + }); + }); + + describe('Return values', () => { + it('should return titleEl and timerEl', () => { + const result = renderHeader(container, baseMockMetadata, null, false); + expect(result.titleEl).toBeDefined(); + expect(result.timerEl).toBeDefined(); + }); + + it('should return correct titleEl reference', () => { + const result = renderHeader(container, baseMockMetadata, null, false); + expect(result.titleEl.textContent).toBe('Test Workout'); + }); + + it('should return correct timerEl reference', () => { + const result = renderHeader(container, baseMockMetadata, null, false); + expect(result.timerEl.textContent).toBe('--:--'); + }); + + it('returned elements should be in DOM', () => { + const result = renderHeader(container, baseMockMetadata, null, false); + const header = container.querySelector('.workout-header'); + expect(header?.children).toContain(result.timerEl.parent); + }); + }); + + describe('Edge cases', () => { + it('should handle very long titles', () => { + const longTitle = 'A'.repeat(100); + const metadata: WorkoutMetadata = { ...baseMockMetadata, title: longTitle }; + renderHeader(container, metadata, null, false); + const title = container.querySelector('.workout-title'); + expect(title?.textContent).toBe(longTitle); + }); + + it('should handle special characters in title', () => { + const metadata: WorkoutMetadata = { + ...baseMockMetadata, + title: 'Work*out @#$%' + }; + renderHeader(container, metadata, null, false); + const title = container.querySelector('.workout-title'); + expect(title?.textContent).toBe('Work*out @#$%'); + }); + + it('should handle large rest duration', () => { + const metadata: WorkoutMetadata = { + ...baseMockMetadata, + restDuration: 3600 + }; + renderHeader(container, metadata, null, false); + const restDuration = container.querySelector('.workout-rest-duration'); + expect(restDuration).toBeTruthy(); + }); + + it('should handle zero rest duration', () => { + const metadata: WorkoutMetadata = { + ...baseMockMetadata, + restDuration: 0 + }; + // Falsy check - 0 is falsy, so rest duration shouldn't render + renderHeader(container, metadata, null, false); + const restDuration = container.querySelector('.workout-rest-duration'); + expect(restDuration).toBeFalsy(); + }); + }); +}); + +describe('updateHeaderTimer', () => { + let timerEl: MockElement; + + beforeEach(() => { + timerEl = new MockElement('span'); + timerEl.className = 'workout-timer'; + jest.clearAllMocks(); + }); + + describe('Timer update', () => { + it('should clear existing content', () => { + timerEl.textContent = 'Old Content'; + timerEl.createSpan({ text: 'Old Indicator' }); + + const timerState: TimerState = { + workoutElapsed: 120, + exerciseElapsed: 30, + isRestActive: false, + restRemaining: 0 + }; + + updateHeaderTimer(timerEl, timerState); + // After empty(), content should be replaced + expect(timerEl._textContent).not.toContain('Old'); + }); + + it('should display formatted elapsed time', () => { + const timerState: TimerState = { + workoutElapsed: 125, + exerciseElapsed: 35, + isRestActive: false, + restRemaining: 0 + }; + + updateHeaderTimer(timerEl, timerState); + expect(timerEl.textContent).toContain('Total:'); + expect(exerciseParser.formatDuration).toHaveBeenCalledWith(125); + }); + + it('should add count-up indicator', () => { + const timerState: TimerState = { + workoutElapsed: 60, + exerciseElapsed: 20, + isRestActive: false, + restRemaining: 0 + }; + + updateHeaderTimer(timerEl, timerState); + // After update, timer should have content + expect(timerEl.textContent).toBeTruthy(); + }); + + it('should update with different elapsed times', () => { + const timerState1: TimerState = { + workoutElapsed: 30, + exerciseElapsed: 10, + isRestActive: false, + restRemaining: 0 + }; + + updateHeaderTimer(timerEl, timerState1); + expect(exerciseParser.formatDuration).toHaveBeenCalledWith(30); + + jest.clearAllMocks(); + + const timerState2: TimerState = { + workoutElapsed: 300, + exerciseElapsed: 100, + isRestActive: false, + restRemaining: 0 + }; + + timerEl.empty(); + updateHeaderTimer(timerEl, timerState2); + expect(exerciseParser.formatDuration).toHaveBeenCalledWith(300); + }); + + it('should replace previous content completely', () => { + const timerState1: TimerState = { + workoutElapsed: 60, + exerciseElapsed: 20, + isRestActive: false, + restRemaining: 0 + }; + + updateHeaderTimer(timerEl, timerState1); + const firstChildren = timerEl.children.length; + + timerEl.empty(); + const timerState2: TimerState = { + workoutElapsed: 120, + exerciseElapsed: 40, + isRestActive: false, + restRemaining: 0 + }; + + updateHeaderTimer(timerEl, timerState2); + // Should have same number of children (text + indicator) + expect(timerEl.children.length).toBe(firstChildren); + }); + }); + + describe('Indicator styling', () => { + it('should use count-up class', () => { + const timerState: TimerState = { + workoutElapsed: 90, + exerciseElapsed: 25, + isRestActive: false, + restRemaining: 0 + }; + + updateHeaderTimer(timerEl, timerState); + const indicator = timerEl.querySelector('.workout-timer-indicator'); + expect(indicator?.className).toContain('count-up'); + }); + + it('should create up arrow indicator', () => { + const timerState: TimerState = { + workoutElapsed: 45, + exerciseElapsed: 15, + isRestActive: false, + restRemaining: 0 + }; + + updateHeaderTimer(timerEl, timerState); + const indicator = timerEl.querySelector('.workout-timer-indicator'); + expect(indicator?.textContent).toBe(' ▲'); + }); + }); +}); diff --git a/src/renderer/header.ts b/src/renderer/header.ts index 15a3ee1..a3145da 100644 --- a/src/renderer/header.ts +++ b/src/renderer/header.ts @@ -1,50 +1,103 @@ -import { WorkoutMetadata, TimerState } from '../types'; +/** + * Header rendering for workout blocks. + * + * Renders the top section of a workout display including: + * - Workout title/name + * - Rest duration setting (if defined) + * - Total elapsed timer (during active workouts) + * + * Key features: + * - renderHeader() creates the initial header structure with state-dependent timer display + * - updateHeaderTimer() efficiently updates the timer display during workout progress + * - Timer shows different info based on workout state (planned, started, completed) + */ + +import { Constants, WorkoutMetadata, TimerState } from '../types'; import { formatDuration, formatDurationHuman } from '../parser/exercise'; +/** + * Render the workout header section. + * + * Creates the header with: + * - Title/name (from metadata or default "Workout") + * - Rest duration display (if defined in metadata) + * - Total elapsed timer with state-dependent formatting + * + * Timer states: + * - completed: Shows recorded total duration with checkmark (✓) + * - running: Shows "Total: MM:SS" with count-up indicator (▲) + * - planned: Shows "--:--" placeholder + * + * Returns { titleEl, timerEl } for external updates during timer progress. + */ export function renderHeader( container: HTMLElement, metadata: WorkoutMetadata, timerState: TimerState | null, isTimerRunning: boolean ): { titleEl: HTMLElement; timerEl: HTMLElement } { + // Create header container const headerEl = container.createDiv({ cls: 'workout-header' }); + // Render workout title/name const titleEl = headerEl.createDiv({ cls: 'workout-title' }); titleEl.textContent = metadata.title || 'Workout'; - // Display rest duration if defined + // Display rest duration if defined in metadata if (metadata.restDuration) { const restDurationEl = headerEl.createDiv({ cls: 'workout-rest-duration' }); const formattedRest = formatDurationHuman(metadata.restDuration); restDurationEl.setText(`Rest: ${formattedRest}`); } + // Create timer container and element const timerContainer = headerEl.createDiv({ cls: 'workout-header-timer' }); const timerEl = timerContainer.createSpan({ cls: 'workout-timer' }); + // Display timer based on workout state if (metadata.state === 'completed' && metadata.duration) { - // Show recorded duration + // Completed: show recorded final duration with checkmark timerEl.textContent = metadata.duration; - timerEl.createSpan({ cls: 'workout-timer-indicator recorded', text: ' ✓' }); + timerEl.createSpan({ cls: 'workout-timer-indicator recorded', text: Constants.TIMER_ICONS['recorded'] }); } else if (isTimerRunning && timerState) { - // Show running timer + // Running: show total elapsed time count-up timerEl.textContent = `Total: ${formatDuration(timerState.workoutElapsed)}`; - timerEl.createSpan({ cls: 'workout-timer-indicator count-up', text: ' ▲' }); + timerEl.createSpan({ cls: 'workout-timer-indicator count-up', text: Constants.TIMER_ICONS['count-up'] }); } else if (metadata.state === 'planned') { + // Planned: show placeholder timerEl.textContent = '--:--'; } else { + // Other states: show placeholder timerEl.textContent = '--:--'; } + // Return elements for external access (used by renderWorkout for timer updates) return { titleEl, timerEl }; } +/** + * Update the header timer display during active workout. + * + * Called repeatedly during timer intervals to show updated elapsed time. + * Clears existing content and renders fresh timer display with: + * - Total elapsed time formatted as MM:SS + * - Count-up indicator (▲) to show timer is running + * + * Parameters: + * - timerEl: Header timer element to update + * - timerState: Current timer state with workoutElapsed in seconds + */ export function updateHeaderTimer( timerEl: HTMLElement, timerState: TimerState ): void { + // Clear previous timer display timerEl.empty(); + + // Display updated total elapsed time timerEl.textContent = `Total: ${formatDuration(timerState.workoutElapsed)}`; - timerEl.createSpan({ cls: 'workout-timer-indicator count-up', text: ' ▲' }); + + // Add count-up indicator to show timer actively running + timerEl.createSpan({ cls: 'workout-timer-indicator count-up', text: Constants.TIMER_ICONS['count-up'] }); } diff --git a/src/renderer/index.test.ts b/src/renderer/index.test.ts new file mode 100644 index 0000000..641c657 --- /dev/null +++ b/src/renderer/index.test.ts @@ -0,0 +1,925 @@ +import { renderWorkout } from './index'; +import { ParsedWorkout, WorkoutCallbacks, TimerState } from '../types'; +import { TimerManager } from '../timer/manager'; + +// Mock renderer modules +jest.mock('./header', () => ({ + renderHeader: jest.fn(() => ({ + titleEl: { textContent: 'Test' }, + timerEl: { textContent: '--:--' } + })), + updateHeaderTimer: jest.fn() +})); + +jest.mock('./exercise', () => ({ + renderExercise: jest.fn(() => ({ + container: { tag: 'div' }, + timerEl: { textContent: '--:--' }, + setTimerEl: { textContent: '--:--' }, + inputs: new Map(), + setInputs: new Map() + })), + updateExerciseTimer: jest.fn() +})); + +jest.mock('./controls', () => ({ + renderWorkoutControls: jest.fn() +})); + +jest.mock('./emptyState', () => ({ + renderEmptyState: jest.fn() +})); + +// Mock TimerManager +jest.mock('../timer/manager', () => ({ + TimerManager: jest.fn().mockImplementation(() => ({ + isTimerRunning: jest.fn(() => false), + getTimerState: jest.fn(() => null), + getActiveExerciseIndex: jest.fn(() => -1), + getActiveSetIndex: jest.fn(() => -1), + subscribe: jest.fn(), + startTimer: jest.fn(), + stopTimer: jest.fn() + })) +})); + +// MockElement class to simulate DOM +class MockElement { + tag: string; + className: string = ''; + children: MockElement[] = []; + parent: MockElement | null = null; + listeners: { [key: string]: Function[] } = {}; + _textContent: string = ''; + styleMap: Map = new Map(); + attributes: Map = new Map(); + isConnected: boolean = true; + + constructor(tag: string) { + this.tag = tag; + } + + get textContent(): string { + let text = this._textContent; + for (const child of this.children) { + text += child.textContent; + } + return text; + } + + set textContent(value: string) { + this._textContent = value; + } + + createDiv(options?: { cls?: string; text?: string }): MockElement { + return this.createEl('div', options); + } + + createEl(tag: string, options?: { cls?: string; text?: string }): MockElement { + const el = new MockElement(tag); + el.parent = this; + if (options?.cls) el.className = options.cls; + if (options?.text) el.textContent = options.text; + this.children.push(el); + return el; + } + + createSpan(options?: { cls?: string; text?: string }): MockElement { + return this.createEl('span', options); + } + + style = { + setProperty: jest.fn() + }; + + querySelector(selector: string): MockElement | null { + if (selector.startsWith('.')) { + const className = selector.substring(1); + return this._findByClass(className); + } + return this._findByTag(selector); + } + + querySelectorAll(selector: string): MockElement[] { + if (selector.startsWith('.')) { + const className = selector.substring(1); + return this._findAllByClass(className); + } + return this._findAllByTag(selector); + } + + private _findByClass(className: string): MockElement | null { + if (this.className.split(' ').includes(className)) return this; + for (const child of this.children) { + const found = child._findByClass(className); + if (found) return found; + } + return null; + } + + private _findAllByClass(className: string): MockElement[] { + const results: MockElement[] = []; + if (this.className.split(' ').includes(className)) { + results.push(this); + } + for (const child of this.children) { + results.push(...child._findAllByClass(className)); + } + return results; + } + + private _findByTag(tagName: string): MockElement | null { + if (this.tag === tagName) return this; + for (const child of this.children) { + const found = child._findByTag(tagName); + if (found) return found; + } + return null; + } + + private _findAllByTag(tagName: string): MockElement[] { + const results: MockElement[] = []; + if (this.tag === tagName) { + results.push(this); + } + for (const child of this.children) { + results.push(...child._findAllByTag(tagName)); + } + return results; + } + + empty(): void { + this.children = []; + this._textContent = ''; + } + + contains(el: MockElement): boolean { + for (const child of this.children) { + if (child === el) return true; + if (child.contains(el)) return true; + } + return false; + } + + addEventListener(event: string, handler: Function): void { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(handler); + } +} + +describe('renderWorkout', () => { + let containerElement: MockElement; + let timerManager: any; + let mockCallbacks: WorkoutCallbacks; + const mockWorkout: ParsedWorkout = { + metadata: { + title: 'Test Workout', + state: 'planned' + }, + exercises: [ + { + name: 'Push Ups', + state: 'pending', + params: [], + sets: [{ state: 'pending', params: [], lineIndex: 1 }], + lineIndex: 0 + } + ], + rawLines: [], + metadataEndIndex: -1 + }; + + beforeEach(() => { + containerElement = new MockElement('div'); + timerManager = new (TimerManager as any)(); + mockCallbacks = { + onParamChange: jest.fn(), + onStartWorkout: jest.fn(), + onExerciseFinish: jest.fn(), + onFlushChanges: jest.fn(), + onAddSample: jest.fn(), + onRestEnd: jest.fn(), + onFinishWorkout: jest.fn(), + onSetFinish: jest.fn(), + onRestStart: jest.fn(), + onExerciseSkip: jest.fn(), + }; + jest.clearAllMocks(); + }); + + describe('Container initialization', () => { + it('should clear existing content', () => { + containerElement.createDiv({ text: 'Old Content' }); + expect(containerElement.children.length).toBe(1); + + renderWorkout({ + containerElement, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + // After render, old content should be gone + expect(containerElement.children.length).toBeGreaterThanOrEqual(1); + }); + + it('should create workout container', () => { + renderWorkout({ + containerElement, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = containerElement.querySelector('.workout-container'); + expect(container).toBeTruthy(); + }); + + it('should add state class to container', () => { + renderWorkout({ + containerElement, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = containerElement.querySelector('.workout-container'); + expect(container?.className).toContain('state-planned'); + }); + + it('should add correct state class for different states', () => { + const completedWorkout: ParsedWorkout = { + ...mockWorkout, + metadata: { ...mockWorkout.metadata, state: 'completed' } + }; + + renderWorkout({ + containerElement, + parsed: completedWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = containerElement.querySelector('.workout-container'); + expect(container?.className).toContain('state-completed'); + }); + }); + + describe('Header rendering', () => { + it('should render header', () => { + const { renderHeader } = require('./header'); + renderWorkout({ + containerElement, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(renderHeader).toHaveBeenCalled(); + }); + + it('should pass workout metadata to header', () => { + const { renderHeader } = require('./header'); + renderWorkout({ + containerElement, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(renderHeader).toHaveBeenCalledWith( + expect.any(MockElement), + mockWorkout.metadata, + null, + false + ); + }); + }); + + describe('Empty state rendering', () => { + it('should render empty state for empty planned workout', () => { + const { renderEmptyState } = require('./emptyState'); + const emptyWorkout: ParsedWorkout = { + metadata: { title: 'Empty', state: 'planned' }, + exercises: [], + rawLines: [], + metadataEndIndex: -1 + }; + + renderWorkout({ + containerElement, + parsed: emptyWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(renderEmptyState).toHaveBeenCalledWith( + expect.any(MockElement), + mockCallbacks.onAddSample + ); + }); + + it('should not render empty state for non-empty workout', () => { + const { renderEmptyState } = require('./emptyState'); + renderWorkout({ + containerElement, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(renderEmptyState).not.toHaveBeenCalled(); + }); + + it('should not render empty state for completed empty workout', () => { + const { renderEmptyState } = require('./emptyState'); + const emptyCompletedWorkout: ParsedWorkout = { + metadata: { title: 'Empty', state: 'completed' }, + exercises: [], + rawLines: [], + metadataEndIndex: -1 + }; + + renderWorkout({ + containerElement, + parsed: emptyCompletedWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(renderEmptyState).not.toHaveBeenCalled(); + }); + }); + + describe('Exercise rendering', () => { + it('should render exercises container', () => { + renderWorkout({ + containerElement, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = containerElement.querySelector('.workout-exercises'); + expect(container).toBeTruthy(); + }); + + it('should render each exercise', () => { + const { renderExercise } = require('./exercise'); + renderWorkout({ + containerElement, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(renderExercise).toHaveBeenCalled(); + }); + + it('should render multiple exercises', () => { + const { renderExercise } = require('./exercise'); + const multiExerciseWorkout: ParsedWorkout = { + metadata: { title: 'Multi', state: 'planned' }, + exercises: [ + { + name: 'Exercise 1', + state: 'pending', + params: [], + sets: [{ state: 'pending', params: [], lineIndex: 1 }], + lineIndex: 0 + }, + { + name: 'Exercise 2', + state: 'pending', + params: [], + sets: [{ state: 'pending', params: [], lineIndex: 2 }], + lineIndex: 1 + } + ], + rawLines: [], + metadataEndIndex: -1 + }; + + renderWorkout({ + containerElement, + parsed: multiExerciseWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(renderExercise).toHaveBeenCalledTimes(2); + }); + + it('should set max-name-chars CSS variable', () => { + renderWorkout({ + containerElement, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const exercisesContainer = containerElement.querySelector('.workout-exercises'); + expect((exercisesContainer?.style.setProperty as jest.Mock).mock.calls.some( + call => call[0] === '--max-name-chars' + )).toBe(true); + }); + }); + + describe('Controls rendering', () => { + it('should render workout controls', () => { + const { renderWorkoutControls } = require('./controls'); + renderWorkout({ + containerElement, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(renderWorkoutControls).toHaveBeenCalled(); + }); + + it('should pass state to controls', () => { + const { renderWorkoutControls } = require('./controls'); + renderWorkout({ + containerElement, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(renderWorkoutControls).toHaveBeenCalledWith( + expect.any(MockElement), + 'planned', + mockCallbacks, + mockWorkout + ); + }); + }); + + describe('Focus event handling', () => { + it('should add focusout listener to container', () => { + renderWorkout({ + containerElement, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = containerElement.querySelector('.workout-container') as MockElement; + expect(container?.listeners['focusout']).toBeDefined(); + }); + + it('should call onFlushChanges when focus leaves container', () => { + renderWorkout({ + containerElement, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = containerElement.querySelector('.workout-container') as MockElement; + const focusoutEvent = new Event('focusout') as any; + focusoutEvent.relatedTarget = null; + + if (container?.listeners['focusout']) { + for (const handler of container.listeners['focusout']) { + handler(focusoutEvent); + } + } + + expect(mockCallbacks.onFlushChanges).toHaveBeenCalled(); + }); + + it('should not call onFlushChanges when focus moves within container', () => { + renderWorkout({ + containerElement, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = containerElement.querySelector('.workout-container') as MockElement; + const targetEl = new MockElement('input'); + container.children.push(targetEl); + + const focusoutEvent = new Event('focusout') as any; + focusoutEvent.relatedTarget = targetEl; + + mockCallbacks.onFlushChanges.mockClear(); + + if (container?.listeners['focusout']) { + for (const handler of container.listeners['focusout']) { + handler(focusoutEvent); + } + } + + expect(mockCallbacks.onFlushChanges).not.toHaveBeenCalled(); + }); + }); + + describe('Timer subscription', () => { + it('should handle planned state', () => { + renderWorkout({ + containerElement, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = containerElement.querySelector('.workout-container'); + expect(container?.className).toContain('state-planned'); + }); + + it('should handle started state', () => { + const startedWorkout: ParsedWorkout = { + ...mockWorkout, + metadata: { ...mockWorkout.metadata, state: 'started' } + }; + + renderWorkout({ + containerElement, + parsed: startedWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = containerElement.querySelector('.workout-container'); + expect(container?.className).toContain('state-started'); + }); + + it('should handle completed state', () => { + const completedWorkout: ParsedWorkout = { + ...mockWorkout, + metadata: { ...mockWorkout.metadata, state: 'completed' } + }; + + renderWorkout({ + containerElement, + parsed: completedWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = containerElement.querySelector('.workout-container'); + expect(container?.className).toContain('state-completed'); + }); + }); + + describe('Timer subscription', () => { + it('should subscribe to timer when workout is running', () => { + const mockTimerManager = { + isTimerRunning: jest.fn(() => true), + getTimerState: jest.fn(() => null), + getActiveExerciseIndex: jest.fn(() => 0), + getActiveSetIndex: jest.fn(() => 0), + subscribe: jest.fn(), + startTimer: jest.fn(), + stopTimer: jest.fn() + } as any; + + const parsedWorkout = { + metadata: { + title: 'Timer Workout', + state: 'started', + startDate: '2026-01-08 15:45', + duration: '10m' + }, + exercises: [ + { + name: 'Exercise 1', + state: 'in-progress', + params: [], + sets: [{ state: 'in-progress', params: [] }], + targetDuration: 300, + lineIndex: 0 + } + ] + }; + + renderWorkout({ + containerElement, + parsed: parsedWorkout as any, + callbacks: mockCallbacks, + workoutId: 'test-workout:0', + timerManager: mockTimerManager + }); + + expect(mockTimerManager.subscribe).toHaveBeenCalled(); + }); + + it('should handle timer callback updates', () => { + const mockTimerManager = { + isTimerRunning: jest.fn(() => true), + getTimerState: jest.fn(() => null), + getActiveExerciseIndex: jest.fn(() => 0), + getActiveSetIndex: jest.fn(() => 0), + subscribe: jest.fn(), + startTimer: jest.fn(), + stopTimer: jest.fn() + } as any; + + const parsedWorkout = { + metadata: { + title: 'Timer Workout', + state: 'started', + startDate: '2026-01-08 15:45', + duration: '10m' + }, + exercises: [ + { + name: 'Exercise 1', + state: 'in-progress', + params: [], + sets: [{ state: 'in-progress', params: [] }], + targetDuration: 300, + lineIndex: 0 + } + ] + }; + + renderWorkout({ + containerElement, + parsed: parsedWorkout as any, + callbacks: mockCallbacks, + workoutId: 'test-workout:0', + timerManager: mockTimerManager + }); + + // Get the subscription callback that was registered + const subscribeCall = mockTimerManager.subscribe.mock.calls[0]; + const timerCallback = subscribeCall[1]; + + // Simulate timer state update + const timerState: TimerState = { + workoutElapsed: 100, + exerciseElapsed: 50, + isOvertime: false, + isRestActive: false, + restRemaining: 0 + }; + + const { updateHeaderTimer } = require('./header'); + const { updateExerciseTimer } = require('./exercise'); + timerCallback(timerState); + + // Verify header timer was updated + expect(updateHeaderTimer).toHaveBeenCalled(); + expect(updateExerciseTimer).toHaveBeenCalled(); + }); + + it('should not subscribe to timer when workout is not running', () => { + const mockTimerManager = { + isTimerRunning: jest.fn(() => false), + getTimerState: jest.fn(() => null), + getActiveExerciseIndex: jest.fn(() => -1), + getActiveSetIndex: jest.fn(() => -1), + subscribe: jest.fn(), + startTimer: jest.fn(), + stopTimer: jest.fn() + } as any; + + const parsedWorkout = { + metadata: { + title: 'Planned Workout', + state: 'planned', + startDate: '2026-01-08 15:45', + duration: '10m' + }, + exercises: [ + { + name: 'Exercise 1', + state: 'pending', + params: [], + sets: [{ state: 'pending', params: [] }], + lineIndex: 0 + } + ] + }; + + renderWorkout({ + containerElement, + parsed: parsedWorkout as any, + callbacks: mockCallbacks, + workoutId: 'test-workout:0', + timerManager: mockTimerManager + }); + + // Should not have called subscribe + expect(mockTimerManager.subscribe).not.toHaveBeenCalled(); + }); + + it('should handle stale render detection', () => { + const mockTimerManager = { + isTimerRunning: jest.fn(() => true), + getTimerState: jest.fn(() => null), + getActiveExerciseIndex: jest.fn(() => 0), + getActiveSetIndex: jest.fn(() => 0), + subscribe: jest.fn(), + startTimer: jest.fn(), + stopTimer: jest.fn() + } as any; + + const parsedWorkout = { + metadata: { + title: 'Timer Workout', + state: 'started', + startDate: '2026-01-08 15:45', + duration: '10m' + }, + exercises: [ + { + name: 'Exercise 1', + state: 'in-progress', + params: [], + sets: [{ state: 'in-progress', params: [] }], + targetDuration: 300, + lineIndex: 0 + }, + { + name: 'Exercise 2', + state: 'pending', + params: [], + sets: [{ state: 'pending', params: [] }], + lineIndex: 1 + } + ] + }; + + renderWorkout({ + containerElement, + parsed: parsedWorkout as any, + callbacks: mockCallbacks, + workoutId: 'test-workout:0', + timerManager: mockTimerManager + }); + + // Get the subscription callback + const subscribeCall = mockTimerManager.subscribe.mock.calls[0]; + const timerCallback = subscribeCall[1]; + + // Simulate active index change - should trigger stale detection + mockTimerManager.getActiveExerciseIndex.mockReturnValue(1); + + const timerState: TimerState = { + workoutElapsed: 100, + exerciseElapsed: 50, + isOvertime: false, + isRestActive: false, + restRemaining: 0 + }; + + // This should return early due to stale index + timerCallback(timerState); + + // onRestEnd should not be called + expect(mockCallbacks.onRestEnd).not.toHaveBeenCalled(); + }); + + it('should handle rest completion auto-advance', () => { + const mockTimerManager = { + isTimerRunning: jest.fn(() => true), + getTimerState: jest.fn(() => null), + getActiveExerciseIndex: jest.fn(() => 0), + getActiveSetIndex: jest.fn(() => 1), + subscribe: jest.fn(), + startTimer: jest.fn(), + stopTimer: jest.fn() + } as any; + + const parsedWorkout = { + metadata: { + title: 'Timer Workout', + state: 'started', + startDate: '2026-01-08 15:45', + duration: '10m' + }, + exercises: [ + { + name: 'Exercise 1', + state: 'in-progress', + params: [], + sets: [ + { state: 'completed', params: [] }, + { state: 'in-progress', params: [{ key: 'Rest', value: '60s', editable: true, unit: '' }] } + ], + lineIndex: 0 + }, + { + name: 'Exercise 2', + state: 'pending', + params: [], + sets: [{ state: 'pending', params: [] }], + lineIndex: 1 + } + ] + }; + + renderWorkout({ + containerElement, + parsed: parsedWorkout as any, + callbacks: mockCallbacks, + workoutId: 'test-workout:0', + timerManager: mockTimerManager + }); + + // Get the subscription callback + const subscribeCall = mockTimerManager.subscribe.mock.calls[0]; + expect(subscribeCall).toBeDefined(); + + const timerCallback = subscribeCall[1]; + + // Simulate timer state update + const timerState: TimerState = { + workoutElapsed: 200, + exerciseElapsed: 100, + isOvertime: false, + isRestActive: true, + restRemaining: 0 + }; + + // Call the timer callback + timerCallback(timerState); + + // Verify onRestEnd was called to auto-advance + expect(mockCallbacks.onRestEnd).toHaveBeenCalledWith(0); + }); + }); + + describe('Edge cases', () => { + it('should handle empty exercise list', () => { + const emptyWorkout: ParsedWorkout = { + metadata: { title: 'Empty', state: 'planned' }, + exercises: [], + rawLines: [], + metadataEndIndex: -1 + }; + + renderWorkout({ + containerElement, + parsed: emptyWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(containerElement.children.length).toBeGreaterThan(0); + }); + + it('should handle single exercise', () => { + renderWorkout({ + containerElement, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(containerElement.children.length).toBeGreaterThan(0); + }); + + it('should handle very long workout title', () => { + const longTitleWorkout: ParsedWorkout = { + metadata: { + title: 'A'.repeat(500), + state: 'planned' + }, + exercises: mockWorkout.exercises, + rawLines: [], + metadataEndIndex: -1 + }; + + renderWorkout({ + containerElement, + parsed: longTitleWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(containerElement.children.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/renderer/index.ts b/src/renderer/index.ts index 3e140d4..25f2d5d 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -1,3 +1,20 @@ +/** + * Main rendering orchestration for workout blocks. + * + * Responsibilities: + * - Initialize and render the complete UI (header, exercises, controls) + * - Manage timer subscriptions and state updates + * - Detect stale renders and prevent duplicate updates + * - Auto-advance to next exercise on rest completion + * - Handle focus-out events to flush pending changes + * + * Architecture: + * - renderWorkout() is the entry point, receives parsed data and callbacks + * - Delegates to specialized renderers (header, exercise, controls, emptyState) + * - Subscribes to timer updates only if workout is active + * - Stale render detection via activeIndex tracking + */ + import { ParsedWorkout, WorkoutCallbacks, TimerState } from '../types'; import { renderHeader, updateHeaderTimer } from './header'; import { renderExercise, updateExerciseTimer, ExerciseElements } from './exercise'; @@ -5,31 +22,61 @@ import { renderWorkoutControls } from './controls'; import { renderEmptyState } from './emptyState'; import { TimerManager } from '../timer/manager'; +/** + * Context object passed to renderWorkout containing all necessary data and callbacks. + * + * Properties: + * - containerElement: DOM element where content will be rendered + * - parsed: ParsedWorkout data structure with metadata and exercises + * - callbacks: User action handlers (set completion, timer events, etc.) + * - workoutId: Unique identifier for this workout (used to track timer state) + * - timerManager: Manages timers for all active workouts + */ export interface RendererContext { - el: HTMLElement; + containerElement: HTMLElement; parsed: ParsedWorkout; callbacks: WorkoutCallbacks; workoutId: string; timerManager: TimerManager; } +/** + * Render a complete workout UI with all sections and timer subscriptions. + * + * Flow: + * 1. Clear existing content and create container + * 2. Check if workout has active timer + * 3. Render header with metadata and timer display + * 4. Handle empty state if no exercises (planned workout) + * 5. Render each exercise with current timer state and set information + * 6. Render workout-level controls (Start, Mark Complete, etc.) + * 7. Subscribe to timer updates if workout is active + * 8. Set up focus-out handler to flush pending changes + * + * Key Logic: + * - Stale render detection: tracks initialActiveIndex to detect when UI becomes stale + * - Only processes timer updates if activeIndex hasn't changed + * - Auto-advance on rest completion checks multiple conditions + * - Updates header timer separately from exercise/set timers + */ export function renderWorkout(ctx: RendererContext): void { - const { el, parsed, callbacks, workoutId, timerManager } = ctx; + const { containerElement, parsed, callbacks, workoutId, timerManager } = ctx; - // Clear existing content - el.empty(); + // Clear previous content + containerElement.empty(); - const container = el.createDiv({ + const container = containerElement.createDiv({ cls: `workout-container state-${parsed.metadata.state}` }); + // Check if timer is running and get current active indices const isTimerRunning = timerManager.isTimerRunning(workoutId); const timerState = timerManager.getTimerState(workoutId); const initialActiveIndex = isTimerRunning ? timerManager.getActiveExerciseIndex(workoutId) : -1; - // Render header + // Render header section with metadata const { timerEl: headerTimerEl } = renderHeader( container, parsed.metadata, @@ -37,97 +84,162 @@ export function renderWorkout(ctx: RendererContext): void { isTimerRunning ); - // Check if empty workout - show "Add Sample Workout" button + // Handle empty state: show sample workout prompt if no exercises if (parsed.exercises.length === 0 && parsed.metadata.state === 'planned') { renderEmptyState(container, callbacks.onAddSample); return; } - // Render exercises + // Render all exercises const exerciseElements: ExerciseElements[] = []; const exercisesContainer = container.createDiv({ cls: 'workout-exercises' }); - // Calculate max name length for alignment + // Set CSS variable for name alignment (used by CSS for columnar layout) const maxNameLength = Math.max(...parsed.exercises.map(e => e.name.length)); - // Approximate character width (will be refined by CSS) exercisesContainer.style.setProperty('--max-name-chars', String(maxNameLength)); + // Use callbacks directly - rest logic handled in callbacks + const exerciseCallbacks = callbacks; + for (let i = 0; i < parsed.exercises.length; i++) { const exercise = parsed.exercises[i]; if (!exercise) continue; + // Determine if this exercise is currently active const isActive = i === initialActiveIndex; + const activeSetIndex = isActive ? timerManager.getActiveSetIndex(workoutId) : -1; + const elements = renderExercise( exercisesContainer, exercise, i, isActive, + activeSetIndex, isActive ? timerState : null, - callbacks, + exerciseCallbacks, parsed.metadata.state, - parsed.metadata.restDuration + parsed.metadata.restDuration, + parsed.exercises.length // totalExercises ); exerciseElements.push(elements); } - // Render workout-level controls + // Render workout-level controls (Start, Mark Complete, etc.) renderWorkoutControls(container, parsed.metadata.state, callbacks, parsed); // Flush pending changes when focus leaves the workout container + // This ensures all edits are saved before the user moves away container.addEventListener('focusout', (e) => { const relatedTarget = e.relatedTarget as HTMLElement | null; - // Only flush if focus is leaving the container entirely + // Only flush if focus is leaving the container entirely (not moving within it) if (!relatedTarget || !container.contains(relatedTarget)) { callbacks.onFlushChanges(); } }); - // Subscribe to timer updates if workout is in progress + // Subscribe to timer updates and handle state changes if (isTimerRunning) { - // Track the active index at render time to detect when it changes + // Track initial active index to detect stale renders + // When Obsidian re-renders a code block, the new render will get different indices let lastKnownActiveIndex = initialActiveIndex; - // Flag to prevent multiple auto-advances from this render instance + // Prevent multiple auto-advances from the same render instance let hasAutoAdvanced = false; + let unsubscribe: () => void; + + unsubscribe = timerManager.subscribe(workoutId, (state: TimerState) => { + // Stale render detection: if the container is no longer in the DOM, unsubscribe and stop. + // This prevents updates from old renders after a re-render, which can cause race conditions + // when the app is backgrounded and then brought back to the foreground. + if (!container.isConnected) { + if (unsubscribe) unsubscribe(); + return; + } - timerManager.subscribe(workoutId, (state: TimerState) => { - // Update header timer + // Update the header timer display updateHeaderTimer(headerTimerEl, state); - // Get the CURRENT active index from the timer manager (not the stale one) + // Get current active index from timer manager (not stale capture) const currentActiveIndex = timerManager.getActiveExerciseIndex(workoutId); + const currentSetActiveIndex = timerManager.getActiveSetIndex(workoutId); + - // If the active index changed, this render instance is stale - // Don't process updates - a new render will take over + // Stale render detection: if active index changed, a new render is handling updates + // Stop processing - this render is obsolete if (currentActiveIndex !== lastKnownActiveIndex) { + console.log('Stale render detected. Current active index:', currentActiveIndex, 'Last known active index:', lastKnownActiveIndex); + if (unsubscribe) unsubscribe(); return; } - // Update active exercise timer + // Update the active exercise's timer display const activeElements = exerciseElements[currentActiveIndex]; const activeExercise = parsed.exercises[currentActiveIndex]; + const activeSet = activeExercise?.sets[currentSetActiveIndex]; + + if (activeExercise) { + // Update set timer if exercise has multisets + if (activeElements?.setTimerEl && !state.isRestActive) { + console.log(`[active] Active Exercise: [${currentActiveIndex}] ${activeExercise.name}, Active Set: [${currentSetActiveIndex}] ${activeSet?.lineIndex}`) + updateExerciseTimer( + activeElements.setTimerEl, + state, + activeSet?.targetDuration // Set timers are count-up, not countdown + ); + } + // Update exercise timer if exercise has multiset and is in rest + else if (activeElements?.setTimerEl && state.isRestActive) { + console.log(`[rest] Active Exercise: [${currentActiveIndex}] ${activeExercise.name}, Active Set: [${currentSetActiveIndex}] ${activeSet?.lineIndex}`) + updateExerciseTimer( + activeElements.setTimerEl, + state, + activeSet?.targetRest // Set timers are count-up, not countdown + ); + } + else if (activeElements?.timerEl && !state.isRestActive) { + // Update exercise timer while active if not multiset + console.log(`[active] Active Exercise: [${currentActiveIndex}] ${activeExercise.name}`); + updateExerciseTimer( + activeElements.timerEl, + state, + activeExercise.sets[0]?.targetDuration + ); + } + else if (activeElements?.timerEl && state.isRestActive) { + // Update exercise timer while rest if not multiset + console.log(`[rest] Active Exercise: [${currentActiveIndex}] ${activeExercise.name}`); + updateExerciseTimer( + activeElements.timerEl, + state, + activeExercise.sets[0]?.targetRest + ); + } - if (activeElements?.timerEl && activeExercise) { - updateExerciseTimer( - activeElements.timerEl, - state, - activeExercise.targetDuration - ); - - // Check for auto-advance on countdown completion - // Only trigger once per render instance - if (!hasAutoAdvanced && activeExercise.targetDuration !== undefined) { - if (state.exerciseElapsed >= activeExercise.targetDuration) { - hasAutoAdvanced = true; - // Auto-advance to next exercise - callbacks.onExerciseFinish(currentActiveIndex); - } + // Auto-advance to next exercise when rest completes + // Conditions: (1) on last set, (2) not last exercise, (3) rest has an editable bracket + const isLastSet = activeExercise.sets.length > 0 && + (timerManager.getActiveSetIndex(workoutId) === activeExercise.sets.length - 1); + const isLastExercise = currentActiveIndex === parsed.exercises.length - 1; + + const restParam = activeSet?.params.find(p => p.key.toLowerCase() === 'rest') || + activeExercise.params.find(p => p.key.toLowerCase() === 'rest'); + const isRestEditable = restParam?.editable ?? false; + + if ( + isLastSet && !isLastExercise && state.isRestActive && + typeof state.restRemaining === 'number' && state.restRemaining <= 0 && !hasAutoAdvanced && + isRestEditable + ) { + hasAutoAdvanced = true; + // Trigger advance to next exercise + callbacks.onRestEnd(currentActiveIndex); } } }); } } +// Re-export specialized rendering functions for header (and update functions) export { renderHeader, updateHeaderTimer } from './header'; export { renderExercise, updateExerciseTimer } from './exercise'; export { renderWorkoutControls } from './controls'; diff --git a/src/serializer.test.ts b/src/serializer.test.ts new file mode 100644 index 0000000..d8a35f1 --- /dev/null +++ b/src/serializer.test.ts @@ -0,0 +1,532 @@ +import { + serializeWorkout, + updateParamValue, + updateSetParamValue, + updateExerciseState, + updateSetState, + lockAllFields, + addRest, + addSet, + setRecordedDuration, + setSetRecordedDuration, + updateSetRestDuration, + createSampleWorkout, + serializeWorkoutAsTemplate +} from './serializer'; +import { ExerciseState } from './types'; +import { parseWorkout } from './parser/index'; + +describe('serializeWorkout', () => { + it('should serialize basic workout', () => { + const source = 'title: Test\nstate: planned\n---\n- [ ] Exercise\n - [ ] | Weight: [100] kg'; + const parsed = parseWorkout(source); + const serialized = serializeWorkout(parsed); + + expect(serialized).toContain('title: Test'); + expect(serialized).toContain('state: planned'); + expect(serialized).toContain('---'); + expect(serialized).toContain('- [ ] Exercise'); + expect(serialized).toContain(' - [ ] | Weight: [100] kg'); + }); + + it('should roundtrip parse and serialize', () => { + const source = 'title: Full Body\nstate: completed\n---\n- [x] Bench\n - [x] | Weight: 100 kg | Reps: 8'; + const parsed = parseWorkout(source); + const serialized = serializeWorkout(parsed); + const reparsed = parseWorkout(serialized); + + expect(reparsed.metadata.title).toBe(parsed.metadata.title); + expect(reparsed.exercises).toHaveLength(parsed.exercises.length); + expect(reparsed.exercises[0].name).toBe(parsed.exercises[0].name); + }); +}); + +describe('updateParamValue', () => { + it('should update existing exercise param', () => { + const source = '---\n- [ ] Bench | Weight: [100] kg | Reps: [10]\n - [ ] | Weight: [100] kg'; + const parsed = parseWorkout(source); + const updated = updateParamValue(parsed, 0, 'Weight', '110'); + + expect(updated.exercises[0].params.find(p => p.key === 'Weight')?.value).toBe('110'); + }); + + it('should only update if param exists', () => { + const source = '---\n- [ ] Exercise | Weight: [50] kg\n - [ ] |'; + const parsed = parseWorkout(source); + const updated = updateParamValue(parsed, 0, 'NonExistent', 'value'); + + // Param not added, only existing params can be updated + expect(updated.exercises[0].params.find(p => p.key === 'NonExistent')).toBeUndefined(); + }); + + it('should return unchanged if exercise index invalid', () => { + const source = '---\n- [ ] Exercise | Weight: [50] kg\n - [ ] |'; + const parsed = parseWorkout(source); + const updated = updateParamValue(parsed, 99, 'Key', 'value'); + + expect(updated).toBe(parsed); + }); + + it('should not mutate original', () => { + const source = '---\n- [ ] Bench | Weight: [100] kg | Reps: [10]\n - [ ] |'; + const parsed = parseWorkout(source); + const updated = updateParamValue(parsed, 0, 'Weight', '110'); + + expect(parsed.exercises[0].params[0].value).toBe('100'); + expect(updated.exercises[0].params[0].value).toBe('110'); + }); +}); + +describe('updateSetParamValue', () => { + it('should update existing set param', () => { + const source = '---\n- [ ] Bench\n - [ ] | Weight: [100] kg'; + const parsed = parseWorkout(source); + const updated = updateSetParamValue(parsed, 0, 0, 'Weight', '120'); + + expect(updated.exercises[0].sets[0].params[0].value).toBe('120'); + }); + + it('should return unchanged if exercise index invalid', () => { + const source = '---\n- [ ] Bench\n - [ ] | Weight: [100] kg'; + const parsed = parseWorkout(source); + const updated = updateSetParamValue(parsed, 99, 0, 'Weight', '120'); + + expect(updated).toBe(parsed); + }); + + it('should return unchanged if set index invalid', () => { + const source = '---\n- [ ] Bench\n - [ ] | Weight: [100] kg'; + const parsed = parseWorkout(source); + const updated = updateSetParamValue(parsed, 0, 99, 'Weight', '120'); + + expect(updated).toBe(parsed); + }); +}); + +describe('updateExerciseState', () => { + it('should update exercise state', () => { + const source = '---\n- [ ] Exercise\n - [ ] |'; + const parsed = parseWorkout(source); + const updated = updateExerciseState(parsed, 0, 'completed'); + + expect(updated.exercises[0].state).toBe('completed'); + }); + + it('should preserve original state if invalid index', () => { + const source = '---\n- [ ] Exercise\n - [ ] |'; + const parsed = parseWorkout(source); + const updated = updateExerciseState(parsed, 99, 'completed'); + + expect(updated).toBe(parsed); + }); +}); + +describe('updateSetState', () => { + it('should update set state', () => { + const source = '---\n- [ ] Bench\n - [ ] | Weight: [100] kg'; + const parsed = parseWorkout(source); + const updated = updateSetState(parsed, 0, 0, 'completed'); + + expect(updated.exercises[0].sets[0].state).toBe('completed'); + }); + + it('should return unchanged if indices invalid', () => { + const source = '---\n- [ ] Bench\n - [ ] | Weight: [100] kg'; + const parsed = parseWorkout(source); + const updated = updateSetState(parsed, 99, 0, 'completed'); + + expect(updated).toBe(parsed); + }); +}); + +describe('lockAllFields', () => { + it('should convert all editable params to non-editable', () => { + const source = '---\n- [ ] Bench | Weight: [100] kg | Reps: [8]\n - [ ] | Weight: [100] kg | Reps: [8]'; + const parsed = parseWorkout(source); + const locked = lockAllFields(parsed); + + // All params should be non-editable + locked.exercises[0].params.forEach(p => { + expect(p.editable).toBe(false); + }); + locked.exercises[0].sets.forEach(s => { + s.params.forEach(p => { + expect(p.editable).toBe(false); + }); + }); + }); + + it('should not mutate original', () => { + const source = '---\n- [ ] Bench | Weight: [100] kg | Reps: [8]\n - [ ] |'; + const parsed = parseWorkout(source); + const locked = lockAllFields(parsed); + + expect(parsed.exercises[0].params[0].editable).toBe(true); + expect(locked.exercises[0].params[0].editable).toBe(false); + }); +}); + +describe('addRest', () => { + it('should add rest exercise after specified index', () => { + const source = '---\n- [ ] Bench\n - [ ] |'; + const parsed = parseWorkout(source); + const withRest = addRest(parsed, 0, 60); + + expect(withRest.exercises).toHaveLength(2); + expect(withRest.exercises[1].name).toBe('Rest'); + expect(withRest.exercises[1].targetDuration).toBe(60); + }); + + it('should return unchanged if exercise index invalid', () => { + const source = '---\n- [ ] Bench\n - [ ] |'; + const parsed = parseWorkout(source); + const withRest = addRest(parsed, 99, 60); + + expect(withRest).toBe(parsed); + }); + + it('should create rest with editable Duration param', () => { + const source = '---\n- [ ] Bench\n - [ ] |'; + const parsed = parseWorkout(source); + const withRest = addRest(parsed, 0, 90); + + const restEx = withRest.exercises[1]; + expect(restEx.params).toHaveLength(1); + expect(restEx.params[0].key).toBe('Duration'); + expect(restEx.params[0].value).toBe('90s'); + expect(restEx.params[0].editable).toBe(true); + }); +}); + +describe('addSet', () => { + it('should add new set to exercise', () => { + const source = '---\n- [ ] Bench\n - [ ] | Weight: [100] kg'; + const parsed = parseWorkout(source); + const withSet = addSet(parsed, 0); + + expect(withSet.exercises[0].sets).toHaveLength(2); + expect(withSet.exercises[0].sets[1].state).toBe('pending'); + }); + + it('should preserve params in new set', () => { + const source = '---\n- [ ] Bench\n - [ ] | Weight: [100] kg | Reps: [8]'; + const parsed = parseWorkout(source); + const withSet = addSet(parsed, 0); + + expect(withSet.exercises[0].sets[1].params).toHaveLength(2); + }); + + it('should return unchanged if invalid exercise or no sets', () => { + const source = '---\n- [ ] Bench'; + const parsed = parseWorkout(source); + const withSet = addSet(parsed, 0); + + // First set is created from exercise params, so has 1 set + // Adding another should work + expect(withSet.exercises[0].sets.length).toBeGreaterThan(1); + }); +}); + +describe('setRecordedDuration', () => { + it('should set recorded duration on exercise', () => { + const source = '---\n- [ ] Cardio\n - [ ] |'; + const parsed = parseWorkout(source); + const updated = setRecordedDuration(parsed, 0, '5m 30s'); + + const durationParam = updated.exercises[0].params.find(p => p.key === 'Duration'); + expect(durationParam?.value).toBe('5m 30s'); + expect(durationParam?.editable).toBe(false); + }); + + it('should overwrite existing duration', () => { + const source = '---\n- [ ] Cardio | Duration: [60]\n - [ ] |'; + const parsed = parseWorkout(source); + const updated = setRecordedDuration(parsed, 0, '65s'); + + const durationParam = updated.exercises[0].params.find(p => p.key === 'Duration'); + expect(durationParam?.value).toBe('65s'); + expect(durationParam?.editable).toBe(false); + }); + + it('should return unchanged if exercise index invalid', () => { + const source = '---\n- [ ] Cardio'; + const parsed = parseWorkout(source); + const updated = setRecordedDuration(parsed, 99, '60s'); + + expect(updated).toBe(parsed); + }); +}); + +describe('setSetRecordedDuration', () => { + it('should set recorded duration on set', () => { + const source = '---\n- [ ] Bench\n - [ ] |'; + const parsed = parseWorkout(source); + const updated = setSetRecordedDuration(parsed, 0, 0, '2m 30s'); + + // Should store to recordedDuration field, not Duration param + expect(updated.exercises[0].sets[0].recordedTime).toBe('2m 30s'); + }); +}); + +describe('updateSetRestDuration', () => { + it('should update rest duration on set', () => { + const source = '---\n- [ ] Bench\n - [ ] |'; + const parsed = parseWorkout(source); + const updated = updateSetRestDuration(parsed, 0, 0, '90s'); + + const restParam = updated.exercises[0].sets[0].params.find(p => p.key === 'Rest'); + expect(restParam?.value).toBe('90s'); + expect(restParam?.editable).toBe(true); + }); +}); + +describe('totals persistence (~time and ~rest)', () => { + it('should parse ~time and ~rest parameters in whitelist', () => { + const source = '---\n- [x] Exercise | ~rest: 5m | ~time: 38m\n - [x] | Rest: 60s | Duration: 2:30'; + const parsed = parseWorkout(source); + + // ~time and ~rest are extracted into separate fields, not stored in params array + expect(parsed.exercises[0].recordedRest).toBe('5m'); + expect(parsed.exercises[0].recordedTime).toBe('38m'); + }); + + it('should strip ~time and ~rest from incomplete exercises on serialize', () => { + const source = '---\n- [ ] Bench | Weight: [100] kg | ~rest: 5m | ~time: 30m\n - [ ] | Weight: [100] kg'; + const parsed = parseWorkout(source); + const serialized = serializeWorkout(parsed); + + // Totals should be stripped for incomplete exercise + expect(serialized).not.toContain('~rest'); + expect(serialized).not.toContain('~time'); + expect(serialized).toContain('- [ ] Bench | Weight: [100] kg'); + }); + + it('should calculate and append ~rest for completed exercises with recorded rest', () => { + const source = '---\n- [x] Bench\n - [x] | Rest: 60s\n - [x] | Rest: 60s'; + const parsed = parseWorkout(source); + + // Set recorded rest times (simulating actual elapsed rest from timer) + parsed.exercises[0].sets[0].recordedRest = '60s'; + parsed.exercises[0].sets[1].recordedRest = '60s'; + + const serialized = serializeWorkout(parsed); + + // Should aggregate ~rest from both sets + expect(serialized).toContain('~rest: 2m'); + expect(serialized).toContain('- [x] Bench | ~rest: 2m'); + }); + + it('should calculate and append ~time for completed exercises with recorded duration', () => { + const source = '---\n- [x] Bench\n - [x] | Duration: 1:30\n - [x] | Duration: 2:00'; + const parsed = parseWorkout(source); + + // Set recorded durations (simulating actual elapsed time from timer) + parsed.exercises[0].sets[0].recordedTime = '1m 30s'; + parsed.exercises[0].sets[1].recordedTime = '2m'; + + const serialized = serializeWorkout(parsed); + + // Should aggregate ~time from both sets + expect(serialized).toContain('~time: 3m 30s'); + }); + + it('should include both ~rest and ~time for completed exercises', () => { + const source = '---\n- [x] Bench\n - [x] | Duration: 2:00 | Rest: 45s\n - [x] | Duration: 2:00 | Rest: 45s'; + const parsed = parseWorkout(source); + + // Set recorded times + parsed.exercises[0].sets[0].recordedTime = '2m'; + parsed.exercises[0].sets[0].recordedRest = '45s'; + parsed.exercises[0].sets[1].recordedTime = '2m'; + parsed.exercises[0].sets[1].recordedRest = '45s'; + + const serialized = serializeWorkout(parsed); + + expect(serialized).toContain('~rest: 1m 30s'); + expect(serialized).toContain('~time: 4m'); + }); + + it('should recalculate totals fresh (not preserve old values)', () => { + const source = '---\n- [x] Bench | ~rest: 99m | ~time: 99m\n - [x] | Duration: 1:00 | Rest: 30s'; + const parsed = parseWorkout(source); + + // Set recorded times (should overwrite old ~rest/~time from params) + parsed.exercises[0].sets[0].recordedTime = '1m'; + parsed.exercises[0].sets[0].recordedRest = '30s'; + + const serialized = serializeWorkout(parsed); + + // Old values should be recalculated, not preserved + expect(serialized).not.toContain('~rest: 99m'); + expect(serialized).not.toContain('~time: 99m'); + expect(serialized).toContain('~rest: 30s'); + expect(serialized).toContain('~time: 1m'); + }); + + it('should omit ~rest if no recorded rest exists', () => { + const source = '---\n- [x] Bench\n - [x] | Duration: 1:00'; + const parsed = parseWorkout(source); + + // Set only recordedDuration + parsed.exercises[0].sets[0].recordedTime = '1m'; + + const serialized = serializeWorkout(parsed); + + expect(serialized).not.toContain('~rest'); + expect(serialized).toContain('~time: 1m'); + }); + + it('should omit ~time if no recorded duration exists', () => { + const source = '---\n- [x] Bench\n - [x] | Rest: 60s'; + const parsed = parseWorkout(source); + + // Set only recordedRest + parsed.exercises[0].sets[0].recordedRest = '60s'; + + const serialized = serializeWorkout(parsed); + + expect(serialized).not.toContain('~time'); + expect(serialized).toContain('~rest: 1m'); + }); + + it('should show totals as locked (non-editable) parameters', () => { + const source = '---\n- [x] Bench\n - [x] | Duration: 1:30 | Rest: 45s'; + const parsed = parseWorkout(source); + + // Set recorded times + parsed.exercises[0].sets[0].recordedTime = '1m 30s'; + parsed.exercises[0].sets[0].recordedRest = '45s'; + + const serialized = serializeWorkout(parsed); + + // Totals should be locked format (no brackets) + expect(serialized).toContain('~rest: 45s'); + expect(serialized).not.toContain('~rest: [45s]'); + expect(serialized).toContain('~time: 1m 30s'); + expect(serialized).not.toContain('~time: [1m 30s]'); + }); + + it('should roundtrip with totals preserved for completed exercises', () => { + const source = '---\n- [x] Bench\n - [x] | Duration: 1:00 | Rest: 30s'; + const parsed = parseWorkout(source); + + // Set recorded times + parsed.exercises[0].sets[0].recordedTime = '1m'; + parsed.exercises[0].sets[0].recordedRest = '30s'; + + const serialized = serializeWorkout(parsed); + const reparsed = parseWorkout(serialized); + + // ~rest and ~time are extracted into separate fields after parsing + expect(reparsed.exercises[0].recordedRest).toBeDefined(); + expect(reparsed.exercises[0].recordedTime).toBeDefined(); + }); + + it('should handle exercises with multiple sets correctly', () => { + const source = '---\n- [x] Squats\n - [x] | Duration: 2:00 | Rest: 60s\n - [x] | Duration: 2:30 | Rest: 60s\n - [x] | Duration: 2:15 | Rest: 45s'; + const parsed = parseWorkout(source); + + // Set recorded times for each set + parsed.exercises[0].sets[0].recordedTime = '2m'; + parsed.exercises[0].sets[0].recordedRest = '60s'; + parsed.exercises[0].sets[1].recordedTime = '2m 30s'; + parsed.exercises[0].sets[1].recordedRest = '60s'; + parsed.exercises[0].sets[2].recordedTime = '2m 15s'; + parsed.exercises[0].sets[2].recordedRest = '45s'; + + const serialized = serializeWorkout(parsed); + + // 2m + 2m 30s + 2m 15s = 6m 45s + // 60s + 60s + 45s = 165s = 2m 45s + expect(serialized).toContain('~time: 6m 45s'); + expect(serialized).toContain('~rest: 2m 45s'); + }); +}); + +describe('createSampleWorkout', () => { + it('should create sample workout with exercises', () => { + const sample = createSampleWorkout(); + + expect(sample.metadata.title).toBe('Sample Workout'); + expect(sample.exercises.length).toBeGreaterThan(0); + expect(sample.exercises[0].sets.length).toBeGreaterThan(0); + }); +}); + +describe('serializeWorkoutAsTemplate', () => { + it('should serialize workout as template with reset state', () => { + const source = '---\n- [ ] Bench\n - [ ] | Weight: [100] kg'; + const parsed = parseWorkout(source); + const template = serializeWorkoutAsTemplate(parsed); + + expect(template).toContain('state: planned'); + expect(template).toContain('startDate:'); + expect(template).toContain('duration:'); + }); +}); + +describe('totals persistence roundtrip', () => { + it('should preserve ~time and ~rest through parse-serialize cycles', () => { + // Create a completed workout + const source = '---\n- [x] Bench\n - [x] | Rest: 60s'; + const parsed1 = parseWorkout(source); + + // Set recorded times + parsed1.exercises[0].sets[0].recordedTime = '2m'; + parsed1.exercises[0].sets[0].recordedRest = '45s'; + + // First serialize - should add ~time and ~rest + const serialized1 = serializeWorkout(parsed1); + expect(serialized1).toContain('~time: 2m'); + expect(serialized1).toContain('~rest: 45s'); + + // Parse the serialized result + const parsed2 = parseWorkout(serialized1); + + // Check that ~time and ~rest were extracted into recordedDuration/recordedRest + expect(parsed2.exercises[0].sets[0].recordedTime).toBe('2m'); + expect(parsed2.exercises[0].sets[0].recordedRest).toBe('45s'); + + // Serialize again - should still have ~time and ~rest + const serialized2 = serializeWorkout(parsed2); + expect(serialized2).toContain('~time: 2m'); + expect(serialized2).toContain('~rest: 45s'); + + // Parse and check one more time + const parsed3 = parseWorkout(serialized2); + expect(parsed3.exercises[0].sets[0].recordedTime).toBe('2m'); + expect(parsed3.exercises[0].sets[0].recordedRest).toBe('45s'); + }); + + it('should aggregate set totals through exercise totals across multiple roundtrips', () => { + const source = '---\n- [x] Squats\n - [x] | Rest: 60s\n - [x] | Rest: 60s'; + const parsed1 = parseWorkout(source); + + // Set recorded times for both sets + parsed1.exercises[0].sets[0].recordedTime = '2m'; + parsed1.exercises[0].sets[0].recordedRest = '60s'; + parsed1.exercises[0].sets[1].recordedTime = '2m 30s'; + parsed1.exercises[0].sets[1].recordedRest = '60s'; + + // First serialize - should calculate exercise totals + const serialized1 = serializeWorkout(parsed1); + expect(serialized1).toContain('- [x] Squats | ~rest: 2m | ~time: 4m 30s'); + // ~time and ~rest are internal and should not be serialized on the set line + expect(serialized1).toContain(' - [x] | Rest: 60s'); + + // Parse back + const parsed2 = parseWorkout(serialized1); + + // Verify set totals are preserved + expect(parsed2.exercises[0].sets[0].recordedTime).toBe('2m'); + expect(parsed2.exercises[0].sets[0].recordedRest).toBe('60s'); + // Note: duration format may vary slightly (spaces), so just check the value exists + expect(parsed2.exercises[0].sets[1].recordedTime).toBeTruthy(); + expect(parsed2.exercises[0].sets[1].recordedTime?.replace(/\s+/g, '')).toBe('2m30s'); + expect(parsed2.exercises[0].sets[1].recordedRest).toBe('60s'); + + // Serialize again - exercise totals should be recalculated from set totals + const serialized2 = serializeWorkout(parsed2); + expect(serialized2).toContain('- [x] Squats | ~rest: 2m | ~time: 4m 30s'); + }); +}); diff --git a/src/serializer.ts b/src/serializer.ts index c42b3f0..cdf7a8e 100644 --- a/src/serializer.ts +++ b/src/serializer.ts @@ -1,26 +1,240 @@ -import { ParsedWorkout, Exercise, ExerciseState } from './types'; +/** + * Serialization layer: Convert parsed workout data structures back to markdown format. + * + * Responsibilities: + * - Convert ParsedWorkout objects back to markdown strings + * - Manage system totals (~time and ~rest) for exercises and sets + * - Enrich completed sets/exercises with their calculated totals + * - Support workflow operations: state changes, param updates, adding sets/exercises + * - Generate templates for copying/reuse + * + * System Totals: + * - ~time: Accumulated duration for completed set or exercise + * - ~rest: Accumulated rest time for completed set or exercise + * - Automatically calculated from set-level totals when exercise completes + * - Stripped on parse to avoid parse-induced changes + * - Restored on serialize for display/export + * + * Key Design Patterns: + * - Always use structuredClone() for immutability + * - Parameter updates maintain editable state (editable params keep [brackets]) + * - Recorded times separate from display/target Duration params + * - Sample workouts used for template generation and UI demos + */ + +import { ParsedWorkout, Exercise, ExerciseState, ExerciseSet, ExerciseParam } from './types'; import { serializeMetadata } from './parser/metadata'; -import { serializeExercise, getStateChar } from './parser/exercise'; - +import { serializeExercise, serializeSet, getStateChar, formatDurationHuman, parseDurationToSeconds } from './parser/exercise'; + +/** + * Convert a parsed workout back to markdown format. + * + * Process: + * 1. Serialize metadata (title, state, dates, duration) + * 2. Add separator (---) + * 3. Enrich exercises with their set totals + * 4. Serialize each exercise and its sets + * + * Returns: Complete markdown string ready to write back to file + */ export function serializeWorkout(parsed: ParsedWorkout): string { const lines: string[] = []; - // Serialize metadata + // Serialize metadata (title, state, dates, duration) const metadataLines = serializeMetadata(parsed.metadata); lines.push(...metadataLines); - // Add separator + // Add separator between metadata and exercises lines.push('---'); - // Serialize exercises + // Serialize exercises with their sets for (const exercise of parsed.exercises) { - lines.push(serializeExercise(exercise)); + // Enrich sets with their own totals, then use those to compute exercise totals + const enrichedExercise = enrichExerciseWithSetTotals(exercise); + lines.push(serializeExercise(enrichedExercise)); + + // Serialize enriched sets under the exercise + for (const set of enrichedExercise.sets) { + lines.push(serializeSet(set)); + } } return lines.join('\n'); } -// Update a specific param value in a workout +/** + * Enrich an exercise with system totals (~time, ~rest) based on completed sets. + * + * Process for INCOMPLETE exercises: + * - Strip any existing ~time and ~rest params + * - Keep other exercise-level params unchanged + * - Don't add totals (nothing to total yet) + * + * Process for COMPLETED exercises: + * - Strip any existing ~time and ~rest params + * - Enrich all sets with their own totals (recordedTime/recordedRest) + * - Sum set-level totals to create exercise-level totals + * - Add exercise ~time and ~rest params + * + * Used during serialization to ensure totals are accurate and up-to-date. + * + * Parameters: + * - exercise: Exercise with potentially stale totals from previous serialize + * + * Returns: Enriched exercise with fresh totals + */ +function enrichExerciseWithSetTotals(exercise: Exercise): Exercise { + // First, enrich each set with its own totals + const enrichedSets = exercise.sets.map(set => enrichSetWithTotals(set)); + + // Remove old exercise totals (will recalculate if needed) + const paramsWithoutTotals = exercise.params.filter( + p => p.key.toLowerCase() !== '~time' && p.key.toLowerCase() !== '~rest' + ); + + // If exercise is incomplete, don't add totals (nothing finalized yet) + if (exercise.state !== 'completed') { + return { + ...exercise, + sets: enrichedSets, + params: paramsWithoutTotals, + }; + } + + // For completed exercises, compute totals from set-level totals + const totalRest = sumSetTotals(enrichedSets, '~rest'); + const totalTime = sumSetTotals(enrichedSets, '~time'); + + // Add exercise-level totals (if any sets recorded values) + if (totalRest) { + paramsWithoutTotals.push({ + key: '~rest', + value: totalRest, + editable: false, + unit: '', + }); + } + + if (totalTime) { + paramsWithoutTotals.push({ + key: '~time', + value: totalTime, + editable: false, + unit: '', + }); + } + + return { + ...exercise, + sets: enrichedSets, + params: paramsWithoutTotals, + }; +} + +/** + * Enrich an individual set with system totals (~time, ~rest). + * + * System Totals: + * - ~time: Comes from set.recordedTime (actual elapsed time during exercise) + * - ~rest: Comes from set.recordedRest (actual elapsed time during rest period) + * + * Process for INCOMPLETE sets: + * - Strip any existing ~time and ~rest params + * - Keep other set params + * - Don't add totals + * + * Process for COMPLETED sets: + * - Strip any existing ~time and ~rest params + * - Add ~time param if recordedTime exists + * - Add ~rest param if recordedRest exists + * - Mark both as non-editable (locked) + * + * Parameters: + * - set: Set potentially with stale totals + * + * Returns: Set with fresh system totals + */ +function enrichSetWithTotals(set: ExerciseSet): ExerciseSet { + // Remove old set totals (will recalculate if needed) + const paramsWithoutTotals = set.params.filter( + p => p.key.toLowerCase() !== '~time' && p.key.toLowerCase() !== '~rest' + ); + + // If set is incomplete, don't add totals + if (set.state !== 'completed') { + return { + ...set, + params: paramsWithoutTotals, + }; + } + + // For completed sets, use recorded times as totals + if (set.recordedTime) { + paramsWithoutTotals.push({ + key: '~time', + value: set.recordedTime, + editable: false, + unit: '', + }); + } + + if (set.recordedRest) { + paramsWithoutTotals.push({ + key: '~rest', + value: set.recordedRest, + editable: false, + unit: '', + }); + } + + return { + ...set, + params: paramsWithoutTotals, + }; +} + +/** + * Sum a specific system total parameter (~time or ~rest) across all sets. + * + * Process: + * - Find all params matching the key (case-insensitive) across all sets + * - Parse duration string (e.g., "3m 45s") to seconds + * - Sum all seconds + * - Format back to human-readable duration string + * + * Parameters: + * - sets: Array of completed sets with totals + * - paramKey: System total key ("~time" or "~rest") + * + * Returns: Formatted duration string (e.g., "12m 33s") or empty string if no totals + */ +function sumSetTotals(sets: ExerciseSet[], paramKey: string): string { + let totalSeconds = 0; + + for (const set of sets) { + const param = set.params.find(p => p.key.toLowerCase() === paramKey.toLowerCase()); + if (param?.value) { + totalSeconds += parseDurationToSeconds(param.value); + } + } + + return totalSeconds > 0 ? formatDurationHuman(totalSeconds) : ''; +} + +/** + * Update an exercise-level parameter value. + * + * Finds the param by key and updates its value, maintaining editable state. + * Creates a deep clone to avoid mutations. + * + * Parameters: + * - parsed: Complete workout state + * - exerciseIndex: Index of exercise to modify + * - paramKey: Parameter key to update + * - newValue: New value (with or without brackets - caller's responsibility) + * + * Returns: New ParsedWorkout with updated param (or original if exercise not found) + */ export function updateParamValue( parsed: ParsedWorkout, exerciseIndex: number, @@ -39,7 +253,57 @@ export function updateParamValue( return newParsed; } -// Update exercise state +/** + * Update a set-level parameter value. + * + * Finds the param in the specified set by key and updates its value. + * Creates a deep clone to avoid mutations. + * + * Parameters: + * - parsed: Complete workout state + * - exerciseIndex: Index of exercise containing the set + * - setIndex: Index of set to modify + * - paramKey: Parameter key to update + * - newValue: New value (with or without brackets - caller's responsibility) + * + * Returns: New ParsedWorkout with updated param (or original if not found) + */ +export function updateSetParamValue( + parsed: ParsedWorkout, + exerciseIndex: number, + setIndex: number, + paramKey: string, + newValue: string +): ParsedWorkout { + const newParsed = structuredClone(parsed); + const exercise = newParsed.exercises[exerciseIndex]; + if (!exercise) return parsed; + + const set = exercise.sets[setIndex]; + if (!set) return parsed; + + const param = set.params.find(p => p.key === paramKey); + if (param) { + param.value = newValue; + } + + return newParsed; +} + +/** + * Update an exercise's completion state. + * + * Changes the state from pending → in-progress → completed (or skipped). + * State determines whether system totals (~time, ~rest) are calculated on serialize. + * Creates a deep clone to avoid mutations. + * + * Parameters: + * - parsed: Complete workout state + * - exerciseIndex: Index of exercise to update + * - newState: New state ('pending' | 'in-progress' | 'completed' | 'skipped') + * + * Returns: New ParsedWorkout with updated exercise state + */ export function updateExerciseState( parsed: ParsedWorkout, exerciseIndex: number, @@ -53,7 +317,50 @@ export function updateExerciseState( return newParsed; } -// Lock all editable fields (remove brackets) +/** + * Update a set's completion state. + * + * Changes the state from pending → in-progress → completed (or skipped). + * When set reaches completed state, its recordedTime/recordedRest are included in totals. + * Creates a deep clone to avoid mutations. + * + * Parameters: + * - parsed: Complete workout state + * - exerciseIndex: Index of exercise containing the set + * - setIndex: Index of set to update + * - newState: New state ('pending' | 'in-progress' | 'completed' | 'skipped') + * + * Returns: New ParsedWorkout with updated set state + */ +export function updateSetState( + parsed: ParsedWorkout, + exerciseIndex: number, + setIndex: number, + newState: ExerciseState +): ParsedWorkout { + const newParsed = structuredClone(parsed); + const exercise = newParsed.exercises[exerciseIndex]; + if (!exercise) return parsed; + + const set = exercise.sets[setIndex]; + if (!set) return parsed; + + set.state = newState; + return newParsed; +} + +/** + * Lock all editable parameters across all exercises and sets. + * + * Removes [brackets] notation from all parameter values by setting editable=false. + * Used to create "static" workouts that can't be modified in the UI. + * Creates a deep clone to avoid mutations. + * + * Parameters: + * - parsed: Complete workout state + * + * Returns: New ParsedWorkout with all params marked non-editable + */ export function lockAllFields(parsed: ParsedWorkout): ParsedWorkout { const newParsed = structuredClone(parsed); @@ -61,12 +368,31 @@ export function lockAllFields(parsed: ParsedWorkout): ParsedWorkout { for (const param of exercise.params) { param.editable = false; } + for (const set of exercise.sets) { + for (const param of set.params) { + param.editable = false; + } + } } return newParsed; } -// Add a rest exercise after the specified index +/** + * Insert a rest exercise after a specified exercise. + * + * Creates a new Rest exercise with countdown duration. + * Rest exercises are single-set exercises used for scheduled rest periods between exercises. + * Updates line indices for all subsequent exercises. + * Creates a deep clone to avoid mutations. + * + * Parameters: + * - parsed: Complete workout state + * - exerciseIndex: Index of exercise after which to insert rest + * - restDuration: Rest duration in seconds + * + * Returns: New ParsedWorkout with rest exercise inserted + */ export function addRest(parsed: ParsedWorkout, exerciseIndex: number, restDuration: number): ParsedWorkout { const newParsed = structuredClone(parsed); const currentExercise = newParsed.exercises[exerciseIndex]; @@ -81,6 +407,7 @@ export function addRest(parsed: ParsedWorkout, exerciseIndex: number, restDurati value: `${restDuration}s`, editable: true }], + sets: [], targetDuration: restDuration, lineIndex: currentExercise.lineIndex + 1 }; @@ -97,41 +424,63 @@ export function addRest(parsed: ParsedWorkout, exerciseIndex: number, restDurati return newParsed; } -// Add a new set (duplicate an exercise) +/** + * Add a new set to an exercise. + * + * Clones the first set as a template and appends a new set to the exercise. + * New set starts with pending state and editable params. + * Used for creating multi-set exercises or duplicating set templates. + * Creates a deep clone to avoid mutations. + * + * Parameters: + * - parsed: Complete workout state + * - exerciseIndex: Index of exercise to add set to + * + * Returns: New ParsedWorkout with new set appended (or original if exercise not found) + */ export function addSet(parsed: ParsedWorkout, exerciseIndex: number): ParsedWorkout { const newParsed = structuredClone(parsed); const exercise = newParsed.exercises[exerciseIndex]; - if (!exercise) return parsed; - - // Create a copy with pending state - const newExercise: Exercise = { - ...structuredClone(exercise), + if (!exercise || exercise.sets.length === 0) return parsed; + + // Copy the first set as a template + const firstSet = exercise.sets[0]; + if (!firstSet) return parsed; + + const clonedSet = structuredClone(firstSet); + const newSet: ExerciseSet = { state: 'pending', - recordedDuration: undefined, - lineIndex: exercise.lineIndex + 1 + params: clonedSet?.params || [], + lineIndex: (exercise.sets[exercise.sets.length - 1]?.lineIndex || 0) + 1 }; - // Reset editable values to editable - for (const param of newExercise.params) { - if (param.key.toLowerCase() === 'duration' && !param.editable) { - // If duration was recorded, remove it or reset - param.editable = exercise.targetDuration !== undefined; - } + // Make values editable again + for (const param of newSet.params) { + // Keep Duration params editable for sets + param.editable = param.key.toLowerCase() === 'duration' || param.editable; } - // Insert after current exercise - newParsed.exercises.splice(exerciseIndex + 1, 0, newExercise); - - // Update line indices for subsequent exercises - for (let i = exerciseIndex + 2; i < newParsed.exercises.length; i++) { - const ex = newParsed.exercises[i]; - if (ex) ex.lineIndex++; - } + // Add the new set + exercise.sets.push(newSet); return newParsed; } -// Set Duration param value (for recording time after exercise completion) +/** + * Record the actual elapsed duration for an exercise as a Duration param. + * + * Called after an exercise is marked complete to capture the actual time taken. + * Updates exercise.recordedDuration for use in system totals (~time). + * Adds or updates Duration param with the recorded value (locked, non-editable). + * Creates a deep clone to avoid mutations. + * + * Parameters: + * - parsed: Complete workout state + * - exerciseIndex: Index of exercise to record duration for + * - durationStr: Duration string from timer (e.g., "3m 45s") + * + * Returns: New ParsedWorkout with recorded duration applied + */ export function setRecordedDuration( parsed: ParsedWorkout, exerciseIndex: number, @@ -156,11 +505,132 @@ export function setRecordedDuration( }); } - exercise.recordedDuration = durationStr; + exercise.recordedTime = durationStr; return newParsed; } -// Create a sample workout with comprehensive exercise examples +/** + * Record the actual elapsed duration for a single set. + * + * Stores elapsed time in set.recordedTime (not as a param, but as metadata). + * Used when set is completed to capture actual exercise time. + * This time is later included in system totals (~time) when set is completed. + * Creates a deep clone to avoid mutations. + * + * Parameters: + * - parsed: Complete workout state + * - exerciseIndex: Index of exercise containing the set + * - setIndex: Index of set to record duration for + * - durationStr: Duration string from timer (e.g., "45s") + * + * Returns: New ParsedWorkout with set recorded time applied + */ +export function setSetRecordedDuration( + parsed: ParsedWorkout, + exerciseIndex: number, + setIndex: number, + durationStr: string +): ParsedWorkout { + const newParsed = structuredClone(parsed); + const exercise = newParsed.exercises[exerciseIndex]; + if (!exercise) return parsed; + + const set = exercise.sets[setIndex]; + if (!set) return parsed; + + // Store actual elapsed time to recordedDuration (not Duration param) + set.recordedTime = durationStr; + + return newParsed; +} + +/** + * Store the actual elapsed rest time for a specific set. + * Called after a rest period completes to record how long the rest actually took. + */ +export function setSetRecordedRest( + parsed: ParsedWorkout, + exerciseIndex: number, + setIndex: number, + restStr: string +): ParsedWorkout { + const newParsed = structuredClone(parsed); + const exercise = newParsed.exercises[exerciseIndex]; + if (!exercise) return parsed; + + const set = exercise.sets[setIndex]; + if (!set) return parsed; + + // Store actual elapsed rest time to recordedRest (not Rest param) + set.recordedRest = restStr; + + return newParsed; +} + +/** + * Update the Rest parameter for a specific set. + * + * Finds or creates the Rest param and updates its value (editable). + * Used to modify the required rest duration between sets. + * Separate from setSetRecordedRest - this is the target duration stored as a param. + * Creates a deep clone to avoid mutations. + * + * Parameters: + * - parsed: Complete workout state + * - exerciseIndex: Index of exercise containing the set + * - setIndex: Index of set to update + * - restStr: Rest duration string (e.g., "60s", "1m 30s") + * + * Returns: New ParsedWorkout with rest param updated (or original if not found) + */ +export function updateSetRestDuration( + parsed: ParsedWorkout, + exerciseIndex: number, + setIndex: number, + restStr: string +): ParsedWorkout { + const newParsed = structuredClone(parsed); + const exercise = newParsed.exercises[exerciseIndex]; + if (!exercise) return parsed; + + const set = exercise.sets[setIndex]; + if (!set) return parsed; + + // Find Rest param or add one + let restParam = set.params.find(p => p.key.toLowerCase() === 'rest'); + + if (restParam) { + restParam.value = restStr; + restParam.editable = true; + } else { + // Add Rest param + set.params.push({ + key: 'Rest', + value: restStr, + editable: true + }); + } + + return newParsed; +} + +/** + * Create a sample workout for demonstration and testing. + * + * Includes: + * - Metadata with title and planned state + * - Multiple exercise types (timed and rep-based) + * - Rest exercises between sets + * - Multi-set exercises with varying parameters + * - Mix of editable and locked parameter formats + * + * Used for: + * - New user onboarding (sample template) + * - UI debugging and screenshots + * - Test fixtures for workout rendering + * + * Returns: Complete ParsedWorkout ready for rendering or serialization + */ export function createSampleWorkout(): ParsedWorkout { const metadata = { title: 'Sample Workout', @@ -172,38 +642,65 @@ export function createSampleWorkout(): ParsedWorkout { { state: 'pending', name: 'Squats', - params: [ - { key: 'Weight', value: '60', editable: true, unit: 'kg' }, - { key: 'Reps', value: '12', editable: true } + params: [{ key: 'Duration', value: '[180s]', editable: true }], + sets: [ + { + state: 'pending', + params: [ + { key: 'Weight', value: '60', editable: true, unit: 'kg' }, + { key: 'Reps', value: '12', editable: true }, + { key: 'Rest', value: '[60s]', editable: true } + ], + lineIndex: 0 + }, + { + state: 'pending', + params: [ + { key: 'Weight', value: '65', editable: true, unit: 'kg' }, + { key: 'Reps', value: '10', editable: true }, + { key: 'Rest', value: '[90s]', editable: true } + ], + lineIndex: 1 + } ], + targetDuration: 180, lineIndex: 0 }, { state: 'pending', name: 'Rest', params: [{ key: 'Duration', value: '60s', editable: true }], - targetDuration: 60, - lineIndex: 1 - }, - { - state: 'pending', - name: 'Push-ups', - params: [{ key: 'Reps', value: '15', editable: true }], - lineIndex: 2 - }, - { - state: 'pending', - name: 'Rest', - params: [{ key: 'Duration', value: '60s', editable: true }], + sets: [ + { + state: 'pending', + params: [{ key: 'Duration', value: '60s', editable: true }], + lineIndex: 2 + } + ], targetDuration: 60, lineIndex: 3 }, { state: 'pending', - name: 'Dumbbell Rows', - params: [ - { key: 'Weight', value: '20', editable: true, unit: 'kg' }, - { key: 'Reps', value: '10', editable: true, unit: '/arm' } + name: 'Push-ups', + params: [], + sets: [ + { + state: 'pending', + params: [ + { key: 'Reps', value: '15', editable: true }, + { key: 'Rest', value: '[45s]', editable: true } + ], + lineIndex: 3 + }, + { + state: 'pending', + params: [ + { key: 'Reps', value: '12', editable: true }, + { key: 'Rest', value: '[60s]', editable: true } + ], + lineIndex: 4 + } ], lineIndex: 4 }, @@ -211,28 +708,41 @@ export function createSampleWorkout(): ParsedWorkout { state: 'pending', name: 'Rest', params: [{ key: 'Duration', value: '60s', editable: true }], + sets: [ + { + state: 'pending', + params: [{ key: 'Duration', value: '60s', editable: true }], + lineIndex: 5 + } + ], targetDuration: 60, - lineIndex: 5 - }, - { - state: 'pending', - name: 'Plank Hold', - params: [{ key: 'Duration', value: '45s', editable: true }], - targetDuration: 45, lineIndex: 6 }, { state: 'pending', - name: 'Rest', - params: [{ key: 'Duration', value: '60s', editable: true }], - targetDuration: 60, + name: 'Dumbbell Rows', + params: [], + sets: [ + { + state: 'pending', + params: [ + { key: 'Weight', value: '20', editable: true, unit: 'kg' }, + { key: 'Reps', value: '10', editable: true, unit: '/arm' }, + { key: 'Rest', value: '[60s]', editable: true } + ], + lineIndex: 6 + }, + { + state: 'pending', + params: [ + { key: 'Weight', value: '25', editable: true, unit: 'kg' }, + { key: 'Reps', value: '8', editable: true, unit: '/arm' }, + { key: 'Rest', value: '[90s]', editable: true } + ], + lineIndex: 7 + } + ], lineIndex: 7 - }, - { - state: 'pending', - name: 'Lunges', - params: [{ key: 'Reps', value: '10', editable: true, unit: '/leg' }], - lineIndex: 8 } ]; @@ -244,7 +754,27 @@ export function createSampleWorkout(): ParsedWorkout { }; } -// Serialize workout as a clean template (for copying) +/** + * Serialize a workout as a clean template for copying/reuse. + * + * Template Format: + * - Resets metadata to planned state (no dates or elapsed duration) + * - Keeps exercise names and structure + * - Clears system totals (~time, ~rest) - templates start fresh + * - Preserves target durations for timed exercises (e.g., [3m]) + * - Restores all params to editable format [brackets] + * - Ready to paste into new workout entries + * + * Used for: + * - Copy-to-clipboard functionality ("Use as template" button) + * - Generating reusable workout definitions + * - Archiving workouts for future reuse + * + * Parameters: + * - parsed: Completed or in-progress workout to convert to template + * + * Returns: Markdown string suitable for copying to new workout entry + */ export function serializeWorkoutAsTemplate(parsed: ParsedWorkout): string { const lines: string[] = []; @@ -259,47 +789,47 @@ export function serializeWorkoutAsTemplate(parsed: ParsedWorkout): string { // Add separator lines.push('---'); - // Get unique exercises by name (remove duplicate sets) - const seenNames = new Set(); - const uniqueExercises: Exercise[] = []; - + // Serialize exercises with their sets for (const exercise of parsed.exercises) { - if (!seenNames.has(exercise.name)) { - seenNames.add(exercise.name); - uniqueExercises.push(exercise); - } - } - - // Serialize exercises - reset state and make values editable - for (const exercise of uniqueExercises) { - let line = `- [ ] ${exercise.name}`; + let exerciseLine = `- [ ] ${exercise.name}`; + // Add exercise-level params (typically Duration for timed exercises) for (const param of exercise.params) { - line += ' | '; - line += `${param.key}: `; + exerciseLine += ' | '; + exerciseLine += `${param.key}: `; - // Skip recorded durations, but keep target durations - if (param.key.toLowerCase() === 'duration' && !exercise.targetDuration) { - continue; - } - - // Make all values editable if (param.key.toLowerCase() === 'duration' && exercise.targetDuration) { // Restore original target duration format const mins = Math.floor(exercise.targetDuration / 60); const secs = exercise.targetDuration % 60; const durationStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; - line += `[${durationStr}]`; + exerciseLine += `[${durationStr}]`; } else { - line += `[${param.value}]`; + exerciseLine += `[${param.value}]`; } if (param.unit) { - line += ` ${param.unit}`; + exerciseLine += ` ${param.unit}`; } } - lines.push(line); + lines.push(exerciseLine); + + // Add sets as indented sub-items + for (const set of exercise.sets) { + let setLine = ' - [ ]'; + + for (const param of set.params) { + setLine += ' '; + setLine += `${param.key}: `; + setLine += `[${param.value}]`; + if (param.unit) { + setLine += ` ${param.unit}`; + } + } + + lines.push(setLine); + } } return lines.join('\n'); diff --git a/src/timer/manager.test.ts b/src/timer/manager.test.ts new file mode 100644 index 0000000..47d650a --- /dev/null +++ b/src/timer/manager.test.ts @@ -0,0 +1,368 @@ +import { TimerManager } from './manager'; + +describe('TimerManager', () => { + let manager: TimerManager; + let dateNowSpy: jest.SpyInstance; + + beforeEach(() => { + manager = new TimerManager(); + // Mock Date.now() for consistent testing + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1000); + // Mock requestAnimationFrame + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('startWorkoutTimer', () => { + it('should create new timer', () => { + manager.startWorkoutTimer('workout1', 0); + + const state = manager.getTimerState('workout1'); + expect(state).not.toBeNull(); + expect(state?.activeExerciseIndex).toBe(0); + }); + + it('should resume existing timer with new exercise', () => { + manager.startWorkoutTimer('workout1', 0); + dateNowSpy.mockReturnValue(2000); + manager.startWorkoutTimer('workout1', 1); + + const state = manager.getTimerState('workout1'); + expect(state?.activeExerciseIndex).toBe(1); + }); + + it('should initialize timer with correct default values', () => { + manager.startWorkoutTimer('workout1', 0); + const state = manager.getTimerState('workout1'); + + expect(state?.isPaused).toBe(false); + expect(state?.isRestActive).toBe(false); + expect(state?.activeSetIndex).toBe(0); + }); + }); + + describe('advanceExercise', () => { + it('should move to next exercise', () => { + manager.startWorkoutTimer('workout1'); + dateNowSpy.mockReturnValue(5000); + manager.advanceExercise('workout1', 2); + + const state = manager.getTimerState('workout1'); + expect(state?.activeExerciseIndex).toBe(2); + expect(state?.activeSetIndex).toBe(0); + }); + + it('should reset paused time', () => { + manager.startWorkoutTimer('workout1'); + manager.pauseExercise('workout1'); + dateNowSpy.mockReturnValue(2000); + manager.advanceExercise('workout1', 1); + + const state = manager.getTimerState('workout1'); + expect(state?.exercisePausedTime).toBe(0); + }); + }); + + describe('advanceSet', () => { + it('should move to next set in same exercise', () => { + manager.startWorkoutTimer('workout1'); + dateNowSpy.mockReturnValue(5000); + manager.advanceSet('workout1', 0, 2); + + const state = manager.getTimerState('workout1'); + expect(state?.activeExerciseIndex).toBe(0); + expect(state?.activeSetIndex).toBe(2); + }); + }); + + describe('startRest', () => { + it('should activate rest period', () => { + manager.startWorkoutTimer('workout1'); + manager.startRest('workout1', 60); + + const state = manager.getTimerState('workout1'); + expect(state?.isRestActive).toBe(true); + expect(state?.restDuration).toBe(60); + }); + + it('should reset pause time on rest start', () => { + manager.startWorkoutTimer('workout1'); + manager.pauseExercise('workout1'); + dateNowSpy.mockReturnValue(2000); + manager.startRest('workout1', 90); + + const state = manager.getTimerState('workout1'); + expect(state?.restPausedTime).toBe(0); + }); + }); + + describe('exitRest', () => { + it('should exit rest and move to next exercise set', () => { + manager.startWorkoutTimer('workout1'); + manager.startRest('workout1', 60); + dateNowSpy.mockReturnValue(3000); + manager.exitRest('workout1', 1, 1); + + const state = manager.getTimerState('workout1'); + expect(state?.isRestActive).toBe(false); + expect(state?.activeExerciseIndex).toBe(1); + expect(state?.activeSetIndex).toBe(1); + }); + + it('should clear rest duration', () => { + manager.startWorkoutTimer('workout1'); + manager.startRest('workout1', 60); + manager.exitRest('workout1', 0, 0); + + const state = manager.getTimerState('workout1'); + expect(state?.restDuration).toBe(0); + }); + }); + + describe('pauseExercise', () => { + it('should pause exercise', () => { + manager.startWorkoutTimer('workout1'); + manager.pauseExercise('workout1'); + + const state = manager.getTimerState('workout1'); + expect(state?.isPaused).toBe(true); + }); + + it('should accumulate pause time', () => { + manager.startWorkoutTimer('workout1'); + dateNowSpy.mockReturnValue(2000); + manager.pauseExercise('workout1'); + + const state = manager.getTimerState('workout1'); + expect(state?.exercisePausedTime).toBe(1000); + }); + + it('should not double-pause', () => { + manager.startWorkoutTimer('workout1'); + manager.pauseExercise('workout1'); + const state1 = manager.getTimerState('workout1'); + + dateNowSpy.mockReturnValue(3000); + manager.pauseExercise('workout1'); + const state2 = manager.getTimerState('workout1'); + + expect(state2?.exercisePausedTime).toBe(state1?.exercisePausedTime); + }); + + it('should pause rest period', () => { + manager.startWorkoutTimer('workout1'); + manager.startRest('workout1', 60); + dateNowSpy.mockReturnValue(2000); + manager.pauseExercise('workout1'); + + const state = manager.getTimerState('workout1'); + expect(state?.restPausedTime).toBe(1000); + }); + }); + + describe('resumeExercise', () => { + it('should resume paused exercise', () => { + manager.startWorkoutTimer('workout1'); + manager.pauseExercise('workout1'); + manager.resumeExercise('workout1'); + + const state = manager.getTimerState('workout1'); + expect(state?.isPaused).toBe(false); + }); + + it('should not resume if not paused', () => { + manager.startWorkoutTimer('workout1'); + manager.resumeExercise('workout1'); + + const state = manager.getTimerState('workout1'); + expect(state?.isPaused).toBe(false); + }); + }); + + describe('stopWorkoutTimer', () => { + it('should remove timer', () => { + manager.startWorkoutTimer('workout1'); + manager.stopWorkoutTimer('workout1'); + + const state = manager.getTimerState('workout1'); + expect(state).toBeNull(); + }); + + it('should not error if timer doesnt exist', () => { + expect(() => manager.stopWorkoutTimer('nonexistent')).not.toThrow(); + }); + }); + + describe('subscribe', () => { + it('should call callback on updates', () => { + const callback = jest.fn(); + manager.startWorkoutTimer('workout1'); + manager.subscribe('workout1', callback); + + dateNowSpy.mockReturnValue(2000); + jest.runAllTimers(); + + expect(callback).toHaveBeenCalled(); + }); + + it('should return unsubscribe function', () => { + const callback = jest.fn(); + manager.startWorkoutTimer('workout1'); + const unsubscribe = manager.subscribe('workout1', callback); + + jest.runAllTimers(); + const callCount1 = callback.mock.calls.length; + + unsubscribe(); + jest.runAllTimers(); + + // Callback should not be called again after unsubscribe + expect(callback.mock.calls.length).toBe(callCount1); + }); + }); + + describe('getTimerState', () => { + it('should return null for nonexistent timer', () => { + const state = manager.getTimerState('nonexistent'); + expect(state).toBeNull(); + }); + + it('should return current timer state', () => { + manager.startWorkoutTimer('workout1', 2); + const state = manager.getTimerState('workout1'); + + expect(state?.workoutId).toBe('workout1'); + expect(state?.activeExerciseIndex).toBe(2); + }); + }); + + describe('getActiveExerciseIndex', () => { + it('should return active exercise index', () => { + manager.startWorkoutTimer('workout1', 1); + const index = manager.getActiveExerciseIndex('workout1'); + + expect(index).toBe(1); + }); + + it('should return -1 for nonexistent timer', () => { + const index = manager.getActiveExerciseIndex('nonexistent'); + expect(index).toBe(-1); + }); + }); + + describe('getElapsedSeconds', () => { + it('should calculate elapsed time', () => { + manager.startWorkoutTimer('workout1'); + dateNowSpy.mockReturnValue(6000); + + const state = manager.getTimerState('workout1'); + expect(state?.workoutElapsed).toBe(5); + }); + + it('should account for pause time', () => { + manager.startWorkoutTimer('workout1'); + dateNowSpy.mockReturnValue(2000); + manager.pauseExercise('workout1'); + dateNowSpy.mockReturnValue(6000); + + const state = manager.getTimerState('workout1'); + expect(state?.exerciseElapsed).toBe(1); + }); + }); + + describe('getRestElapsedSeconds', () => { + it('should calculate rest elapsed time', () => { + manager.startWorkoutTimer('workout1'); + manager.startRest('workout1', 60); + dateNowSpy.mockReturnValue(36000); + + const state = manager.getTimerState('workout1'); + expect(state?.restElapsed).toBe(35); // 36000ms (now) - 1000ms (start) = 35000ms = 35s + }); + }); + + describe('getRestRemainingSeconds', () => { + it('should calculate rest remaining seconds', () => { + manager.startWorkoutTimer('workout1'); + manager.startRest('workout1', 60); + dateNowSpy.mockReturnValue(21000); + + const state = manager.getTimerState('workout1'); + expect(state?.restRemaining).toBeCloseTo(40, 1); // 60s duration - (21000ms - 1000ms) = 40s + }); + }); + + describe('setAutoAdvanceCallback', () => { + it('should set callback', () => { + const callback = jest.fn(); + manager.setAutoAdvanceCallback(callback); + + // Callback should be set internally (we can't directly test without more complex setup) + expect(() => manager.setAutoAdvanceCallback(callback)).not.toThrow(); + }); + }); + + describe('multiple timers', () => { + it('should manage multiple timers independently', () => { + manager.startWorkoutTimer('workout1', 0); + manager.startWorkoutTimer('workout2', 1); + + const state1 = manager.getTimerState('workout1'); + const state2 = manager.getTimerState('workout2'); + + expect(state1?.activeExerciseIndex).toBe(0); + expect(state2?.activeExerciseIndex).toBe(1); + }); + + it('should allow pausing one timer without affecting another', () => { + manager.startWorkoutTimer('workout1'); + manager.startWorkoutTimer('workout2'); + + manager.pauseExercise('workout1'); + + expect(manager.getTimerState('workout1')?.isPaused).toBe(true); + expect(manager.getTimerState('workout2')?.isPaused).toBe(false); + }); + + it('should stop only specified timer', () => { + manager.startWorkoutTimer('workout1'); + manager.startWorkoutTimer('workout2'); + + manager.stopWorkoutTimer('workout1'); + + expect(manager.getTimerState('workout1')).toBeNull(); + expect(manager.getTimerState('workout2')).not.toBeNull(); + }); + }); + + describe('edge cases', () => { + it('should handle operations on nonexistent timers gracefully', () => { + expect(() => { + manager.advanceExercise('nonexistent', 1); + manager.pauseExercise('nonexistent'); + manager.resumeExercise('nonexistent'); + manager.startRest('nonexistent', 60); + }).not.toThrow(); + }); + + it('should handle negative exercise indices', () => { + manager.startWorkoutTimer('workout1'); + manager.advanceExercise('workout1', -1); + + const state = manager.getTimerState('workout1'); + expect(state?.activeExerciseIndex).toBe(-1); + }); + + it('should handle large exercise indices', () => { + manager.startWorkoutTimer('workout1'); + manager.advanceExercise('workout1', 9999); + + const state = manager.getTimerState('workout1'); + expect(state?.activeExerciseIndex).toBe(9999); + }); + }); +}); diff --git a/src/timer/manager.ts b/src/timer/manager.ts index d11d761..79d6557 100644 --- a/src/timer/manager.ts +++ b/src/timer/manager.ts @@ -1,76 +1,311 @@ +/** + * Central timer management for all active workouts. + * + * Responsibilities: + * - Maintain timer state (elapsed times, pause/resume, active exercise/set) + * - Manage rest periods with duration tracking and countdown + * - Coordinate subscribers and notify on state changes + * - Efficient animation frame scheduling (one frame for all timers) + * - State change detection (only notify when values actually change) + * - Handle tab visibility changes (resume on tab focus) + * + * Architecture: + * - TimerManager singleton manages Map + * - Each workout has its own timer with subscribers + * - Uses requestAnimationFrame to update all timers efficiently + * - Tracks previous state to avoid redundant callbacks + * - Pause/resume handled at workout level (affects both exercise and rest timers) + */ + import { TimerInstance, TimerState, TimerCallback } from '../types'; +/** + * Manages timers for all active workouts with centralized state and callbacks. + * + * Public API: + * - startWorkoutTimer() - Initialize or resume a workout timer + * - advanceExercise() - Move to next exercise and reset elapsed time + * - advanceSet() - Move to next set within same exercise + * - startRest() - Begin rest period after set completion + * - exitRest() - End rest period and advance to next set + * - pauseExercise() / resumeExercise() - Pause/resume all timers + * - stopWorkoutTimer() - Clean up timer and remove all subscribers + * - subscribe() / getTimerState() - Query and monitor timer state + * - isTimerRunning() / getActiveExerciseIndex() - Query current state + * + * Subscription model: + * - Callbacks notified only when state meaningfully changes + * - Unsubscribe function returned from subscribe() + * - Auto-cleanup when last subscriber unsubscribes + */ export class TimerManager { private timers: Map = new Map(); - private intervalId: number | null = null; + private frameId: number | null = null; + private lastSecond: number = 0; private onAutoAdvance: ((workoutId: string) => void) | null = null; + // Track state from last callback to detect meaningful changes + private lastCalledState: Map = new Map(); + // Track timeouts for graceful cleanup of orphaned timers + private cleanupTimeouts: Map> = new Map(); + + /** + * Create a TimerManager instance. + * Listens for tab visibility changes to resume timers when tab becomes active. + */ + constructor() { + // Resume timers when tab becomes visible (was in background) + document.addEventListener('visibilitychange', () => { + if (!document.hidden && this.timers.size > 0) { + // Tab just became visible - force immediate update + this.tick(); + } + }); + } + /** + * Set callback for auto-advance triggers (e.g., countdown timer completion). + * Called by main plugin to handle automatic progression. + */ setAutoAdvanceCallback(callback: (workoutId: string) => void): void { this.onAutoAdvance = callback; } - startWorkoutTimer(workoutId: string, activeExerciseIndex: number = 0): void { + /** + * Start or resume a workout timer. + * + * If timer already exists: + * - Resets exercise timers + * - Preserves workout start time (for total elapsed) + * - Clears rest state + * + * If timer doesn't exist: + * - Creates new TimerInstance with current timestamp + * - Initializes empty callbacks set + * - Sets activeExerciseIndex to specified value (default 0) + * + * Parameters: + * - workoutId: Unique identifier for this workout + * - activeExerciseIndex: Starting exercise index (default 0) + * - elapsedWorkoutSeconds: Seconds already elapsed from a previous session (default 0) + */ + startWorkoutTimer(workoutId: string, activeExerciseIndex: number = 0, elapsedWorkoutSeconds: number = 0): void { const now = Date.now(); + const workoutStartTime = now - (elapsedWorkoutSeconds * 1000); const existing = this.timers.get(workoutId); if (existing) { - // Resume existing timer with new exercise + // Resume existing timer with new exercise starting fresh existing.exerciseStartTime = now; existing.exercisePausedTime = 0; existing.isPaused = false; existing.activeExerciseIndex = activeExerciseIndex; + existing.activeSetIndex = 0; + existing.isRestActive = false; + existing.restPausedTime = 0; + existing.restDuration = 0; } else { + // Create new timer instance this.timers.set(workoutId, { workoutId, - workoutStartTime: now, - exerciseStartTime: now, - exercisePausedTime: 0, + workoutStartTime, // Total elapsed from workout start (never paused) + exerciseStartTime: now, // Current exercise/set start time + exercisePausedTime: 0, // Accumulated time when paused isPaused: false, activeExerciseIndex, + activeSetIndex: 0, + isRestActive: false, + restStartTime: now, + restPausedTime: 0, // Accumulated time for paused rest + restDuration: 0, // Total rest seconds to count down from callbacks: new Set() }); } - this.ensureInterval(); + this.ensureFrame(); } + /** + * Move to the next exercise and reset its timer. + * + * Clears exercise elapsed time, exits any active rest, and starts fresh. + * Set index resets to 0 (first set of new exercise). + * + * Parameters: + * - workoutId: Workout identifier + * - newExerciseIndex: Index of next exercise + */ advanceExercise(workoutId: string, newExerciseIndex: number): void { const timer = this.timers.get(workoutId); if (!timer) return; + // Reset exercise timer counters timer.exerciseStartTime = Date.now(); timer.exercisePausedTime = 0; timer.isPaused = false; timer.activeExerciseIndex = newExerciseIndex; + timer.activeSetIndex = 0; // Start at first set of new exercise + // Exit any active rest + timer.isRestActive = false; + timer.restPausedTime = 0; + timer.restDuration = 0; + } + + /** + * Advance to the next set within the same (or specified) exercise. + * + * Resets exercise elapsed time for the new set. + * Exits any active rest period. + * + * Parameters: + * - workoutId: Workout identifier + * - exerciseIndex: Exercise index + * - setIndex: Index of next set within exercise + */ + advanceSet(workoutId: string, exerciseIndex: number, setIndex: number): void { + const timer = this.timers.get(workoutId); + if (!timer) return; + + // Reset exercise timer for new set + timer.exerciseStartTime = Date.now(); + timer.exercisePausedTime = 0; + timer.isPaused = false; + timer.activeExerciseIndex = exerciseIndex; + timer.activeSetIndex = setIndex; + // Exit rest if active + timer.isRestActive = false; + timer.restPausedTime = 0; + timer.restDuration = 0; + } + + /** + * Begin a rest period between sets. + * + * Initializes countdown from specified rest duration. + * Rest timer counts down to 0, then shows negative (overtime) values. + * + * Parameters: + * - workoutId: Workout identifier + * - restDurationSeconds: Duration in seconds to rest + */ + startRest(workoutId: string, restDurationSeconds: number): void { + const timer = this.timers.get(workoutId); + if (!timer) return; + + timer.isRestActive = true; + timer.restStartTime = Date.now(); + timer.restPausedTime = 0; + timer.isPaused = false; + timer.restDuration = restDurationSeconds; // Countdown target + } + + /** + * Exit rest period and advance to next set. + * + * Clears rest state and resets exercise timer for the next set. + * + * Parameters: + * - workoutId: Workout identifier + * - nextExerciseIndex: Exercise index to advance to + * - nextSetIndex: Set index within that exercise + */ + exitRest(workoutId: string, nextExerciseIndex: number, nextSetIndex: number): void { + const timer = this.timers.get(workoutId); + if (!timer) return; + + // Clear rest state + timer.isRestActive = false; + timer.restPausedTime = 0; + timer.restDuration = 0; + // Start timer for next set + timer.exerciseStartTime = Date.now(); + timer.exercisePausedTime = 0; + timer.activeExerciseIndex = nextExerciseIndex; + timer.activeSetIndex = nextSetIndex; } + /** + * Pause exercise timer and any active rest timer. + * + * Records elapsed time at moment of pause. + * Resume with resumeExercise() to continue from same point. + * + * Parameters: + * - workoutId: Workout identifier + */ pauseExercise(workoutId: string): void { const timer = this.timers.get(workoutId); if (!timer || timer.isPaused) return; timer.isPaused = true; - // Store how much time has passed for this exercise + // Record elapsed time at moment of pause const now = Date.now(); - timer.exercisePausedTime += now - timer.exerciseStartTime; + if (timer.isRestActive) { + timer.restPausedTime += now - timer.restStartTime; + } else { + timer.exercisePausedTime += now - timer.exerciseStartTime; + } } + /** + * Resume paused exercise timer. + * + * Restarts from point where pause() was called. + * + * Parameters: + * - workoutId: Workout identifier + */ resumeExercise(workoutId: string): void { const timer = this.timers.get(workoutId); if (!timer || !timer.isPaused) return; timer.isPaused = false; - timer.exerciseStartTime = Date.now(); + // Reset start time to now (continue from pause point with accumulated time) + if (timer.isRestActive) { + timer.restStartTime = Date.now(); + } else { + timer.exerciseStartTime = Date.now(); + } } + /** + * Stop and clean up a workout timer. + * + * Removes timer instance and all subscribers. + * Cancels animation frame if no more active timers. + * + * Parameters: + * - workoutId: Workout identifier + */ stopWorkoutTimer(workoutId: string): void { this.timers.delete(workoutId); + this.lastCalledState.delete(workoutId); + + const timeout = this.cleanupTimeouts.get(workoutId); + if (timeout) { + clearTimeout(timeout); + this.cleanupTimeouts.delete(workoutId); + } - if (this.timers.size === 0 && this.intervalId !== null) { - window.clearInterval(this.intervalId); - this.intervalId = null; + // Cancel animation frame if no more active timers + if (this.timers.size === 0 && this.frameId !== null) { + cancelAnimationFrame(this.frameId); + this.frameId = null; + this.lastSecond = 0; } } + /** + * Subscribe to timer state changes for a workout. + * + * Callback called immediately with current state, then on each meaningful change. + * Only notifies when exerciseElapsed, restRemaining, or isRestActive actually change. + * + * Parameters: + * - workoutId: Workout identifier + * - callback: Function called with TimerState on changes + * + * Returns: Unsubscribe function (removes callback and cleans up timer if last subscriber) + */ subscribe(workoutId: string, callback: TimerCallback): () => void { const timer = this.timers.get(workoutId); if (!timer) { @@ -80,27 +315,65 @@ export class TimerManager { timer.callbacks.add(callback); + // Clear any pending cleanup timeout since we have a new subscriber + const existingTimeout = this.cleanupTimeouts.get(workoutId); + if (existingTimeout) { + clearTimeout(existingTimeout); + this.cleanupTimeouts.delete(workoutId); + } + // Immediately call with current state const state = this.getTimerState(workoutId); if (state) { callback(state); + this.lastCalledState.set(workoutId, state); } + // Return unsubscribe function return () => { timer.callbacks.delete(callback); + + // Graceful Auto-cleanup: wait 5 minutes before deleting to allow for UI re-renders + if (timer.callbacks.size === 0) { + const timeout = setTimeout(() => { + this.timers.delete(workoutId); + this.lastCalledState.delete(workoutId); + this.cleanupTimeouts.delete(workoutId); + + // Cancel animation frame if no timers left + if (this.timers.size === 0 && this.frameId !== null) { + cancelAnimationFrame(this.frameId); + this.frameId = null; + this.lastSecond = 0; + } + }, 300000); // 5 minutes grace period + + this.cleanupTimeouts.set(workoutId, timeout); + } }; } + /** + * Get current timer state for a workout. + * + * Calculates elapsed times based on start times and pause state: + * - workoutElapsed: Total from workout start (never paused) + * - exerciseElapsed: Current exercise/set time (respects pause) + * - restElapsed: Time accumulated during rest (respects pause) + * - restRemaining: Seconds left in rest countdown (0+ or undefined if not resting) + * + * Returns: TimerState object or null if timer doesn't exist + */ getTimerState(workoutId: string): TimerState | null { const timer = this.timers.get(workoutId); if (!timer) return null; const now = Date.now(); - // Total workout elapsed (always running, no pause) + // Total workout elapsed: always counting, no pause const workoutElapsed = Math.floor((now - timer.workoutStartTime) / 1000); - // Exercise elapsed (respects pause) + // Exercise elapsed: respects pause state let exerciseElapsed: number; if (timer.isPaused) { exerciseElapsed = Math.floor(timer.exercisePausedTime / 1000); @@ -109,18 +382,60 @@ export class TimerManager { exerciseElapsed = Math.floor((timer.exercisePausedTime + currentExerciseTime) / 1000); } + // Rest elapsed and remaining: only if rest is active + let restElapsed: number | undefined; + let restRemaining: number | undefined; + + if (timer.isRestActive) { + if (timer.isPaused) { + restElapsed = Math.floor(timer.restPausedTime / 1000); + } else { + const currentRestTime = now - timer.restStartTime; + restElapsed = Math.floor((timer.restPausedTime + currentRestTime) / 1000); + } + restRemaining = Math.max(0, timer.restDuration - restElapsed); + } + return { workoutElapsed, exerciseElapsed, - isOvertime: false // Calculated by caller with target duration + isOvertime: false, // Calculated by caller with target duration + isRestActive: timer.isRestActive, + restElapsed, + restRemaining }; } + /** + * Get the currently active exercise index. + * + * Returns: Exercise index or 0 if timer doesn't exist + */ getActiveExerciseIndex(workoutId: string): number { const timer = this.timers.get(workoutId); return timer?.activeExerciseIndex ?? 0; } + /** + * Get the currently active set index. + * + * Returns: Set index or 0 if timer doesn't exist + */ + getActiveSetIndex(workoutId: string): number { + const timer = this.timers.get(workoutId); + return timer?.activeSetIndex ?? 0; + } + + /** + * Update the active exercise index. + * + * Only resets exercise timer if index actually changes. + * Automatically resets to first set (activeSetIndex = 0). + * + * Parameters: + * - workoutId: Workout identifier + * - index: New exercise index + */ setActiveExerciseIndex(workoutId: string, index: number): void { const timer = this.timers.get(workoutId); if (!timer) return; @@ -128,41 +443,154 @@ export class TimerManager { // Only reset exercise timer if index actually changed if (timer.activeExerciseIndex !== index) { timer.activeExerciseIndex = index; + timer.activeSetIndex = 0; timer.exerciseStartTime = Date.now(); timer.exercisePausedTime = 0; timer.isPaused = false; } } + /** + * Check if a timer is running for the workout. + * + * Returns: true if timer exists and has active subscribers + */ isTimerRunning(workoutId: string): boolean { return this.timers.has(workoutId); } + /** + * Manually notify all subscribers of current timer state. + * + * Used when state changes require immediate UI update (e.g., exercise skip). + * Only notifies if state has meaningfully changed since last callback. + * + * Parameters: + * - workoutId: Workout identifier + */ + notifySubscribers(workoutId: string): void { + const timer = this.timers.get(workoutId); + if (!timer) return; + + const state = this.getTimerState(workoutId); + if (!state) return; + + // Call all subscribers with current state and update lastCalledState + if (this.stateChanged(workoutId, state)) { + this.lastCalledState.set(workoutId, state); + + for (const callback of timer.callbacks) { + callback(state); + } + } + } + + /** + * Check if pause state has changed for a timer. + * + * Returns: true if paused, false if running or timer doesn't exist + */ isPaused(workoutId: string): boolean { const timer = this.timers.get(workoutId); return timer?.isPaused ?? false; } - private ensureInterval(): void { - if (this.intervalId !== null) return; + /** + * Ensure animation frame is scheduled for timer updates. + * + * Uses requestAnimationFrame to update all timers efficiently. + * Only processes on actual second change (not every frame). + * Automatically cancels when no timers remain. + * + * Private: Called automatically by start/pause/resume methods. + */ + private ensureFrame(): void { + if (this.frameId !== null) return; // Already scheduled + + const scheduleNextFrame = () => { + this.frameId = requestAnimationFrame(() => { + this.frameId = null; + + const now = Date.now(); + const currentSecond = Math.floor(now / 1000); + + // Only process on actual second change (reduces callback frequency) + if (currentSecond !== this.lastSecond) { + this.lastSecond = currentSecond; + this.tick(); + } + + // Schedule next frame if timers still active + if (this.timers.size > 0) { + scheduleNextFrame(); + } + }); + }; - this.intervalId = window.setInterval(() => { - this.tick(); - }, 1000); + scheduleNextFrame(); } + /** + * Process all timers and notify subscribers of state changes. + * + * Called once per second by ensureFrame(). + * Skips timers with no active subscribers. + * Only calls callbacks if state meaningfully changed. + * + * Private: Called via requestAnimationFrame by ensureFrame(). + */ private tick(): void { for (const [workoutId, timer] of this.timers) { + // Skip if no active subscribers + if (timer.callbacks.size === 0) continue; + const state = this.getTimerState(workoutId); if (!state) continue; - for (const callback of timer.callbacks) { - callback(state); + // Only callback if state meaningfully changed + if (this.stateChanged(workoutId, state)) { + this.lastCalledState.set(workoutId, state); + + for (const callback of timer.callbacks) { + callback(state); + } } } } - // Called when we need to check for auto-advance (countdown completed) + /** + * Check if timer state has meaningfully changed since last callback. + * + * Only triggers callbacks for changes to: + * - exerciseElapsed (second boundary) + * - restRemaining (countdown changed) + * - isRestActive (entered/exited rest) + * + * Ignores: workoutElapsed (always increasing), other timer details + * + * Private: Used by tick() and notifySubscribers(). + */ + private stateChanged(workoutId: string, current: TimerState): boolean { + const prev = this.lastCalledState.get(workoutId); + if (!prev) return true; + + return ( + prev.exerciseElapsed !== current.exerciseElapsed || + prev.restRemaining !== current.restRemaining || + prev.isRestActive !== current.isRestActive + ); + } + + /** + * Check if exercise duration target has been exceeded and trigger auto-advance. + * + * Called by renderer to check if countdown timer finished. + * Calls onAutoAdvance callback if elapsed >= targetDuration. + * + * Parameters: + * - workoutId: Workout identifier + * - targetDuration: Target duration in seconds (if undefined, no auto-advance) + */ checkAutoAdvance(workoutId: string, targetDuration: number | undefined): void { if (targetDuration === undefined) return; @@ -174,12 +602,23 @@ export class TimerManager { } } - // Cleanup all timers + /** + * Cleanup: Cancel all timers and clear state. + * + * Used during plugin shutdown or cleanup. + * Cancels any pending animation frame. + * Clears all timer and callback data. + */ destroy(): void { - if (this.intervalId !== null) { - window.clearInterval(this.intervalId); - this.intervalId = null; + if (this.frameId !== null) { + cancelAnimationFrame(this.frameId); + this.frameId = null; } this.timers.clear(); + this.lastCalledState.clear(); + for (const timeout of this.cleanupTimeouts.values()) { + clearTimeout(timeout); + } + this.cleanupTimeouts.clear(); } } diff --git a/src/types.ts b/src/types.ts index f20b273..81a1f78 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,7 +7,36 @@ export type WorkoutState = 'planned' | 'started' | 'completed'; // [ ] = pending, [\] = inProgress, [x] = completed, [-] = skipped export type ExerciseState = 'pending' | 'inProgress' | 'completed' | 'skipped'; -// Key-value pairs for exercise parameters +// Token types for parameter parsing +export interface Token { + type: 'key' | 'bracket' | 'value' | 'unit'; + value: string; +} + +export abstract class Constants { + static readonly STATE_ICONS: Record = { + 'pending': '○', + 'inProgress': '◐', + 'completed': '✓', + 'skipped': '—' + }; + static readonly PARAM_PREFIX_ICONS: Record = { + 'duration': '⏱️', + 'reps': '×', + 'rest': '⏸️' + }; + static readonly TIMER_ICONS: Record = { + 'count-down': ' ▼', + 'count-up': ' ▲', + 'recorded': ' ✓', + 'rest': ' ‖', + 'rest-overtime': ' ‖', + 'overtime': ' ⚠' + + }; +} + +// Key-value pairs for exercise/set parameters export interface ExerciseParam { key: string; value: string; @@ -15,6 +44,17 @@ export interface ExerciseParam { unit?: string; } +// Single set within an exercise +export interface ExerciseSet { + state: ExerciseState; + params: ExerciseParam[]; + lineIndex: number; // Line index relative to exercise section start + targetDuration?: number; // Target duration in seconds (for countdown timers) + targetRest?: number; // Target rest duration in seconds (for countdown timers) + recordedTime?: string; // Actual elapsed time during the set (from timer) + recordedRest?: string; // Actual elapsed time during rest period after this set +} + // Parsed metadata from the workout block header export interface WorkoutMetadata { title?: string; @@ -22,16 +62,20 @@ export interface WorkoutMetadata { startDate?: string; // ISO format or human readable duration?: string; // e.g., "11m 33s" restDuration?: number; // Default rest duration in seconds + saveToProperties?: boolean; // Whether to save workout data to Obsidian properties } -// Single exercise entry +// Single exercise entry (with nested sets) export interface Exercise { state: ExerciseState; name: string; - params: ExerciseParam[]; - targetDuration?: number; // Target duration in seconds (for countdown) - recordedDuration?: string; // Recorded duration after completion - lineIndex: number; // Line index relative to exercise section start + params: ExerciseParam[]; // Exercise-level params (e.g., Duration) + sets: ExerciseSet[]; // Nested sets + targetDuration?: number; // Target duration in seconds (for countdown) + targetRest?: number; // Target rest duration in seconds (for countdown) + recordedTime?: string; // Recorded duration after completion + recordedRest?: string; // Recorded rest duration after completion + lineIndex: number; // Line index relative to exercise section start } // Complete parsed workout block @@ -46,10 +90,15 @@ export interface ParsedWorkout { export interface TimerInstance { workoutId: string; workoutStartTime: number; // Timestamp when workout started - exerciseStartTime: number; // Timestamp when current exercise started - exercisePausedTime: number; // Accumulated paused time for current exercise + exerciseStartTime: number; // Timestamp when current set started + exercisePausedTime: number; // Accumulated paused time for current set isPaused: boolean; activeExerciseIndex: number; + activeSetIndex: number; // Index of active set within active exercise + isRestActive: boolean; // True if currently in rest period after a set + restStartTime: number; // Timestamp when rest period started + restPausedTime: number; // Accumulated paused time for rest period + restDuration: number; // Total rest duration in seconds for current rest period callbacks: Set; } @@ -57,8 +106,11 @@ export interface TimerInstance { export interface TimerState { workoutElapsed: number; // Total workout elapsed seconds exerciseElapsed: number; // Current exercise elapsed seconds - remaining?: number; // Seconds remaining (countdown mode) + exerciseRemaining?: number; // Seconds remaining (countdown mode) isOvertime: boolean; // True if countdown exceeded + isRestActive?: boolean; // True if currently in rest period + restElapsed?: number; // Rest period elapsed seconds + restRemaining?: number; // Rest period remaining seconds } export type TimerCallback = (state: TimerState) => void; @@ -67,15 +119,27 @@ export type TimerCallback = (state: TimerState) => void; export interface WorkoutCallbacks { onStartWorkout: () => Promise; onFinishWorkout: () => Promise; - onExerciseFinish: (exerciseIndex: number) => Promise; + + // Set/Exercise flow callbacks + onSetFinish: (exerciseIndex: number, setIndex: number) => Promise; + onRestStart: (exerciseIndex: number, restDuration: number) => Promise; + onRestEnd: (exerciseIndex: number) => Promise; + onExerciseSkip: (exerciseIndex: number) => Promise; onExerciseAddSet: (exerciseIndex: number) => Promise; onExerciseAddRest: (exerciseIndex: number) => Promise; - onExerciseSkip: (exerciseIndex: number) => Promise; + + // Parameter change callbacks onParamChange: (exerciseIndex: number, paramKey: string, newValue: string) => void; + onSetParamChange: (exerciseIndex: number, setIndex: number, paramKey: string, newValue: string) => void; + + // UI control callbacks onFlushChanges: () => Promise; onPauseExercise: () => void; onResumeExercise: () => void; onAddSample: () => Promise; + + // Deprecated: kept for backwards compatibility, use onSetFinish instead + onExerciseFinish?: (exerciseIndex: number) => Promise; } // Context passed to renderer diff --git a/styles.css b/styles.css index cd5cf24..eb2d080 100644 --- a/styles.css +++ b/styles.css @@ -112,7 +112,7 @@ color: var(--text-normal); white-space: nowrap; /* Use ch unit for character-based width */ - min-width: calc(var(--max-name-chars, 10) * 1ch); + min-width: calc((var(--max-name-chars, 10) * 0.8) * 1ch); } /* Simple exercise (no params) - center */ @@ -120,6 +120,39 @@ justify-content: center; } +/* Set Main Row - single line with flex layout */ +.workout-set-main { + display: flex; + align-items: center; + gap: 8px; + min-height: 24px; +} + +.workout-set-icon { + font-size: 0.9em; + width: 16px; + text-align: center; + color: var(--text-muted); + flex-shrink: 0; +} + +.workout-set-label { + font-weight: 250; + color: var(--text-normal); + white-space: nowrap; + min-width: calc((var(--max-name-chars, 10)*0.80) * 1ch); + text-align: center; +} + +/* Set Timer - positioned on the right */ +.workout-set-timer { + font-family: var(--font-monospace); + font-size: 0.9em; + color: var(--text-muted); + flex-shrink: 0; + margin-left: auto; +} + .workout-exercise.simple .workout-exercise-name { flex: none; min-width: auto; @@ -133,12 +166,19 @@ flex: 1; } +.workout-set-params { + display: flex; + align-items: center; + gap: 8px; + flex: 1; +} + .workout-param { display: inline-flex; align-items: center; - gap: 1px; + gap: 2px; white-space: nowrap; - padding: 0 8px; + padding: 0 6px; border-radius: 12px; background: var(--background-modifier-hover); font-size: 0.9em; @@ -159,9 +199,11 @@ } .workout-param-input { - width: 40px; + width: auto; + min-width: 42px; + max-width: 60px; height: 20px; - padding: 0; + padding: 0 4px; margin: 0; border: none; border-radius: 0; @@ -196,6 +238,34 @@ color: var(--color-red); } +.workout-exercise-timer.rest { + color: var(--color-cyan); +} + +.workout-exercise-timer.rest-overtime { + color: var(--color-blue); +} + +.workout-set-timer { + font-family: var(--font-monospace); + font-size: 0.9em; + color: var(--text-muted); + flex-shrink: 0; + margin-left: auto; +} + +.workout-set-timer.overtime { + color: var(--color-red); +} + +.workout-set-timer.rest { + color: var(--color-cyan); +} + +.workout-set-timer.rest-overtime { + color: var(--color-red); +} + .timer-indicator { margin-left: 2px; font-size: 0.85em; @@ -217,6 +287,14 @@ color: var(--color-red); } +.timer-indicator.rest { + color: var(--color-cyan); +} + +.timer-indicator.rest-overtime { + color: var(--color-red); +} + /* Exercise Controls */ .workout-exercise-controls { display: flex; @@ -318,6 +396,7 @@ } /* Mobile Responsive Layout (phones and small tablets) */ +/* Tablet and Larger Phone Layout (480px to 768px) */ @media (max-width: 768px) { /* Workout Container - More compact */ .workout-container { @@ -354,25 +433,35 @@ padding: 6px 8px; } - /* Exercise Main Row - Allow wrapping */ - .workout-exercise-main { - flex-wrap: wrap; + /* Exercise & Set Main Row - Prevent wrapping to keep timer on right */ + .workout-exercise-main, + .workout-set-main { + flex-wrap: nowrap; align-items: center; gap: 8px; } - /* Exercise Name - Natural width */ + /* Exercise Name & Set Label - Allow text wrap if space is tight */ .workout-exercise-name { min-width: auto; flex: 0 1 auto; + white-space: normal; + word-break: break-word; + } + + .workout-set-label { + min-width: 5ch; + flex: 0 1 auto; + white-space: normal; + word-break: break-word; } - /* Params Container - Own line but compact width */ - .workout-exercise-params { - flex: 0 0 100%; + /* Params Container - Flexible width */ + .workout-exercise-params, + .workout-set-params { + flex: 1 1 auto; flex-wrap: wrap; gap: 6px; - order: 10; justify-content: flex-start; } @@ -384,19 +473,16 @@ font-size: 0.95em; } - /* Input Fields - Much larger for touch */ + /* Input Fields - Much larger for touch, flexible width */ .workout-param-input { - width: 60px; + width: auto; + min-width: 60px; + max-width: 100px; height: 28px; font-size: 1em; padding: 0 4px; } - /* Timer - Keep on right side of first row */ - .workout-exercise-timer { - order: 5; - } - /* Exercise Controls - Smaller gaps */ .workout-exercise-controls { gap: 4px; @@ -421,3 +507,110 @@ font-size: 0.9em; } } + +/* Small Phone Layout (max 480px) */ +@media (max-width: 480px) { + /* Workout Container - Slightly more compact padding */ + .workout-container { + padding: 8px; + } + + /* Header - Keep stacking but potentially smaller gaps/padding */ + .workout-header { + gap: 4px; + margin-bottom: 8px; + padding-bottom: 6px; + } + + /* Exercises Container - Tighter gaps */ + .workout-exercises { + gap: 2px; + } + + /* Individual Exercise - More compact */ + .workout-exercise { + padding: 4px 6px; + } + + /* Exercise & Set Main Row - Tighter gaps */ + .workout-exercise-main, + .workout-set-main { + gap: 4px; + } + + /* Params Container - Tighter gaps */ + .workout-exercise-params{ + gap: 2px; + } + .workout-set-params { + gap: 4px; + } + + /* Individual Param Pills - Smaller touch targets, compact width */ + .workout-param { + height: 32px; + padding: 0 8px; + font-size: 0.9em; + } + + /* Input Fields - Smaller for touch, flexible width */ + .workout-param-input { + min-width: 45px; + max-width: 80px; + height: 24px; + font-size: 0.9em; + } + + /* Buttons - Even smaller for very small screens */ + .workout-btn { + padding: 4px 6px; + font-size: 0.75em; + min-height: 32px; + } +} + +/* Rest Timer Color Phases */ +@keyframes rest-green-pulse { + 0%, 100% { + background-color: rgba(76, 175, 80, 0.1); + } + 50% { + background-color: rgba(76, 175, 80, 0.3); + } +} + +@keyframes rest-yellow-pulse { + 0%, 100% { + background-color: rgba(255, 193, 7, 0.1); + } + 50% { + background-color: rgba(255, 193, 7, 0.3); + } +} + +@keyframes rest-red-pulse { + 0%, 100% { + background-color: rgba(244, 67, 54, 0.1); + } + 50% { + background-color: rgba(244, 67, 54, 0.4); + } +} + +/* Green phase: 66-100% remaining - Calm pulsing green */ +.workout-set.rest-phase-green { + animation: rest-green-pulse 2s ease-in-out infinite; + border-left: 4px solid rgba(76, 175, 80, 0.6); +} + +/* Yellow phase: 33-66% remaining - Faster pulsing yellow */ +.workout-set.rest-phase-yellow { + animation: rest-yellow-pulse 1s ease-in-out infinite; + border-left: 4px solid rgba(255, 193, 7, 0.8); +} + +/* Red phase: 0-33% remaining - Rapid pulsing red */ +.workout-set.rest-phase-red { + animation: rest-red-pulse 0.5s ease-in-out infinite; + border-left: 4px solid rgba(244, 67, 54, 0.95); +} diff --git a/tsconfig.json b/tsconfig.json index 222535d..b5cfa81 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,10 +21,14 @@ "DOM", "ES5", "ES6", - "ES7" + "ES7", + "ES2019" ] }, "include": [ "src/**/*.ts" + ], + "exclude": [ + "**/*.test.ts" ] }