From b0d82eb580db45271ddd4dee7bf77a727f2e9b0a Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Wed, 8 Apr 2026 11:05:00 -0500 Subject: [PATCH 01/42] Added nested sets, tokenizer instead of regex, issue with timer and next button on last set of exercise --- IMPLEMENTATION_NOTES.md | 170 ++++++++++++++++ NESTED_SETS_FORMAT.md | 72 +++++++ package-lock.json | 4 +- package.json | 2 +- sample-workout.md | 47 ++++- src/main.ts | 207 +++++++++++++------- src/parser/exercise.ts | 159 +++++++++++---- src/parser/index.ts | 57 +++++- src/renderer/exercise.ts | 409 +++++++++++++++++++++++++++++++++++---- src/renderer/index.ts | 86 ++++++-- src/serializer.ts | 325 ++++++++++++++++++++++--------- src/timer/manager.ts | 204 +++++++++++++++++-- src/types.ts | 40 +++- styles.css | 71 +++++++ 14 files changed, 1560 insertions(+), 293 deletions(-) create mode 100644 IMPLEMENTATION_NOTES.md create mode 100644 NESTED_SETS_FORMAT.md diff --git a/IMPLEMENTATION_NOTES.md b/IMPLEMENTATION_NOTES.md new file mode 100644 index 0000000..ee67ae2 --- /dev/null +++ b/IMPLEMENTATION_NOTES.md @@ -0,0 +1,170 @@ +# Nested Sets Implementation Summary + +This document describes the implementation of nested sets support in the Obsidian Workout Log plugin. + +## Overview + +Previously, each exercise instance represented a single set. Multiple sets for the same exercise required duplicating the entire exercise line. Now, exercises can contain multiple sets as indented sub-items, enabling per-set tracking of reps and weights. + +## Architecture Changes + +### Type System (`types.ts`) + +#### New: `ExerciseSet` Interface +```typescript +export interface ExerciseSet { + state: ExerciseState; + params: ExerciseParam[]; + lineIndex: number; +} +``` + +#### Updated: `Exercise` Interface +```typescript +export interface Exercise { + state: ExerciseState; + name: string; + params: ExerciseParam[]; // Exercise-level params (typically Duration) + sets: ExerciseSet[]; // Nested sets + targetDuration?: number; + recordedDuration?: string; + lineIndex: number; +} +``` + +### Parser (`parser/`) + +#### `parser/index.ts` - `parseWorkout()` +- Detects indentation to distinguish between parent exercises and child sets +- Groups indented lines as sets under the parent exercise +- When an exercise has no sets (backward compatibility), creates a default set from exercise params + +#### `parser/exercise.ts` - New Functions +- `parseExercise()`: Now returns Exercise with `sets: []` initialized +- `parseSet()`: Parses indented lines as sets (no exercise name, just params) + +#### Format Parsing Logic +``` +- [ ] Exercise Name | Duration: [180s] → Exercise with params + - [ ] Reps: [10] | Weight: [225] lbs → Set (converted as set params) + - [ ] Reps: [8] | Weight: [235] lbs → Set +``` + +Result: Exercise has `params: [Duration]` and `sets: [{params: [Reps, Weight]}, {params: [Reps, Weight]}]` + +### Serializer (`serializer.ts`) + +#### Updated Functions +- `serializeWorkout()`: Now serializes exercises + their sets +- `createSampleWorkout()`: Creates exercises with sample sets + +#### New Functions +- `updateSetParamValue()`: Updates a specific param in a set +- `updateSetState()`: Updates state of a specific set +- `setSetRecordedDuration()`: Records duration for a set +- `addSet()`: Adds a new set to an exercise (modified from duplicating exercises) + +#### New Exports +- `serializeSet()`: Formats a set as an indented markdown line + +### Renderer (`renderer/`) + +#### `renderer/index.ts` +- No major changes; uses same iteration pattern for exercises + +#### `renderer/exercise.ts` - Significant Updates +- `ExerciseElements` now tracks `setInputs: Map>` +- New `renderSet()` function: Renders each set as an indented row with checkbox and params +- Sets appear visually indented under the exercise +- Each set has its own state icon and parameter inputs + +#### UI Structure +``` +[○] Bench Press | Duration: 5m + [○] Set 1 ×10 185 lbs + [○] Set 2 ×8 185 lbs + [○] Set 3 ×6 185 lbs +``` + +### Callbacks (`types.ts`) + +#### New Callback +```typescript +onSetParamChange: (exerciseIndex: number, setIndex: number, paramKey: string, newValue: string) => void; +``` + +#### Main.ts Implementation +- Implements `onSetParamChange` to update in-memory state +- Marks changes as pending, waits for flush before saving to file + +## Backward Compatibility + +The implementation fully supports the old format (exercise-level params): + +``` +- [ ] Bench Press | Reps: [10] | Weight: [225] lbs +``` + +When parsed: +1. Recognized as Exercise with no indented sets +2. Automatically creates a default set containing all params +3. Serializes back with sets indented, or to legacy format if needed + +## "+ Set" Button Behavior + +When clicked during workout: +1. Records current set's duration (timer state) +2. Adds new pending set to exercise +3. Advances timer to new set +4. User can modify reps/weight for new set +5. "+ Next" button (or "+ Set" again) moves workflow forward + +## File Update Flow + +### When Set Parameters Change +1. `onSetParamChange` called → updates `currentParsed` in memory +2. Sets `hasPendingChanges = true` +3. On blur/focusout, `onFlushChanges()` → `updateFile()` +4. `serializeWorkout()` writes exercise + all sets to file + +### Example Output +``` +- [ ] Bench Press | Duration: [300s] + - [ ] Reps: [10] | Weight: [225] lbs + - [ ] Reps: [8] | Weight: [235] lbs +``` + +## Limitations & Future Work + +### Current Limitations +- Set state is tracked but not prominently displayed during workout +- Callbacks for set-level skip, pause, etc. not yet implemented +- Timer assumes single active exercise (not per-set granularity) + +### Future Enhancements +- Per-set timers with individual countdown/count-up +- Set-level skip button +- Progressive set tracking (decreasing reps/increasing weight visualization) +- Set templates (e.g., "3×10" auto-expands to 3 sets) + +## Testing + +### Sample Formats +See [sample-workout.md](sample-workout.md) for: +- **Upper Body Strength**: Example with nested sets +- **Full Body**: Legacy format (old single-line format) +- **Morning Mobility**: Mixed format demo + +### Parsing Examples +All test cases in sample files should parse correctly to Exercise objects with nested sets. + +## Code Locations + +| File | Change | +|------|--------| +| `src/types.ts` | Added `ExerciseSet` interface, updated `Exercise` | +| `src/parser/index.ts` | Added set grouping logic in `parseWorkout()` | +| `src/parser/exercise.ts` | Added `parseSet()`, updated `parseExercise()`, added `serializeSet()` | +| `src/serializer.ts` | Added set-level functions, updated `createSampleWorkout()` | +| `src/renderer/exercise.ts` | Added `renderSet()`, updated `ExerciseElements` interface | +| `src/main.ts` | Added `onSetParamChange` callback implementation | 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/package-lock.json b/package-lock.json index 8747733..7060a54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-workout-log", - "version": "1.0.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-workout-log", - "version": "1.0.0", + "version": "1.1.1", "license": "MIT", "dependencies": { "obsidian": "latest" diff --git a/package.json b/package.json index 459d2b3..14a9e82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-workout-log", - "version": "1.1.1", + "version": "1.1.2", "description": "A block-rendered workout tracker for Obsidian with timer functionality", "main": "main.js", "type": "module", 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/main.ts b/src/main.ts index 6c108ef..8ea9a49 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,11 @@ 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, 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 { formatDurationHuman, parseDurationToSeconds } from './parser/exercise'; export default class WorkoutLogPlugin extends Plugin { private timerManager: TimerManager = new TimerManager(); @@ -136,42 +136,79 @@ export default class WorkoutLogPlugin extends Plugin { const exercise = currentParsed.exercises[exerciseIndex]; if (!exercise) return; - // Record duration + const activeSetIndex = this.timerManager.getActiveSetIndex(workoutId); const timerState = this.timerManager.getTimerState(workoutId); + + // If in rest mode, exit rest and advance to next set + if (timerState?.isRestActive) { + const nextSetIndex = activeSetIndex + 1; + if (nextSetIndex < exercise.sets.length) { + currentParsed = updateSetState(currentParsed, exerciseIndex, nextSetIndex, 'inProgress'); + this.timerManager.exitRest(workoutId, exerciseIndex, nextSetIndex); + await updateFile(currentParsed); + } + return; + } + + // Record duration for current set if (timerState) { - currentParsed = setRecordedDuration( + currentParsed = setSetRecordedDuration( currentParsed, exerciseIndex, + activeSetIndex, formatDurationHuman(timerState.exerciseElapsed) ); } - // Mark as completed - currentParsed = updateExerciseState(currentParsed, exerciseIndex, 'completed'); - - // Find next pending exercise - const nextPending = currentParsed.exercises.findIndex( - (e, i) => i > exerciseIndex && e.state === 'pending' - ); - - if (nextPending >= 0) { - // Activate next exercise - currentParsed = updateExerciseState(currentParsed, nextPending, 'inProgress'); + // Mark current set as completed + currentParsed = updateSetState(currentParsed, exerciseIndex, activeSetIndex, 'completed'); + + // Check if there are more sets in this exercise + if (activeSetIndex < exercise.sets.length - 1) { + // Check if current set has a rest period + const currentSet = exercise.sets[activeSetIndex]; if (!currentSet) return; + const restParam = currentSet.params.find(p => p.key.toLowerCase() === 'rest'); + + if (restParam) { + // Start rest timer instead of immediately advancing + const restDurationSeconds = parseDurationToSeconds(restParam.value); + this.timerManager.startRest(workoutId, restDurationSeconds); + await updateFile(currentParsed); + } else { + // No rest, advance immediately to next set + const nextSetIndex = activeSetIndex + 1; + currentParsed = updateSetState(currentParsed, exerciseIndex, nextSetIndex, 'inProgress'); + this.timerManager.advanceSet(workoutId, exerciseIndex, nextSetIndex); + await updateFile(currentParsed); + } + } else { + // No more sets, finish the exercise and move to next exercise + currentParsed = updateExerciseState(currentParsed, exerciseIndex, 'completed'); - // Advance timer BEFORE file update so re-render sees reset timer - this.timerManager.advanceExercise(workoutId, nextPending); + // Find next pending exercise + const nextPending = currentParsed.exercises.findIndex( + (e, i) => i > exerciseIndex && e.state === 'pending' + ); - 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); + 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); + + 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); } - currentParsed = lockAllFields(currentParsed); - await updateFile(currentParsed); - this.timerManager.stopWorkoutTimer(workoutId); } }, @@ -180,27 +217,31 @@ export default class WorkoutLogPlugin extends Plugin { const exercise = currentParsed.exercises[exerciseIndex]; if (!exercise) return; + const activeSetIndex = this.timerManager.getActiveSetIndex(workoutId); + // Record duration for current set 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'); + // Mark current set as completed + currentParsed = updateSetState(currentParsed, exerciseIndex, activeSetIndex, 'completed'); - // Add new set (inserts after current) + // Add new set currentParsed = addSet(currentParsed, exerciseIndex); - // The new set is at exerciseIndex + 1, activate it - currentParsed = updateExerciseState(currentParsed, exerciseIndex + 1, 'inProgress'); + // The new set is at exercise.sets.length - 1, activate it + const newSetIndex = currentParsed.exercises[exerciseIndex]?.sets.length || 0 - 1; + currentParsed = updateSetState(currentParsed, exerciseIndex, newSetIndex, 'inProgress'); - // Advance timer BEFORE file update so re-render sees reset timer - this.timerManager.advanceExercise(workoutId, exerciseIndex + 1); + // Advance timer to new set + this.timerManager.advanceSet(workoutId, exerciseIndex, newSetIndex); await updateFile(currentParsed); }, @@ -211,70 +252,79 @@ export default class WorkoutLogPlugin extends Plugin { const restDuration = currentParsed.metadata.restDuration; if (!exercise || !restDuration) return; - // Record duration for current exercise + const activeSetIndex = this.timerManager.getActiveSetIndex(workoutId); + + // Record duration for current set 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'); + // Mark current set as completed + currentParsed = updateSetState(currentParsed, exerciseIndex, activeSetIndex, 'completed'); // Add rest exercise (inserts after current) currentParsed = addRest(currentParsed, exerciseIndex, restDuration); - // The new rest is at exerciseIndex + 1, activate it + // The new rest is at exerciseIndex + 1, activate it with its first set currentParsed = updateExerciseState(currentParsed, exerciseIndex + 1, 'inProgress'); - // Advance timer BEFORE file update so re-render sees reset timer - this.timerManager.advanceExercise(workoutId, exerciseIndex + 1); + // Advance timer to new exercise with set index 0 + this.timerManager.advanceSet(workoutId, exerciseIndex + 1, 0); await updateFile(currentParsed); }, 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' - ); + const exercise = currentParsed.exercises[exerciseIndex]; + if (!exercise) return; - if (nextPending >= 0) { - currentParsed = updateExerciseState(currentParsed, nextPending, 'inProgress'); + const activeSetIndex = this.timerManager.getActiveSetIndex(workoutId); - // Advance timer BEFORE file update so re-render sees reset timer - this.timerManager.advanceExercise(workoutId, nextPending); + // Mark current set as skipped + currentParsed = updateSetState(currentParsed, exerciseIndex, activeSetIndex, 'skipped'); + // Check if there are more sets in this exercise + if (activeSetIndex < exercise.sets.length - 1) { + // Advance to next set + const nextSetIndex = activeSetIndex + 1; + currentParsed = updateSetState(currentParsed, exerciseIndex, nextSetIndex, 'inProgress'); + this.timerManager.advanceSet(workoutId, exerciseIndex, nextSetIndex); 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); + // No more sets, finish the exercise and move to next exercise + currentParsed = updateExerciseState(currentParsed, exerciseIndex, 'completed'); - // Stop timer BEFORE file update - this.timerManager.stopWorkoutTimer(workoutId); + // Find next pending exercise + const nextPending = currentParsed.exercises.findIndex( + (e, i) => i > exerciseIndex && e.state === 'pending' + ); - await updateFile(currentParsed); + 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); + + await updateFile(currentParsed); + this.timerManager.stopWorkoutTimer(workoutId); + } } }, @@ -290,6 +340,19 @@ export default class WorkoutLogPlugin extends Plugin { // Don't save to file yet - wait for flush }, + onSetParamChange: (exerciseIndex: number, setIndex: number, paramKey: string, newValue: string): void => { + // Check if value actually changed + 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 update + } + currentParsed = updateSetParamValue(currentParsed, exerciseIndex, setIndex, paramKey, newValue); + hasPendingChanges = true; + // Don't save to file yet - wait for flush + }, + onFlushChanges: flushChanges, onPauseExercise: (): void => { diff --git a/src/parser/exercise.ts b/src/parser/exercise.ts index 4c968e7..0da8a4a 100644 --- a/src/parser/exercise.ts +++ b/src/parser/exercise.ts @@ -1,8 +1,6 @@ -import { Exercise, ExerciseState, ExerciseParam } from '../types'; +import { Exercise, ExerciseSet, ExerciseState, ExerciseParam, Token } from '../types'; // Checkbox patterns: [ ] pending, [\] inProgress, [x] completed, [-] skipped -const EXERCISE_PATTERN = /^-\s*\[(.)\]\s*(.+)$/; - const STATE_MAP: Record = { ' ': 'pending', '\\': 'inProgress', @@ -17,21 +15,37 @@ 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+(.+))?$/; +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; - const stateChar = match[1] ?? ' '; - const rest = match[2] ?? ''; + // Get remainder after closing bracket + const afterBracket = line.substring(closeBracketIndex + 1).trimStart(); + if (!afterBracket) return null; + + return { stateChar, remainder: afterBracket }; +} + +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); @@ -61,43 +75,98 @@ export function parseExercise(line: string, lineIndex: number): Exercise | null state, name, params, + sets: [], targetDuration, recordedDuration, lineIndex }; } -function parseParam(paramStr: string): ExerciseParam | null { - // Handle simple format: Key: value or Key: [value] or Key: [value] unit +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[] = []; + + for (const paramStr of paramStrings) { + const param = parseParam(paramStr); + if (param) { + params.push(param); + } + } + + return { + state, + params, + lineIndex + }; +} + +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(); + tokens.push({ type: 'key', value: key }); + + // 2. Parse value and unit (after colon) + let remainder = 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 - }; + 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 value - read until space + const spaceIndex = remainder.indexOf(' '); + if (spaceIndex === -1) { + // No space, entire remainder is value + tokens.push({ type: 'value', value: remainder }); + return tokens; + } else { + tokens.push({ type: 'value', value: remainder.substring(0, spaceIndex) }); + remainder = remainder.substring(spaceIndex).trim(); + } } - // 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; + // 3. Parse unit (whatever remains) + if (remainder) { + tokens.push({ type: 'unit', value: remainder }); + } + + return tokens; +} + +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; + + const unitToken = tokens.find(t => t.type === 'unit'); return { - key, - value, - editable: false, - unit + key: keyToken.value, + value: valueToken.value, + editable: valueToken.type === 'bracket', + unit: unitToken?.value }; } @@ -179,3 +248,23 @@ export function serializeExercise(exercise: Exercise): string { return line; } + +export function serializeSet(set: ExerciseSet): string { + const stateChar = getStateChar(set.state); + let line = ` - [${stateChar}]`; + + for (const param of set.params) { + line += ' | '; + line += `${param.key}: `; + if (param.editable) { + line += `[${param.value}]`; + } else { + line += param.value; + } + if (param.unit) { + line += ` ${param.unit}`; + } + } + + return line; +} diff --git a/src/parser/index.ts b/src/parser/index.ts index c6a7104..07ad896 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -1,6 +1,6 @@ -import { ParsedWorkout } from '../types'; +import { ParsedWorkout, Exercise, ExerciseSet } from '../types'; import { parseMetadata } from './metadata'; -import { parseExercise } from './exercise'; +import { parseExercise, parseSet } from './exercise'; export function parseWorkout(source: string): ParsedWorkout { const rawLines = source.split('\n'); @@ -20,21 +20,60 @@ export function parseWorkout(source: string): ParsedWorkout { : []; const metadata = parseMetadata(metadataLines); - // Parse exercises (lines after ---) + // Parse exercises (lines after ---), handling nested sets 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) { + // This is a set (indented line) + if (currentExercise) { + const set = parseSet(line, i); + if (set) { + currentExercise.sets.push(set); + } + } + } else { + // This is a parent exercise (no indent) + // Save previous exercise if it has no sets, create a default one + if (currentExercise && currentExercise.sets.length === 0) { + currentExercise.sets.push({ + state: currentExercise.state, + params: currentExercise.params, + lineIndex: currentExercise.lineIndex + }); + 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 last exercise: if it has no sets, create one from its params + if (currentExercise && currentExercise.sets.length === 0) { + currentExercise.sets.push({ + state: currentExercise.state, + params: currentExercise.params, + lineIndex: currentExercise.lineIndex + }); + currentExercise.params = []; + } + return { metadata, exercises, @@ -44,4 +83,4 @@ export function parseWorkout(source: string): ParsedWorkout { } export { parseMetadata, serializeMetadata } from './metadata'; -export { parseExercise, serializeExercise, formatDuration, formatDurationHuman, parseDurationToSeconds, getStateChar } from './exercise'; +export { parseExercise, parseSet, serializeExercise, serializeSet, formatDuration, formatDurationHuman, parseDurationToSeconds, getStateChar } from './exercise'; diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index b61e0be..a476a26 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -1,5 +1,5 @@ -import { Exercise, ExerciseState, TimerState, WorkoutCallbacks } from '../types'; -import { formatDuration } from '../parser/exercise'; +import { Exercise, ExerciseSet, ExerciseParam, ExerciseState, TimerState, WorkoutCallbacks } from '../types'; +import { formatDuration, parseDurationToSeconds, formatDurationHuman } from '../parser/exercise'; const STATE_ICONS: Record = { 'pending': '○', @@ -23,12 +23,91 @@ function nameToHue(name: string): number { 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 set has params to display (excludes Duration which is shown separately) +function hasDisplayableSetParams(set: ExerciseSet): boolean { + return set.params.some(p => p.key.toLowerCase() !== 'duration'); +} + +// Get params to display inline (excludes Duration and recorded values) +function getDisplayableSetParams(set: ExerciseSet): ExerciseParam[] { + return set.params.filter(p => p.key.toLowerCase() !== 'duration'); +} + +// Get recorded duration from a set (if any) +function getSetRecordedDuration(set: ExerciseSet): string | null { + const durationParam = set.params.find(p => p.key.toLowerCase() === 'duration' && !p.editable); + return durationParam ? durationParam.value : null; +} + +// Get rest duration from a set (if any) +function getSetRestDuration(set: ExerciseSet): string | null { + const restParam = set.params.find(p => p.key.toLowerCase() === 'rest'); + return restParam ? restParam.value : null; +} + +// Compute totals 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; // Use last weight value (assume same for all sets) + weightFound = true; + } + } else if (param.key.toLowerCase() === 'rest') { + // Sum rest durations from all sets (only if editable, meaning user-set) + if (param.editable) { + const restSeconds = parseDurationToSeconds(param.value); + totalRest += restSeconds; + } + } + } + + // If exercise is completed, sum up recorded durations from each set + if (isCompleted) { + for (const param of set.params) { + if (param.key.toLowerCase() === 'duration' && !param.editable) { + // This is a recorded duration (not editable means it's recorded) + const seconds = parseDurationToSeconds(param.value); + totalRecordedTime += seconds; + } + } + } + } + + return { + reps: repsFound ? totalReps : null, + weight: weightFound ? totalWeight : null, + duration: targetDuration, + totalRecordedTime, + totalRest + }; } export function renderExercise( @@ -36,14 +115,15 @@ export function renderExercise( 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,6 +131,8 @@ 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 updates // Single row: icon | name | params | timer const mainRow = exerciseEl.createDiv({ cls: 'workout-exercise-main' }); @@ -63,8 +145,43 @@ export function renderExercise( const nameEl = mainRow.createSpan({ cls: 'workout-exercise-name' }); nameEl.textContent = exercise.name; + // Display totals from all sets (reps, weight, etc.) + const isCompleted = exercise.state === 'completed'; + const totals = computeExerciseTotals(exercise, isCompleted); + + if (totals.reps !== null || totals.weight !== null || (isCompleted && totals.totalRecordedTime > 0) || totals.totalRest > 0) { + const totalsEl = mainRow.createSpan({ cls: 'workout-exercise-totals' }); + + // Show total reps + if (totals.reps !== null) { + const repsEl = totalsEl.createSpan({ cls: 'workout-total' }); + repsEl.createSpan({ cls: 'workout-param-prefix', text: '×' }); + repsEl.createSpan({ cls: 'workout-param-value', text: String(totals.reps) }); + } + + // Show weight + if (totals.weight !== null) { + const weightEl = totalsEl.createSpan({ cls: 'workout-total' }); + weightEl.createSpan({ cls: 'workout-param-value', text: String(totals.weight) }); + weightEl.createSpan({ cls: 'workout-param-unit', text: ' lbs' }); + } + + // Show total recorded time when completed + if (isCompleted && totals.totalRecordedTime > 0) { + const timeEl = totalsEl.createSpan({ cls: 'workout-total' }); + timeEl.createSpan({ cls: 'workout-param-value', text: formatDurationHuman(totals.totalRecordedTime) }); + } + + // Show total rest time + if (totals.totalRest > 0) { + const restEl = totalsEl.createSpan({ cls: 'workout-total workout-rest' }); + restEl.createSpan({ cls: 'workout-param-prefix', text: '⏸' }); + restEl.createSpan({ cls: 'workout-param-value', text: formatDurationHuman(totals.totalRest) }); + } + } + // Params inline (between name and timer) - chip/pill style - if (hasDisplayableParams(exercise)) { + if (exercise.params.length > 0) { const paramsEl = mainRow.createSpan({ cls: 'workout-exercise-params' }); for (const param of exercise.params) { @@ -105,34 +222,226 @@ export function renderExercise( } } - // Timer display (right side) - const 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 = '--'; + // Timer display (right side) - only if no sets + // (timer shows on active set when sets exist) + let timerEl: HTMLElement | null = null; + + if (exercise.sets.length === 0) { + 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 = '--'; + } + } + + // Render sets as indented rows + if (exercise.sets.length > 0) { + const setsContainer = exerciseEl.createDiv({ cls: 'workout-sets' }); + for (let setIndex = 0; setIndex < exercise.sets.length; setIndex++) { + const set = exercise.sets[setIndex]; + if (!set) continue; + + 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 exercise during workout) + // 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 }; +} + +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); +} + +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 = STATE_ICONS[set.state]; + + // Set label + const labelEl = setRow.createSpan({ cls: 'workout-set-label' }); + labelEl.textContent = `Set ${setIndex + 1}`; + + // Set params 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 paramEl = paramsEl.createSpan({ cls: 'workout-param' }); + + // × prefix for params without units + if (!param.unit) { + paramEl.createSpan({ cls: 'workout-param-prefix', text: '×' }); + } + + if (param.editable && workoutState !== 'completed') { + const input = paramEl.createEl('input', { + cls: 'workout-param-input', + type: 'text', + value: param.value + }); + input.addEventListener('input', () => { + callbacks.onSetParamChange(exerciseIndex, setIndex, param.key, input.value); + }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + input.blur(); + } + }); + setParamInputs.set(param.key, 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}` }); + } + } + + setInputs.set(setIndex, setParamInputs); + } + + // Show recorded duration for completed sets (similar to exercise totals) + const recordedDuration = getSetRecordedDuration(set); + if (recordedDuration && workoutState === 'completed') { + const setDurationEl = setRow.createSpan({ cls: 'workout-set-duration' }); + setDurationEl.createSpan({ cls: 'workout-param-value', text: recordedDuration }); } - return { container: exerciseEl, timerEl, inputs }; + // Show Rest duration if set has one + const restDurationStr = getSetRestDuration(set); + if (restDurationStr) { + const restEl = setRow.createSpan({ cls: 'workout-set-rest-info' }); + restEl.createSpan({ cls: 'workout-param-prefix', text: '⏸' }); + restEl.createSpan({ cls: 'workout-param-value', text: restDurationStr }); + } + + // Timer display for active set (right side) + let timerEl: HTMLElement | null = null; + if (isActive && timerState) { + timerEl = setRow.createSpan({ cls: 'workout-set-timer' }); + + // If in rest mode, show rest timer + if (timerState.isRestActive && timerState.restRemaining !== undefined) { + const restDuration = restDurationStr ? parseDurationToSeconds(restDurationStr) : 0; + const remaining = timerState.restRemaining; + + // Calculate rest progress and apply color phase class + if (restDuration > 0) { + const restProgress = remaining / restDuration; + + if (restProgress > 0.66) { + // Green phase: 66-100% remaining + setEl.removeClass('rest-phase-yellow'); + setEl.removeClass('rest-phase-red'); + setEl.addClass('rest-phase-green'); + } else if (restProgress > 0.33) { + // Yellow phase: 33-66% remaining + setEl.removeClass('rest-phase-green'); + setEl.removeClass('rest-phase-red'); + setEl.addClass('rest-phase-yellow'); + } else { + // Red phase: 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.createSpan({ cls: 'timer-indicator rest', text: ' ⏸' }); + } else { + timerEl.textContent = formatDuration(Math.abs(remaining)); + timerEl.addClass('rest-overtime'); + timerEl.createSpan({ cls: 'timer-indicator', text: ' ⏸' }); + } + } else { + // Show exercise timer + updateExerciseTimer(timerEl, timerState, undefined); + } + } + + return timerEl; } -function renderExerciseControls( +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' }); @@ -151,30 +460,60 @@ function renderExerciseControls( // Skip button const skipBtn = controlsEl.createEl('button', { cls: 'workout-btn', text: 'Skip' }); skipBtn.addEventListener('click', () => { - callbacks.onExerciseSkip(index); + callbacks.onExerciseSkip(exerciseIndex); }); // Finish group container const finishGroup = controlsEl.createDiv({ cls: 'workout-btn-group' }); - // Add Set button + // Add Set button (for additional sets) 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) 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 button text based on rest state + let nextBtnText: string; + if (timerState?.isRestActive) { + // During rest, show "Skip Rest" or "Start Next" + nextBtnText = 'Start Next'; + } else { + const isLastSet = setIndex === totalSets - 1; + const isLastExercise = typeof totalExercises === 'number' ? (exerciseIndex === totalExercises - 1) : false; + if (isLastSet && !isLastExercise) { + nextBtnText = 'Next'; + } else if (isLastSet && isLastExercise) { + nextBtnText = 'Done'; + } else { + nextBtnText = 'Next Set'; + } + } + + const nextBtn = finishGroup.createEl('button', { cls: 'workout-btn', text: nextBtnText }); nextBtn.addEventListener('click', () => { - callbacks.onExerciseFinish(index); + if (timerState?.isRestActive) { + // Skip rest and advance to next set + const nextSetIndex = setIndex + 1; + callbacks.onExerciseFinish(exerciseIndex); + } else { + const isLastSet = setIndex === totalSets - 1; + const isLastExercise = typeof totalExercises === 'number' ? (exerciseIndex === totalExercises - 1) : false; + if (isLastSet && !isLastExercise && typeof callbacks.onSetFinish === 'function') { + // Last set, but not last exercise: trigger rest for last set + callbacks.onSetFinish(exerciseIndex, setIndex); + } else { + // Otherwise, finish set/exercise as usual + callbacks.onExerciseFinish(exerciseIndex); + } + } }); } diff --git a/src/renderer/index.ts b/src/renderer/index.ts index 3e140d4..60d99fd 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -53,20 +53,57 @@ export function renderWorkout(ctx: RendererContext): void { // Approximate character width (will be refined by CSS) exercisesContainer.style.setProperty('--max-name-chars', String(maxNameLength)); + // Provide onSetFinish callback for last set rest logic + const enhancedCallbacks: WorkoutCallbacks = { + ...callbacks, + onSetFinish: (exerciseIndex: number, setIndex: number) => { + const exercise = parsed.exercises[exerciseIndex]; + if (!exercise || !exercise.sets) return; + const set = exercise.sets[setIndex]; + if (!set) return; + const restParam = set.params.find(p => p.key.toLowerCase() === 'rest'); + if (!restParam) { + // No rest parameter, just advance + callbacks.onExerciseFinish(exerciseIndex); + return; + } + // Parse rest duration - value could be like "60" or "60s" + const restDurationStr = (restParam.value || '').trim(); + const restSeconds = parseInt(restDurationStr, 10); + if (restSeconds > 0) { + // Start rest timer + timerManager.startRest(workoutId, restSeconds); + // Immediately notify subscribers so UI updates without waiting for next timer tick + try { + timerManager.notifySubscribers(workoutId); + } catch (e) { + // Silently ignore if notifySubscribers fails - rest will still update on next tick + } + // Do NOT mark set as completed yet; wait for rest to finish and auto-advance + } else { + // No valid rest duration, immediately mark set as completed and advance + callbacks.onExerciseFinish(exerciseIndex); + } + } + }; + for (let i = 0; i < parsed.exercises.length; i++) { const exercise = parsed.exercises[i]; if (!exercise) continue; const isActive = i === initialActiveIndex; + const activeSetIndex = isActive ? timerManager.getActiveSetIndex(workoutId) : -1; const elements = renderExercise( exercisesContainer, exercise, i, isActive, + activeSetIndex, isActive ? timerState : null, - callbacks, + enhancedCallbacks, parsed.metadata.state, - parsed.metadata.restDuration + parsed.metadata.restDuration, + parsed.exercises.length // totalExercises ); exerciseElements.push(elements); } @@ -103,25 +140,38 @@ export function renderWorkout(ctx: RendererContext): void { return; } - // Update active exercise timer + // Update active exercise timer or set timer const activeElements = exerciseElements[currentActiveIndex]; const activeExercise = parsed.exercises[currentActiveIndex]; - 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); - } + if (activeExercise) { + // Update set timer if sets exist and active set timer element exists + if (activeElements?.setTimerEl) { + updateExerciseTimer( + activeElements.setTimerEl, + state, + undefined // Set timers are count-up, not countdown + ); + } else if (activeElements?.timerEl) { + // Update exercise timer if no sets + updateExerciseTimer( + activeElements.timerEl, + state, + activeExercise.targetDuration + ); + } + + // Check for auto-advance on rest completion for last set (onSetFinish case) + const isLastSet = Array.isArray(activeExercise.sets) && activeElements?.setTimerEl && + (timerManager.getActiveSetIndex(workoutId) === activeExercise.sets.length - 1); + const isLastExercise = currentActiveIndex === parsed.exercises.length - 1; + if ( + isLastSet && !isLastExercise && state.isRestActive && + typeof state.restRemaining === 'number' && state.restRemaining <= 0 && !hasAutoAdvanced + ) { + hasAutoAdvanced = true; + // Now mark set as completed and advance + callbacks.onExerciseFinish(currentActiveIndex); } } }); diff --git a/src/serializer.ts b/src/serializer.ts index c42b3f0..5c0e2b4 100644 --- a/src/serializer.ts +++ b/src/serializer.ts @@ -1,6 +1,6 @@ -import { ParsedWorkout, Exercise, ExerciseState } from './types'; +import { ParsedWorkout, Exercise, ExerciseState, ExerciseSet } from './types'; import { serializeMetadata } from './parser/metadata'; -import { serializeExercise, getStateChar } from './parser/exercise'; +import { serializeExercise, serializeSet, getStateChar } from './parser/exercise'; export function serializeWorkout(parsed: ParsedWorkout): string { const lines: string[] = []; @@ -12,15 +12,20 @@ export function serializeWorkout(parsed: ParsedWorkout): string { // Add separator lines.push('---'); - // Serialize exercises + // Serialize exercises with their sets for (const exercise of parsed.exercises) { lines.push(serializeExercise(exercise)); + + // Serialize sets under the exercise + for (const set of exercise.sets) { + lines.push(serializeSet(set)); + } } return lines.join('\n'); } -// Update a specific param value in a workout +// Update a specific param value in a workout (exercise-level params) export function updateParamValue( parsed: ParsedWorkout, exerciseIndex: number, @@ -39,6 +44,29 @@ export function updateParamValue( return newParsed; } +// Update a specific param value in a set +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 exercise state export function updateExerciseState( parsed: ParsedWorkout, @@ -53,6 +81,24 @@ export function updateExerciseState( return newParsed; } +// Update 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 fields (remove brackets) export function lockAllFields(parsed: ParsedWorkout): ParsedWorkout { const newParsed = structuredClone(parsed); @@ -61,6 +107,11 @@ 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; @@ -81,6 +132,7 @@ export function addRest(parsed: ParsedWorkout, exerciseIndex: number, restDurati value: `${restDuration}s`, editable: true }], + sets: [], targetDuration: restDuration, lineIndex: currentExercise.lineIndex + 1 }; @@ -97,36 +149,31 @@ export function addRest(parsed: ParsedWorkout, exerciseIndex: number, restDurati return newParsed; } -// Add a new set (duplicate an exercise) +// Add a new set to an exercise 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; } @@ -160,6 +207,70 @@ export function setRecordedDuration( return newParsed; } +// Set Duration param value for a specific set +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; + + // Find Duration param or add one + let durationParam = set.params.find(p => p.key.toLowerCase() === 'duration'); + + if (durationParam) { + durationParam.value = durationStr; + durationParam.editable = false; + } else { + // Add Duration param + set.params.push({ + key: 'Duration', + value: durationStr, + editable: false + }); + } + + return newParsed; +} + +// Update Rest param value for a specific set +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 with comprehensive exercise examples export function createSampleWorkout(): ParsedWorkout { const metadata = { @@ -172,38 +283,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 +349,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 } ]; @@ -259,47 +410,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.ts b/src/timer/manager.ts index d11d761..a703fee 100644 --- a/src/timer/manager.ts +++ b/src/timer/manager.ts @@ -2,8 +2,20 @@ import { TimerInstance, TimerState, TimerCallback } from '../types'; 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; + private lastCalledState: Map = new Map(); + + constructor() { + // Track tab visibility changes + document.addEventListener('visibilitychange', () => { + if (!document.hidden && this.timers.size > 0) { + // Tab just became visible - force immediate update + this.tick(); + } + }); + } setAutoAdvanceCallback(callback: (workoutId: string) => void): void { this.onAutoAdvance = callback; @@ -19,6 +31,10 @@ export class TimerManager { existing.exercisePausedTime = 0; existing.isPaused = false; existing.activeExerciseIndex = activeExerciseIndex; + existing.activeSetIndex = 0; + existing.isRestActive = false; + existing.restPausedTime = 0; + existing.restDuration = 0; } else { this.timers.set(workoutId, { workoutId, @@ -27,11 +43,16 @@ export class TimerManager { exercisePausedTime: 0, isPaused: false, activeExerciseIndex, + activeSetIndex: 0, + isRestActive: false, + restStartTime: now, + restPausedTime: 0, + restDuration: 0, callbacks: new Set() }); } - this.ensureInterval(); + this.ensureFrame(); } advanceExercise(workoutId: string, newExerciseIndex: number): void { @@ -42,6 +63,51 @@ export class TimerManager { timer.exercisePausedTime = 0; timer.isPaused = false; timer.activeExerciseIndex = newExerciseIndex; + timer.activeSetIndex = 0; + timer.isRestActive = false; + timer.restPausedTime = 0; + timer.restDuration = 0; + } + + // Advance to next set within the same exercise + advanceSet(workoutId: string, exerciseIndex: number, setIndex: number): void { + const timer = this.timers.get(workoutId); + if (!timer) return; + + timer.exerciseStartTime = Date.now(); + timer.exercisePausedTime = 0; + timer.isPaused = false; + timer.activeExerciseIndex = exerciseIndex; + timer.activeSetIndex = setIndex; + timer.isRestActive = false; + timer.restPausedTime = 0; + timer.restDuration = 0; + } + + // Start rest period after completing a set + 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; + } + + // Exit rest and advance to next set + exitRest(workoutId: string, nextExerciseIndex: number, nextSetIndex: number): void { + const timer = this.timers.get(workoutId); + if (!timer) return; + + timer.isRestActive = false; + timer.restPausedTime = 0; + timer.restDuration = 0; + timer.exerciseStartTime = Date.now(); + timer.exercisePausedTime = 0; + timer.activeExerciseIndex = nextExerciseIndex; + timer.activeSetIndex = nextSetIndex; } pauseExercise(workoutId: string): void { @@ -51,7 +117,11 @@ export class TimerManager { timer.isPaused = true; // Store how much time has passed for this exercise const now = Date.now(); - timer.exercisePausedTime += now - timer.exerciseStartTime; + if (timer.isRestActive) { + timer.restPausedTime += now - timer.restStartTime; + } else { + timer.exercisePausedTime += now - timer.exerciseStartTime; + } } resumeExercise(workoutId: string): void { @@ -59,15 +129,21 @@ export class TimerManager { if (!timer || !timer.isPaused) return; timer.isPaused = false; - timer.exerciseStartTime = Date.now(); + if (timer.isRestActive) { + timer.restStartTime = Date.now(); + } else { + timer.exerciseStartTime = Date.now(); + } } stopWorkoutTimer(workoutId: string): void { this.timers.delete(workoutId); + this.lastCalledState.delete(workoutId); - if (this.timers.size === 0 && this.intervalId !== null) { - window.clearInterval(this.intervalId); - this.intervalId = null; + if (this.timers.size === 0 && this.frameId !== null) { + cancelAnimationFrame(this.frameId); + this.frameId = null; + this.lastSecond = 0; } } @@ -84,10 +160,24 @@ export class TimerManager { const state = this.getTimerState(workoutId); if (state) { callback(state); + this.lastCalledState.set(workoutId, state); } return () => { timer.callbacks.delete(callback); + + // Clean up if no more subscribers + if (timer.callbacks.size === 0) { + this.timers.delete(workoutId); + this.lastCalledState.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; + } + } }; } @@ -109,10 +199,27 @@ export class TimerManager { exerciseElapsed = Math.floor((timer.exercisePausedTime + currentExerciseTime) / 1000); } + // Rest elapsed (respects pause) + 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 }; } @@ -121,6 +228,11 @@ export class TimerManager { return timer?.activeExerciseIndex ?? 0; } + getActiveSetIndex(workoutId: string): number { + const timer = this.timers.get(workoutId); + return timer?.activeSetIndex ?? 0; + } + setActiveExerciseIndex(workoutId: string, index: number): void { const timer = this.timers.get(workoutId); if (!timer) return; @@ -128,6 +240,7 @@ 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; @@ -138,30 +251,84 @@ export class TimerManager { return this.timers.has(workoutId); } + // Notify all subscribers of current state immediately (used when state changes urgently need UI update) + 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); + } + } + } + isPaused(workoutId: string): boolean { const timer = this.timers.get(workoutId); return timer?.isPaused ?? false; } - private ensureInterval(): void { - if (this.intervalId !== null) return; + private ensureFrame(): void { + if (this.frameId !== null) return; + + const scheduleNextFrame = () => { + this.frameId = requestAnimationFrame(() => { + this.frameId = null; + + const now = Date.now(); + const currentSecond = Math.floor(now / 1000); + + // Only process on actual second change + if (currentSecond !== this.lastSecond) { + this.lastSecond = currentSecond; + this.tick(); + } + + if (this.timers.size > 0) { + scheduleNextFrame(); + } + }); + }; - this.intervalId = window.setInterval(() => { - this.tick(); - }, 1000); + scheduleNextFrame(); } 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); + } } } } + 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 + ); + } + // Called when we need to check for auto-advance (countdown completed) checkAutoAdvance(workoutId: string, targetDuration: number | undefined): void { if (targetDuration === undefined) return; @@ -176,10 +343,11 @@ export class TimerManager { // Cleanup all timers 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(); } } diff --git a/src/types.ts b/src/types.ts index f20b273..9912a9d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,11 @@ import { MarkdownPostProcessorContext, TFile, App } from 'obsidian'; +// Token types for parameter parsing +export interface Token { + type: 'key' | 'bracket' | 'value' | 'unit'; + value: string; +} + // Workout states export type WorkoutState = 'planned' | 'started' | 'completed'; @@ -7,7 +13,7 @@ export type WorkoutState = 'planned' | 'started' | 'completed'; // [ ] = pending, [\] = inProgress, [x] = completed, [-] = skipped export type ExerciseState = 'pending' | 'inProgress' | 'completed' | 'skipped'; -// Key-value pairs for exercise parameters +// Key-value pairs for exercise/set parameters export interface ExerciseParam { key: string; value: string; @@ -15,6 +21,13 @@ 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 +} + // Parsed metadata from the workout block header export interface WorkoutMetadata { title?: string; @@ -24,14 +37,15 @@ export interface WorkoutMetadata { restDuration?: number; // Default rest duration in seconds } -// 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) + recordedDuration?: string; // Recorded duration after completion + lineIndex: number; // Line index relative to exercise section start } // Complete parsed workout block @@ -46,10 +60,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; } @@ -59,6 +78,9 @@ export interface TimerState { exerciseElapsed: number; // Current exercise elapsed seconds remaining?: 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; @@ -72,10 +94,12 @@ export interface WorkoutCallbacks { onExerciseAddRest: (exerciseIndex: number) => Promise; onExerciseSkip: (exerciseIndex: number) => Promise; onParamChange: (exerciseIndex: number, paramKey: string, newValue: string) => void; + onSetParamChange: (exerciseIndex: number, setIndex: number, paramKey: string, newValue: string) => void; onFlushChanges: () => Promise; onPauseExercise: () => void; onResumeExercise: () => void; onAddSample: () => Promise; + onSetFinish?: (exerciseIndex: number, setIndex: number) => void; } // Context passed to renderer diff --git a/styles.css b/styles.css index cd5cf24..7b15180 100644 --- a/styles.css +++ b/styles.css @@ -120,6 +120,31 @@ 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; +} + +/* 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; @@ -421,3 +446,49 @@ font-size: 0.9em; } } + +/* 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); +} From 8845b098c031118afbd4e68721d1802d04fbd98b Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Wed, 8 Apr 2026 11:39:35 -0500 Subject: [PATCH 02/42] Replaced regex matching with tokenizer for better maintainability --- manifest.json | 2 +- src/parser/exercise.ts | 80 +++++++++++++++++++++++++++++------------- src/types.ts | 4 +-- 3 files changed, 58 insertions(+), 28 deletions(-) diff --git a/manifest.json b/manifest.json index 3dfa7b1..9d7cff2 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "workout-log", "name": "Workout Log", - "version": "1.1.1", + "version": "1.1.2", "minAppVersion": "1.5.0", "description": "Render workout blocks as interactive trackers with built-in timers.", "author": "Lukasz", diff --git a/src/parser/exercise.ts b/src/parser/exercise.ts index 4c968e7..84bc93b 100644 --- a/src/parser/exercise.ts +++ b/src/parser/exercise.ts @@ -1,7 +1,4 @@ -import { Exercise, ExerciseState, ExerciseParam } from '../types'; - -// Checkbox patterns: [ ] pending, [\] inProgress, [x] completed, [-] skipped -const EXERCISE_PATTERN = /^-\s*\[(.)\]\s*(.+)$/; +import { Exercise, ExerciseState, ParameterToken } from '../types'; const STATE_MAP: Record = { ' ': 'pending', @@ -17,30 +14,49 @@ const STATE_CHAR_MAP: Record = { 'skipped': '-' }; +// Tokenizer for parsing exercise line +// Format: - [STATE] Exercise Name | Param: value | Param2: [value] unit +function tokenizeExerciseLine(line: string): { + stateChar: string; + remainder: string; +} | null { + // Must start with "- [" + if (!line.startsWith('- [')) return null; + + // Find closing bracket + const closeBracketIdx = line.indexOf(']', 3); + if (closeBracketIdx === -1) return null; + + const stateChar = line[3] ?? ''; + + // Remainder is everything after "] " + const spaceAfterBracket = closeBracketIdx + 1; + if (spaceAfterBracket >= line.length || line[spaceAfterBracket] !== ' ') return null; + + const remainder = line.substring(spaceAfterBracket + 1); + return { stateChar, remainder: remainder }; +} + // Parse value with optional brackets: [value] = editable, value = locked // Also handle Duration special case for timer -const PARAM_PATTERN = /^([^:]+):\s*(\[([^\]]*)\]|([^\s\[]+))(\s+(.+))?$/; - export function parseExercise(line: string, lineIndex: number): Exercise | null { - const match = line.match(EXERCISE_PATTERN); - if (!match) return null; - - const stateChar = match[1] ?? ' '; - const rest = match[2] ?? ''; + const parsed = tokenizeExerciseLine(line); + if (!parsed) return null; - const state = STATE_MAP[stateChar] ?? 'pending'; + const state = STATE_MAP[parsed.stateChar]; + if (!state) return null; // Split by | to get name and params - const parts = rest.split('|').map(p => p.trim()); + const parts = parsed.remainder.split('|').map(p => p.trim()); const name = parts[0] ?? ''; const paramStrings = parts.slice(1); - const params: ExerciseParam[] = []; + const params: ParameterToken[] = []; let targetDuration: number | undefined; let recordedDuration: string | undefined; for (const paramStr of paramStrings) { - const param = parseParam(paramStr); + const param = tokenizeParam(paramStr); if (param) { params.push(param); @@ -67,7 +83,8 @@ export function parseExercise(line: string, lineIndex: number): Exercise | null }; } -function parseParam(paramStr: string): ExerciseParam | null { + +function tokenizeParam(paramStr: string): ParameterToken | null { // Handle simple format: Key: value or Key: [value] or Key: [value] unit const colonIndex = paramStr.indexOf(':'); if (colonIndex === -1) return null; @@ -75,11 +92,16 @@ function parseParam(paramStr: string): ExerciseParam | null { 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(); + // Check for bracketed value using indexOf + const openBracketIdx = rest.indexOf('['); + if (openBracketIdx === 0) { + // Has brackets + const closeBracketIdx = rest.indexOf(']', 1); + if (closeBracketIdx === -1) return null; + + const value = rest.substring(1, closeBracketIdx); + const afterBracket = rest.substring(closeBracketIdx + 1).trim(); + return { key, value, @@ -88,10 +110,18 @@ function parseParam(paramStr: string): ExerciseParam | null { }; } - // 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; + // No brackets - find first space for value and unit + const spaceIdx = rest.indexOf(' '); + let value: string; + let unit: string | undefined; + + if (spaceIdx === -1) { + // No space = just value + value = rest; + } else { + value = rest.substring(0, spaceIdx); + unit = rest.substring(spaceIdx + 1).trim() || undefined; + } return { key, diff --git a/src/types.ts b/src/types.ts index f20b273..96577fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,7 +8,7 @@ export type WorkoutState = 'planned' | 'started' | 'completed'; export type ExerciseState = 'pending' | 'inProgress' | 'completed' | 'skipped'; // Key-value pairs for exercise parameters -export interface ExerciseParam { +export interface ParameterToken { key: string; value: string; editable: boolean; // true if wrapped in [brackets] @@ -28,7 +28,7 @@ export interface WorkoutMetadata { export interface Exercise { state: ExerciseState; name: string; - params: ExerciseParam[]; + params: ParameterToken[]; targetDuration?: number; // Target duration in seconds (for countdown) recordedDuration?: string; // Recorded duration after completion lineIndex: number; // Line index relative to exercise section start From 4a5275b66db2513f60c09b50797351559984718f Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Wed, 8 Apr 2026 18:24:28 -0500 Subject: [PATCH 03/42] fixed issue with next button --- IMPLEMENTATION_NOTES.md | 170 --------------------------------------- src/main.ts | 102 +++++++++++++++++------ src/renderer/exercise.ts | 16 +--- src/renderer/index.ts | 43 ++-------- src/types.ts | 31 ++++--- 5 files changed, 108 insertions(+), 254 deletions(-) delete mode 100644 IMPLEMENTATION_NOTES.md diff --git a/IMPLEMENTATION_NOTES.md b/IMPLEMENTATION_NOTES.md deleted file mode 100644 index ee67ae2..0000000 --- a/IMPLEMENTATION_NOTES.md +++ /dev/null @@ -1,170 +0,0 @@ -# Nested Sets Implementation Summary - -This document describes the implementation of nested sets support in the Obsidian Workout Log plugin. - -## Overview - -Previously, each exercise instance represented a single set. Multiple sets for the same exercise required duplicating the entire exercise line. Now, exercises can contain multiple sets as indented sub-items, enabling per-set tracking of reps and weights. - -## Architecture Changes - -### Type System (`types.ts`) - -#### New: `ExerciseSet` Interface -```typescript -export interface ExerciseSet { - state: ExerciseState; - params: ExerciseParam[]; - lineIndex: number; -} -``` - -#### Updated: `Exercise` Interface -```typescript -export interface Exercise { - state: ExerciseState; - name: string; - params: ExerciseParam[]; // Exercise-level params (typically Duration) - sets: ExerciseSet[]; // Nested sets - targetDuration?: number; - recordedDuration?: string; - lineIndex: number; -} -``` - -### Parser (`parser/`) - -#### `parser/index.ts` - `parseWorkout()` -- Detects indentation to distinguish between parent exercises and child sets -- Groups indented lines as sets under the parent exercise -- When an exercise has no sets (backward compatibility), creates a default set from exercise params - -#### `parser/exercise.ts` - New Functions -- `parseExercise()`: Now returns Exercise with `sets: []` initialized -- `parseSet()`: Parses indented lines as sets (no exercise name, just params) - -#### Format Parsing Logic -``` -- [ ] Exercise Name | Duration: [180s] → Exercise with params - - [ ] Reps: [10] | Weight: [225] lbs → Set (converted as set params) - - [ ] Reps: [8] | Weight: [235] lbs → Set -``` - -Result: Exercise has `params: [Duration]` and `sets: [{params: [Reps, Weight]}, {params: [Reps, Weight]}]` - -### Serializer (`serializer.ts`) - -#### Updated Functions -- `serializeWorkout()`: Now serializes exercises + their sets -- `createSampleWorkout()`: Creates exercises with sample sets - -#### New Functions -- `updateSetParamValue()`: Updates a specific param in a set -- `updateSetState()`: Updates state of a specific set -- `setSetRecordedDuration()`: Records duration for a set -- `addSet()`: Adds a new set to an exercise (modified from duplicating exercises) - -#### New Exports -- `serializeSet()`: Formats a set as an indented markdown line - -### Renderer (`renderer/`) - -#### `renderer/index.ts` -- No major changes; uses same iteration pattern for exercises - -#### `renderer/exercise.ts` - Significant Updates -- `ExerciseElements` now tracks `setInputs: Map>` -- New `renderSet()` function: Renders each set as an indented row with checkbox and params -- Sets appear visually indented under the exercise -- Each set has its own state icon and parameter inputs - -#### UI Structure -``` -[○] Bench Press | Duration: 5m - [○] Set 1 ×10 185 lbs - [○] Set 2 ×8 185 lbs - [○] Set 3 ×6 185 lbs -``` - -### Callbacks (`types.ts`) - -#### New Callback -```typescript -onSetParamChange: (exerciseIndex: number, setIndex: number, paramKey: string, newValue: string) => void; -``` - -#### Main.ts Implementation -- Implements `onSetParamChange` to update in-memory state -- Marks changes as pending, waits for flush before saving to file - -## Backward Compatibility - -The implementation fully supports the old format (exercise-level params): - -``` -- [ ] Bench Press | Reps: [10] | Weight: [225] lbs -``` - -When parsed: -1. Recognized as Exercise with no indented sets -2. Automatically creates a default set containing all params -3. Serializes back with sets indented, or to legacy format if needed - -## "+ Set" Button Behavior - -When clicked during workout: -1. Records current set's duration (timer state) -2. Adds new pending set to exercise -3. Advances timer to new set -4. User can modify reps/weight for new set -5. "+ Next" button (or "+ Set" again) moves workflow forward - -## File Update Flow - -### When Set Parameters Change -1. `onSetParamChange` called → updates `currentParsed` in memory -2. Sets `hasPendingChanges = true` -3. On blur/focusout, `onFlushChanges()` → `updateFile()` -4. `serializeWorkout()` writes exercise + all sets to file - -### Example Output -``` -- [ ] Bench Press | Duration: [300s] - - [ ] Reps: [10] | Weight: [225] lbs - - [ ] Reps: [8] | Weight: [235] lbs -``` - -## Limitations & Future Work - -### Current Limitations -- Set state is tracked but not prominently displayed during workout -- Callbacks for set-level skip, pause, etc. not yet implemented -- Timer assumes single active exercise (not per-set granularity) - -### Future Enhancements -- Per-set timers with individual countdown/count-up -- Set-level skip button -- Progressive set tracking (decreasing reps/increasing weight visualization) -- Set templates (e.g., "3×10" auto-expands to 3 sets) - -## Testing - -### Sample Formats -See [sample-workout.md](sample-workout.md) for: -- **Upper Body Strength**: Example with nested sets -- **Full Body**: Legacy format (old single-line format) -- **Morning Mobility**: Mixed format demo - -### Parsing Examples -All test cases in sample files should parse correctly to Exercise objects with nested sets. - -## Code Locations - -| File | Change | -|------|--------| -| `src/types.ts` | Added `ExerciseSet` interface, updated `Exercise` | -| `src/parser/index.ts` | Added set grouping logic in `parseWorkout()` | -| `src/parser/exercise.ts` | Added `parseSet()`, updated `parseExercise()`, added `serializeSet()` | -| `src/serializer.ts` | Added set-level functions, updated `createSampleWorkout()` | -| `src/renderer/exercise.ts` | Added `renderSet()`, updated `ExerciseElements` interface | -| `src/main.ts` | Added `onSetParamChange` callback implementation | diff --git a/src/main.ts b/src/main.ts index 8ea9a49..d1d7e72 100644 --- a/src/main.ts +++ b/src/main.ts @@ -91,7 +91,8 @@ export default class WorkoutLogPlugin extends Plugin { } }; - return { + // Define callbacks object so methods can reference each other + const callbacks: WorkoutCallbacks = { onStartWorkout: async (): Promise => { hasPendingChanges = false; // Will be saved by updateFile below // Update state to started @@ -132,51 +133,54 @@ export default class WorkoutLogPlugin extends Plugin { }, onExerciseFinish: async (exerciseIndex: number): Promise => { - hasPendingChanges = false; // Will be saved by updateFile below - const exercise = currentParsed.exercises[exerciseIndex]; - if (!exercise) return; - + // Deprecated: kept for backwards compatibility + // Delegates to onSetFinish or onRestEnd based on state const activeSetIndex = this.timerManager.getActiveSetIndex(workoutId); const timerState = this.timerManager.getTimerState(workoutId); - - // If in rest mode, exit rest and advance to next set + if (timerState?.isRestActive) { - const nextSetIndex = activeSetIndex + 1; - if (nextSetIndex < exercise.sets.length) { - currentParsed = updateSetState(currentParsed, exerciseIndex, nextSetIndex, 'inProgress'); - this.timerManager.exitRest(workoutId, exerciseIndex, nextSetIndex); - await updateFile(currentParsed); - } - return; + await callbacks.onRestEnd(exerciseIndex); + } else { + await callbacks.onSetFinish(exerciseIndex, activeSetIndex); } + }, + + onSetFinish: async (exerciseIndex: number, setIndex: number): Promise => { + hasPendingChanges = false; + const exercise = currentParsed.exercises[exerciseIndex]; + if (!exercise) return; + + const timerState = this.timerManager.getTimerState(workoutId); // Record duration for current set if (timerState) { currentParsed = setSetRecordedDuration( currentParsed, exerciseIndex, - activeSetIndex, + setIndex, formatDurationHuman(timerState.exerciseElapsed) ); } // Mark current set as completed - currentParsed = updateSetState(currentParsed, exerciseIndex, activeSetIndex, 'completed'); + currentParsed = updateSetState(currentParsed, exerciseIndex, setIndex, 'completed'); // Check if there are more sets in this exercise - if (activeSetIndex < exercise.sets.length - 1) { + if (setIndex < exercise.sets.length - 1) { // Check if current set has a rest period - const currentSet = exercise.sets[activeSetIndex]; if (!currentSet) return; - const restParam = currentSet.params.find(p => p.key.toLowerCase() === 'rest'); + const currentSet = exercise.sets[setIndex]; + if (!currentSet) return; + const restParam = currentSet.params.find(p => p.key.toLowerCase() === 'rest'); if (restParam) { - // Start rest timer instead of immediately advancing - const restDurationSeconds = parseDurationToSeconds(restParam.value); - this.timerManager.startRest(workoutId, restDurationSeconds); + // Start rest timer - save state first await updateFile(currentParsed); + // Then start rest transition + const restDurationSeconds = parseDurationToSeconds(restParam.value); + await callbacks.onRestStart(exerciseIndex, restDurationSeconds); } else { // No rest, advance immediately to next set - const nextSetIndex = activeSetIndex + 1; + const nextSetIndex = setIndex + 1; currentParsed = updateSetState(currentParsed, exerciseIndex, nextSetIndex, 'inProgress'); this.timerManager.advanceSet(workoutId, exerciseIndex, nextSetIndex); await updateFile(currentParsed); @@ -193,10 +197,56 @@ export default class WorkoutLogPlugin extends Plugin { 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); + 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); + } + } + }, + + onRestStart: async (exerciseIndex: number, restDuration: number): Promise => { + hasPendingChanges = false; + + // Start the rest timer in the timer manager + this.timerManager.startRest(workoutId, restDuration); + + // Save any pending changes (state is already in currentParsed from onSetFinish) + await updateFile(currentParsed); + }, + + onRestEnd: async (exerciseIndex: number): Promise => { + hasPendingChanges = false; + const exercise = currentParsed.exercises[exerciseIndex]; + if (!exercise) return; + + const activeSetIndex = this.timerManager.getActiveSetIndex(workoutId); + + // Rest finished, advance to next set + const nextSetIndex = activeSetIndex + 1; + if (nextSetIndex < exercise.sets.length) { + currentParsed = updateSetState(currentParsed, exerciseIndex, nextSetIndex, 'inProgress'); + this.timerManager.exitRest(workoutId, exerciseIndex, nextSetIndex); + await updateFile(currentParsed); + } else { + // No more sets in this exercise, move to next exercise + currentParsed = updateExerciseState(currentParsed, exerciseIndex, 'completed'); + + const nextPending = currentParsed.exercises.findIndex( + (e, i) => i > exerciseIndex && e.state === 'pending' + ); + if (nextPending >= 0) { + currentParsed = updateExerciseState(currentParsed, nextPending, 'inProgress'); + this.timerManager.advanceExercise(workoutId, nextPending); await updateFile(currentParsed); } else { // No more exercises, complete workout @@ -374,6 +424,8 @@ export default class WorkoutLogPlugin extends Plugin { ); } }; + + return callbacks; } private formatStartDate(date: Date): string { diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index a476a26..2317175 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -500,19 +500,11 @@ function renderSetControls( const nextBtn = finishGroup.createEl('button', { cls: 'workout-btn', text: nextBtnText }); nextBtn.addEventListener('click', () => { if (timerState?.isRestActive) { - // Skip rest and advance to next set - const nextSetIndex = setIndex + 1; - callbacks.onExerciseFinish(exerciseIndex); + // Currently in rest period, end it and advance to next set + callbacks.onRestEnd(exerciseIndex); } else { - const isLastSet = setIndex === totalSets - 1; - const isLastExercise = typeof totalExercises === 'number' ? (exerciseIndex === totalExercises - 1) : false; - if (isLastSet && !isLastExercise && typeof callbacks.onSetFinish === 'function') { - // Last set, but not last exercise: trigger rest for last set - callbacks.onSetFinish(exerciseIndex, setIndex); - } else { - // Otherwise, finish set/exercise as usual - callbacks.onExerciseFinish(exerciseIndex); - } + // Finishing a set, may start rest or advance + callbacks.onSetFinish(exerciseIndex, setIndex); } }); } diff --git a/src/renderer/index.ts b/src/renderer/index.ts index 60d99fd..65a02a6 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -53,39 +53,8 @@ export function renderWorkout(ctx: RendererContext): void { // Approximate character width (will be refined by CSS) exercisesContainer.style.setProperty('--max-name-chars', String(maxNameLength)); - // Provide onSetFinish callback for last set rest logic - const enhancedCallbacks: WorkoutCallbacks = { - ...callbacks, - onSetFinish: (exerciseIndex: number, setIndex: number) => { - const exercise = parsed.exercises[exerciseIndex]; - if (!exercise || !exercise.sets) return; - const set = exercise.sets[setIndex]; - if (!set) return; - const restParam = set.params.find(p => p.key.toLowerCase() === 'rest'); - if (!restParam) { - // No rest parameter, just advance - callbacks.onExerciseFinish(exerciseIndex); - return; - } - // Parse rest duration - value could be like "60" or "60s" - const restDurationStr = (restParam.value || '').trim(); - const restSeconds = parseInt(restDurationStr, 10); - if (restSeconds > 0) { - // Start rest timer - timerManager.startRest(workoutId, restSeconds); - // Immediately notify subscribers so UI updates without waiting for next timer tick - try { - timerManager.notifySubscribers(workoutId); - } catch (e) { - // Silently ignore if notifySubscribers fails - rest will still update on next tick - } - // Do NOT mark set as completed yet; wait for rest to finish and auto-advance - } else { - // No valid rest duration, immediately mark set as completed and advance - callbacks.onExerciseFinish(exerciseIndex); - } - } - }; + // Use callbacks directly - rest logic is now handled in the callbacks + const exerciseCallbacks = callbacks; for (let i = 0; i < parsed.exercises.length; i++) { const exercise = parsed.exercises[i]; @@ -100,7 +69,7 @@ export function renderWorkout(ctx: RendererContext): void { isActive, activeSetIndex, isActive ? timerState : null, - enhancedCallbacks, + exerciseCallbacks, parsed.metadata.state, parsed.metadata.restDuration, parsed.exercises.length // totalExercises @@ -161,7 +130,7 @@ export function renderWorkout(ctx: RendererContext): void { ); } - // Check for auto-advance on rest completion for last set (onSetFinish case) + // Check for auto-advance on rest completion const isLastSet = Array.isArray(activeExercise.sets) && activeElements?.setTimerEl && (timerManager.getActiveSetIndex(workoutId) === activeExercise.sets.length - 1); const isLastExercise = currentActiveIndex === parsed.exercises.length - 1; @@ -170,8 +139,8 @@ export function renderWorkout(ctx: RendererContext): void { typeof state.restRemaining === 'number' && state.restRemaining <= 0 && !hasAutoAdvanced ) { hasAutoAdvanced = true; - // Now mark set as completed and advance - callbacks.onExerciseFinish(currentActiveIndex); + // Rest period completed, advance to next set + callbacks.onRestEnd(currentActiveIndex); } } }); diff --git a/src/types.ts b/src/types.ts index 9912a9d..f240568 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,5 @@ import { MarkdownPostProcessorContext, TFile, App } from 'obsidian'; -// Token types for parameter parsing -export interface Token { - type: 'key' | 'bracket' | 'value' | 'unit'; - value: string; -} - // Workout states export type WorkoutState = 'planned' | 'started' | 'completed'; @@ -13,6 +7,12 @@ export type WorkoutState = 'planned' | 'started' | 'completed'; // [ ] = pending, [\] = inProgress, [x] = completed, [-] = skipped export type ExerciseState = 'pending' | 'inProgress' | 'completed' | 'skipped'; +// Token types for parameter parsing +export interface Token { + type: 'key' | 'bracket' | 'value' | 'unit'; + value: string; +} + // Key-value pairs for exercise/set parameters export interface ExerciseParam { key: string; @@ -35,6 +35,7 @@ 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 (with nested sets) @@ -76,7 +77,7 @@ 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 @@ -89,17 +90,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; - onSetFinish?: (exerciseIndex: number, setIndex: number) => void; + + // Deprecated: kept for backwards compatibility, use onSetFinish instead + onExerciseFinish?: (exerciseIndex: number) => Promise; } // Context passed to renderer From 7b477a909d45acb4079a788e5c50890d36cd8c1c Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Wed, 8 Apr 2026 19:26:09 -0500 Subject: [PATCH 04/42] added function to save to properties --- src/file/updater.ts | 129 ++++++++++++++++++++++++++++++++++++++++- src/main.ts | 5 ++ src/parser/metadata.ts | 8 ++- 3 files changed, 139 insertions(+), 3 deletions(-) diff --git a/src/file/updater.ts b/src/file/updater.ts index 6e52fd8..9d1c333 100644 --- a/src/file/updater.ts +++ b/src/file/updater.ts @@ -1,11 +1,25 @@ import { App, TFile } from 'obsidian'; -import { SectionInfo } from '../types'; +import { SectionInfo, ParsedWorkout } from '../types'; export class FileUpdater { private updateLocks = new Map>(); constructor(private app: App) {} + // Normalize exercise name to camelCase for property names + private normalizeToCamelCase(name: string): string { + return name + .trim() + .split(/[\s\-_]+/) // Split on spaces, hyphens, underscores + .map((word, index) => { + if (index === 0) { + return word.toLowerCase(); + } + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }) + .join(''); + } + // Serialize updates to the same file to prevent race conditions private async withLock(filePath: string, fn: () => Promise): Promise { // Wait for any pending update to complete @@ -124,4 +138,117 @@ export class FileUpdater { return lines.join('\n'); }); } + + 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 () => { + // Update file properties/frontmatter + const properties: Record = {}; + + const metadata = parsed.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; + } + + // Extract exercise data + if (parsed.exercises.length > 0) { + const exercises = parsed.exercises.map(exercise => ({ + name: exercise.name, + state: exercise.state, + recordedDuration: exercise.recordedDuration, + 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 totals for each exercise and add as properties + for (const exercise of parsed.exercises) { + const normalizedName = this.normalizeToCamelCase(exercise.name); + + // Calculate totals from all sets of this exercise + let totalWeight = 0; + let totalReps = 0; + let totalDuration = 0; + + for (const set of exercise.sets) { + // Extract weight + 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 + 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 + 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 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; + } + } + } + + // Use Obsidian's API to set properties + try { + await this.app.fileManager.processFrontMatter(file, (frontmatter) => { + // Update or add properties to 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 d1d7e72..9db18e8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -82,6 +82,11 @@ export default class WorkoutLogPlugin extends Plugin { // Pass title for validation to prevent cross-block contamination const expectedTitle = currentParsed.metadata.title; await this.fileUpdater?.updateCodeBlock(ctx.sourcePath, sectionInfo, newContent, expectedTitle); + + // Save to properties if enabled + if (currentParsed.metadata.saveToProperties) { + await this.fileUpdater?.saveToProperties(ctx.sourcePath, currentParsed); + } }; // Flush any pending param changes to file diff --git a/src/parser/metadata.ts b/src/parser/metadata.ts index 53fd366..c5228ad 100644 --- a/src/parser/metadata.ts +++ b/src/parser/metadata.ts @@ -35,8 +35,9 @@ export function parseMetadata(lines: string[]): WorkoutMetadata { const seconds = parseDurationToSeconds(value); if (seconds > 0) metadata.restDuration = seconds; } - break; - } + break; case 'savetoproperties': + metadata.saveToProperties = value.toLowerCase() === 'true'; + break; } } return metadata; @@ -58,6 +59,9 @@ export function serializeMetadata(metadata: WorkoutMetadata): string[] { if (metadata.restDuration !== undefined) { lines.push(`restDuration: ${formatDurationHuman(metadata.restDuration)}`); } + if (metadata.saveToProperties !== undefined) { + lines.push(`saveToProperties: ${metadata.saveToProperties}`); + } return lines; } From 9c39a79d2211b95eed3487322b98ea50ffe4dd09 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Wed, 8 Apr 2026 23:08:00 -0500 Subject: [PATCH 05/42] added unit tests --- .gitignore | 3 + jest.config.js | 85 + package-lock.json | 5931 ++++++++++++++++++++++++++++--- package.json | 16 +- src/__mocks__/obsidian.ts | 7 + src/file/updater.test.ts | 714 ++++ src/parser/exercise.test.ts | 446 +++ src/parser/exercise.ts | 36 +- src/parser/index.test.ts | 199 ++ src/parser/metadata.test.ts | 254 ++ src/renderer/controls.test.ts | 536 +++ src/renderer/emptyState.test.ts | 555 +++ src/renderer/exercise.test.ts | 1881 ++++++++++ src/renderer/header.test.ts | 506 +++ src/renderer/index.test.ts | 916 +++++ src/serializer.test.ts | 303 ++ src/timer/manager.test.ts | 368 ++ tsconfig.json | 3 + 18 files changed, 12328 insertions(+), 431 deletions(-) create mode 100644 jest.config.js create mode 100644 src/__mocks__/obsidian.ts create mode 100644 src/file/updater.test.ts create mode 100644 src/parser/exercise.test.ts create mode 100644 src/parser/index.test.ts create mode 100644 src/parser/metadata.test.ts create mode 100644 src/renderer/controls.test.ts create mode 100644 src/renderer/emptyState.test.ts create mode 100644 src/renderer/exercise.test.ts create mode 100644 src/renderer/header.test.ts create mode 100644 src/renderer/index.test.ts create mode 100644 src/serializer.test.ts create mode 100644 src/timer/manager.test.ts 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/jest.config.js b/jest.config.js new file mode 100644 index 0000000..13c7f3a --- /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: 98, + statements: 98 + }, + '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/package-lock.json b/package-lock.json index 7060a54..0db0b78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,614 +1,5697 @@ { "name": "obsidian-workout-log", - "version": "1.1.1", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-workout-log", - "version": "1.1.1", + "version": "1.1.2", "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 14a9e82..7267170 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,25 @@ "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/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..b2cc029 --- /dev/null +++ b/src/file/updater.test.ts @@ -0,0 +1,714 @@ +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 | undefined { + 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: [] + }; + + 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: [] + }; + + 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', + recordedDuration: '10m', + params: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Duration', value: '120', unit: 's', editable: false } + ] + } + ] + } + ] + }; + + 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: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Weight', value: '10', unit: 'kg', editable: true }, + { key: 'Reps', value: '10', unit: '', editable: true } + ] + }, + { + state: 'completed', + params: [ + { key: 'Weight', value: '12', unit: 'kg', editable: true }, + { key: 'Reps', value: '8', unit: '', editable: true } + ] + } + ] + } + ] + }; + + 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: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Duration', value: '300', unit: '', editable: true } + ] + }, + { + state: 'completed', + params: [ + { key: 'Duration', value: '180', unit: '', editable: true } + ] + } + ] + } + ] + }; + + 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: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Weight', value: '100', unit: 'kg', editable: true }, + { key: 'Reps', value: '5', unit: '', editable: true } + ] + } + ] + }, + { + name: 'Squats', + state: 'completed', + params: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Weight', value: '150', unit: 'kg', editable: true }, + { key: 'Reps', value: '8', unit: '', editable: true } + ] + } + ] + } + ] + }; + + 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: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Weight', value: 'invalid', unit: 'kg', editable: true }, + { key: 'Reps', value: '10', unit: '', editable: true } + ] + } + ] + } + ] + }; + + 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: [], + sets: [ + { + state: 'completed', + params: [] + } + ] + } + ] + }; + + 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: [] + }; + + 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', + recordedDuration: '10m', + params: [], + sets: [ + { + state: 'completed', + params: [ + { key: 'Duration', value: '300s', unit: '', editable: false } + ] + } + ] + } + ] + }; + + 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/parser/exercise.test.ts b/src/parser/exercise.test.ts new file mode 100644 index 0000000..1fd0e0f --- /dev/null +++ b/src/parser/exercise.test.ts @@ -0,0 +1,446 @@ +import { + parseExercise, + parseSet, + parseDurationToSeconds, + formatDuration, + formatDurationHuman, + getStateChar, + serializeExercise, + serializeSet +} from './exercise'; +import { ExerciseState } from '../types'; + +describe('parseExercise', () => { + 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 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'); + }); + + 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 handle exercise with multiple params', () => { + const line = '- [x] Squats | Weight: [200] kg | Reps: [10] | Duration: 60s'; + const exercise = parseExercise(line, 0); + + expect(exercise!.params).toHaveLength(3); + expect(exercise!.params[0].key).toBe('Weight'); + expect(exercise!.params[1].key).toBe('Reps'); + expect(exercise!.params[2].key).toBe('Duration'); + }); + + 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 combined with their units in parseParam + expect(exercise!.recordedDuration).toBe('125s'); + }); + + it('should handle Duration with compound format like 3m2s', () => { + const line = '- [x] Workout | Duration: 3m2s'; + const exercise = parseExercise(line, 0); + + expect(exercise!.recordedDuration).toBe('3m2s'); + }); + + 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(''); + }); + + it('should preserve lineIndex', () => { + const line = '- [ ] Bench Press'; + const exercise = parseExercise(line, 42); + + expect(exercise!.lineIndex).toBe(42); + }); +}); + +describe('parseSet', () => { + 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 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 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 return null for invalid set line', () => { + const line = ' invalid set'; + const set = parseSet(line, 0); + + expect(set).toBeNull(); + }); + + it('should preserve lineIndex', () => { + const line = ' - [ ] | Weight: [50] kg'; + const set = parseSet(line, 99); + + expect(set!.lineIndex).toBe(99); + }); + + 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('parseDurationToSeconds', () => { + 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 decimal seconds', () => { + expect(parseDurationToSeconds('60')).toBe(60); + }); + + it('should handle minutes without seconds', () => { + expect(parseDurationToSeconds('1m')).toBe(60); + expect(parseDurationToSeconds('5m')).toBe(300); + }); + + 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'); + }); +}); + +describe('Integration tests', () => { + 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 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'); + expect(exercise!.params).toHaveLength(3); + expect(exercise!.recordedDuration).toBe('3m2s'); + }); + + 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'); + }); + + 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 handle param with only a value and no unit', () => { + const line = '- [ ] Exercise | Sets: 3'; + const exercise = parseExercise(line, 0); + + expect(exercise!.params[0].key).toBe('Sets'); + expect(exercise!.params[0].value).toBe('3'); + expect(exercise!.params[0].unit).toBeUndefined(); + }); + + it('should handle bracketed params with spaces inside', () => { + const line = '- [ ] Exercise | Notes: [my note]'; + const exercise = parseExercise(line, 0); + + expect(exercise!.params[0].key).toBe('Notes'); + expect(exercise!.params[0].value).toBe('my note'); + expect(exercise!.params[0].editable).toBe(true); + }); +}); + diff --git a/src/parser/exercise.ts b/src/parser/exercise.ts index 0da8a4a..1832dba 100644 --- a/src/parser/exercise.ts +++ b/src/parser/exercise.ts @@ -134,9 +134,26 @@ function tokenizeParam(paramStr: string): Token[] { // Unbracketed value - read until space const spaceIndex = remainder.indexOf(' '); if (spaceIndex === -1) { - // No space, entire remainder is value - tokens.push({ type: 'value', value: remainder }); - return tokens; + // No space - check if value and unit are concatenated (e.g., "10s", "60m") + 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) { + // Has both numeric value and non-numeric unit + tokens.push({ type: 'value', value: remainder.substring(0, numericEnd) }); + remainder = remainder.substring(numericEnd); + } else { + // No unit attached or all numeric, entire remainder is value + tokens.push({ type: 'value', value: remainder }); + return tokens; + } } else { tokens.push({ type: 'value', value: remainder.substring(0, spaceIndex) }); remainder = remainder.substring(spaceIndex).trim(); @@ -162,11 +179,20 @@ function parseParam(paramStr: string): ExerciseParam | null { const unitToken = tokens.find(t => t.type === 'unit'); + let finalValue = valueToken.value; + let finalUnit = unitToken?.value; + + // For Duration, combine value and unit since they're part of duration syntax (e.g., "3m2s") + if (keyToken.value.toLowerCase() === 'duration' && finalUnit) { + finalValue = finalValue + finalUnit; + finalUnit = undefined; + } + return { key: keyToken.value, - value: valueToken.value, + value: finalValue, editable: valueToken.type === 'bracket', - unit: unitToken?.value + unit: finalUnit }; } diff --git a/src/parser/index.test.ts b/src/parser/index.test.ts new file mode 100644 index 0000000..eb8fe13 --- /dev/null +++ b/src/parser/index.test.ts @@ -0,0 +1,199 @@ +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); + + expect(result.exercises[0].recordedDuration).toBe('65s'); + }); + + 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/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/renderer/controls.test.ts b/src/renderer/controls.test.ts new file mode 100644 index 0000000..fcf6e81 --- /dev/null +++ b/src/renderer/controls.test.ts @@ -0,0 +1,536 @@ +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: 0 + } + ] + }; + 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: 0 + }, + { + name: 'Pull Ups', + state: 'completed', + params: [], + sets: [{ state: 'completed', params: [] }], + lineIndex: 3 + } + ] + }; + + 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/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/exercise.test.ts b/src/renderer/exercise.test.ts new file mode 100644 index 0000000..3d09957 --- /dev/null +++ b/src/renderer/exercise.test.ts @@ -0,0 +1,1881 @@ +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, + recordedDuration: '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 = { + elapsed: 30, + isRestActive: true, + 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 = { + elapsed: 60, + isRestActive: true, + restRemaining: 45 // 45 / 90 = 50% (yellow phase) + }; + 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 = { + elapsed: 90, + isRestActive: true, + restRemaining: 20 // 20 / 90 = 22% (red phase) + }; + 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 = { + elapsed: 75, + isRestActive: true, + restRemaining: -15 // Negative = overtime + }; + 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: [], recordedDuration: '3m' }, + { state: 'completed', params: [], recordedDuration: '2m 45s' } + ], + lineIndex: 0, + recordedDuration: '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: [], restDuration: '45s' }, + { state: 'pending', params: [] } + ], + lineIndex: 0 + }; + const timerState: TimerState = { + elapsed: 30, + isRestActive: false + }; + 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, + recordedDuration: '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 = { + elapsed: 30, + isRestActive: false + }; + 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: [], + recordedDuration: '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: [], + restDuration: '120s' + } + ], + 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: [], restDuration: '60s' } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exercise, + 0, + true, + 1, + { setIndex: 1, phase: 'rest' }, + 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: [], + recordedDuration: '2m 10s', + restDuration: '120s' + } + ], + recordedDuration: '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', + recordedDuration: '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: [], recordedDuration: '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: [], restDuration: '90s' }, + { state: 'pending', params: [], restDuration: '90s' } + ], + lineIndex: 0 + }; + const result = renderExercise( + container, + exerciseWithRest, + 0, + true, + 0, + { setIndex: 0, phase: 'rest' }, + 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, + isRestActive: false, + 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, + isRestActive: false, + restRemaining: 0 + }; + + updateExerciseTimer(timerEl, timerState); + expect(timerEl.textContent).not.toContain('Previous'); + }); + + it('should handle zero elapsed time', () => { + const timerState: TimerState = { + workoutElapsed: 0, + exerciseElapsed: 0, + isRestActive: false, + 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, + isRestActive: false, + 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, + isRestActive: false, + 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, + isRestActive: false, + 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, + isRestActive: false, + 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, + isRestActive: false, + restRemaining: 0 + }; + const targetDuration = 60; + + updateExerciseTimer(timerEl, timerState, targetDuration); + expect(timerEl.textContent).toBeTruthy(); + }); + }); +}); 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/index.test.ts b/src/renderer/index.test.ts new file mode 100644 index 0000000..859a1ba --- /dev/null +++ b/src/renderer/index.test.ts @@ -0,0 +1,916 @@ +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: null, + setTimerEl: null, + 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(); + + 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 el: 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: 0 + } + ] + }; + + beforeEach(() => { + el = new MockElement('div'); + timerManager = new (TimerManager as any)(); + 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 initialization', () => { + it('should clear existing content', () => { + el.createDiv({ text: 'Old Content' }); + expect(el.children.length).toBe(1); + + renderWorkout({ + el, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + // After render, old content should be gone + expect(el.children.length).toBeGreaterThanOrEqual(1); + }); + + it('should create workout container', () => { + renderWorkout({ + el, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = el.querySelector('.workout-container'); + expect(container).toBeTruthy(); + }); + + it('should add state class to container', () => { + renderWorkout({ + el, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = el.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({ + el, + parsed: completedWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = el.querySelector('.workout-container'); + expect(container?.className).toContain('state-completed'); + }); + }); + + describe('Header rendering', () => { + it('should render header', () => { + const { renderHeader } = require('./header'); + renderWorkout({ + el, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(renderHeader).toHaveBeenCalled(); + }); + + it('should pass workout metadata to header', () => { + const { renderHeader } = require('./header'); + renderWorkout({ + el, + 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: [] + }; + + renderWorkout({ + el, + 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({ + el, + 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: [] + }; + + renderWorkout({ + el, + parsed: emptyCompletedWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(renderEmptyState).not.toHaveBeenCalled(); + }); + }); + + describe('Exercise rendering', () => { + it('should render exercises container', () => { + renderWorkout({ + el, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = el.querySelector('.workout-exercises'); + expect(container).toBeTruthy(); + }); + + it('should render each exercise', () => { + const { renderExercise } = require('./exercise'); + renderWorkout({ + el, + 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: 0 + }, + { + name: 'Exercise 2', + state: 'pending', + params: [], + sets: [{ state: 'pending', params: [] }], + lineIndex: 1 + } + ] + }; + + renderWorkout({ + el, + parsed: multiExerciseWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(renderExercise).toHaveBeenCalledTimes(2); + }); + + it('should set max-name-chars CSS variable', () => { + renderWorkout({ + el, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const exercisesContainer = el.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({ + el, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(renderWorkoutControls).toHaveBeenCalled(); + }); + + it('should pass state to controls', () => { + const { renderWorkoutControls } = require('./controls'); + renderWorkout({ + el, + 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({ + el, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = el.querySelector('.workout-container') as MockElement; + expect(container?.listeners['focusout']).toBeDefined(); + }); + + it('should call onFlushChanges when focus leaves container', () => { + renderWorkout({ + el, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = el.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({ + el, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = el.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({ + el, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = el.querySelector('.workout-container'); + expect(container?.className).toContain('state-planned'); + }); + + it('should handle started state', () => { + const startedWorkout: ParsedWorkout = { + ...mockWorkout, + metadata: { ...mockWorkout.metadata, state: 'started' } + }; + + renderWorkout({ + el, + parsed: startedWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = el.querySelector('.workout-container'); + expect(container?.className).toContain('state-started'); + }); + + it('should handle completed state', () => { + const completedWorkout: ParsedWorkout = { + ...mockWorkout, + metadata: { ...mockWorkout.metadata, state: 'completed' } + }; + + renderWorkout({ + el, + parsed: completedWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + const container = el.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({ + el, + 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({ + el, + 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, + isRestActive: false, + restRemaining: 0 + }; + + timerCallback(timerState); + + // Verify header timer was updated + expect(mockCallbacks).toBeDefined(); + }); + + 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({ + el, + 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({ + el, + 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, + 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: [], restDuration: '60s' } + ], + lineIndex: 0 + }, + { + name: 'Exercise 2', + state: 'pending', + params: [], + sets: [{ state: 'pending', params: [] }], + lineIndex: 1 + } + ] + }; + + renderWorkout({ + el, + 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, + isRestActive: true, + restRemaining: 0 + }; + + // Call the timer callback + timerCallback(timerState); + + // Verify callback ran without errors + expect(mockCallbacks).toBeDefined(); + }); + }); + + describe('Edge cases', () => { + it('should handle empty exercise list', () => { + const emptyWorkout: ParsedWorkout = { + metadata: { title: 'Empty', state: 'planned' }, + exercises: [] + }; + + renderWorkout({ + el, + parsed: emptyWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(el.children.length).toBeGreaterThan(0); + }); + + it('should handle single exercise', () => { + renderWorkout({ + el, + parsed: mockWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(el.children.length).toBeGreaterThan(0); + }); + + it('should handle very long workout title', () => { + const longTitleWorkout: ParsedWorkout = { + metadata: { + title: 'A'.repeat(500), + state: 'planned' + }, + exercises: mockWorkout.exercises + }; + + renderWorkout({ + el, + parsed: longTitleWorkout, + callbacks: mockCallbacks, + workoutId: 'test-workout', + timerManager + }); + + expect(el.children.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/serializer.test.ts b/src/serializer.test.ts new file mode 100644 index 0000000..f46badb --- /dev/null +++ b/src/serializer.test.ts @@ -0,0 +1,303 @@ +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'); + + const durationParam = updated.exercises[0].sets[0].params.find(p => p.key === 'Duration'); + expect(durationParam?.value).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('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:'); + }); +}); diff --git a/src/timer/manager.test.ts b/src/timer/manager.test.ts new file mode 100644 index 0000000..2e5d3cd --- /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 elapsed = manager.getElapsedSeconds('workout1'); + expect(elapsed).toBe(5); + }); + + it('should account for pause time', () => { + manager.startWorkoutTimer('workout1'); + dateNowSpy.mockReturnValue(2000); + manager.pauseExercise('workout1'); + dateNowSpy.mockReturnValue(6000); + + const elapsed = manager.getElapsedSeconds('workout1'); + expect(elapsed).toBe(1); + }); + }); + + describe('getRestElapsedSeconds', () => { + it('should calculate rest elapsed time', () => { + manager.startWorkoutTimer('workout1'); + manager.startRest('workout1', 60); + dateNowSpy.mockReturnValue(1035); + + const elapsed = manager.getRestElapsedSeconds('workout1'); + expect(elapsed).toBe(35); + }); + }); + + describe('getRestRemainingSeconds', () => { + it('should calculate rest remaining seconds', () => { + manager.startWorkoutTimer('workout1'); + manager.startRest('workout1', 60); + dateNowSpy.mockReturnValue(1020); + + const remaining = manager.getRestRemainingSeconds('workout1'); + expect(remaining).toBeCloseTo(40, 1); + }); + }); + + 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/tsconfig.json b/tsconfig.json index 222535d..bdd1b01 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,5 +26,8 @@ }, "include": [ "src/**/*.ts" + ], + "exclude": [ + "**/*.test.ts" ] } From 0afc218d36126f3848557de2ac0fab82b985cc9d Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Wed, 8 Apr 2026 23:38:41 -0500 Subject: [PATCH 06/42] fixed issue with totals on header row of exercise --- package.json | 2 +- src/renderer/exercise.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 7267170..596b7ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-workout-log", - "version": "1.1.2", + "version": "1.1.1", "description": "A block-rendered workout tracker for Obsidian with timer functionality", "main": "main.js", "type": "module", diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index 2317175..a0a163d 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -77,7 +77,7 @@ function computeExerciseTotals(exercise: Exercise, isCompleted: boolean): { } else if (param.key.toLowerCase() === 'weight' || param.key.toLowerCase() === 'load') { const weight = parseFloat(param.value); if (!isNaN(weight)) { - totalWeight = weight; // Use last weight value (assume same for all sets) + totalWeight += weight; weightFound = true; } } else if (param.key.toLowerCase() === 'rest') { From 2b789345ff7a7a94e03f815a978be65e84b015e4 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Thu, 9 Apr 2026 14:46:17 -0500 Subject: [PATCH 07/42] fixed styling on main exercise header --- src/renderer/exercise.ts | 118 +++++++++++++++++++++++++++++++-------- styles.css | 12 ++-- 2 files changed, 103 insertions(+), 27 deletions(-) diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index a0a163d..0bd455e 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -28,14 +28,37 @@ export interface ExerciseElements { setInputs: Map>; // Indexed by set index } -// Check if set has params to display (excludes Duration which is shown separately) +// Check if set has params to display (includes duration, weight, reps in order) function hasDisplayableSetParams(set: ExerciseSet): boolean { - return set.params.some(p => p.key.toLowerCase() !== 'duration'); + return set.params.some(p => { + const key = p.key.toLowerCase(); + return key === 'duration' || key === 'weight' || key === 'reps'; + }); } -// Get params to display inline (excludes Duration and recorded values) +// Get params to display in order: duration, weight, reps (only if provided) function getDisplayableSetParams(set: ExerciseSet): ExerciseParam[] { - return set.params.filter(p => p.key.toLowerCase() !== 'duration'); + const paramOrder = ['duration', 'weight', 'reps']; + 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; } // Get recorded duration from a set (if any) @@ -145,36 +168,39 @@ export function renderExercise( const nameEl = mainRow.createSpan({ cls: 'workout-exercise-name' }); nameEl.textContent = exercise.name; - // Display totals from all sets (reps, weight, etc.) + // Determine if exercise has multiple sets + const hasMultipleSets = exercise.sets.length > 1; const isCompleted = exercise.state === 'completed'; const totals = computeExerciseTotals(exercise, isCompleted); - if (totals.reps !== null || totals.weight !== null || (isCompleted && totals.totalRecordedTime > 0) || totals.totalRest > 0) { - const totalsEl = mainRow.createSpan({ cls: 'workout-exercise-totals' }); - - // Show total reps - if (totals.reps !== null) { - const repsEl = totalsEl.createSpan({ cls: 'workout-total' }); - repsEl.createSpan({ cls: 'workout-param-prefix', text: '×' }); - repsEl.createSpan({ cls: 'workout-param-value', text: String(totals.reps) }); - } + // For multi-set exercises, display totals from all sets + if (hasMultipleSets && (totals.reps !== null || totals.weight !== null || (isCompleted && totals.totalRecordedTime > 0) || totals.totalRest > 0)) { + const totalsEl = mainRow.createSpan({ cls: 'workout-exercise-params' }); // Show weight if (totals.weight !== null) { - const weightEl = totalsEl.createSpan({ cls: 'workout-total' }); + const weightEl = totalsEl.createSpan({ cls: 'workout-param' }); weightEl.createSpan({ cls: 'workout-param-value', text: String(totals.weight) }); weightEl.createSpan({ cls: 'workout-param-unit', text: ' lbs' }); } - // Show total recorded time when completed + // Show total reps + if (totals.reps !== null) { + const repsEl = totalsEl.createSpan({ cls: 'workout-param' }); + repsEl.createSpan({ cls: 'workout-param-prefix', text: '×' }); + repsEl.createSpan({ cls: 'workout-param-value', text: String(totals.reps) }); + } + + // Show total recorded time when completed + // TODO: change this to show total duration if provided. if (isCompleted && totals.totalRecordedTime > 0) { - const timeEl = totalsEl.createSpan({ cls: 'workout-total' }); + const timeEl = totalsEl.createSpan({ cls: 'workout-param' }); timeEl.createSpan({ cls: 'workout-param-value', text: formatDurationHuman(totals.totalRecordedTime) }); } // Show total rest time if (totals.totalRest > 0) { - const restEl = totalsEl.createSpan({ cls: 'workout-total workout-rest' }); + const restEl = totalsEl.createSpan({ cls: 'workout-param' }); restEl.createSpan({ cls: 'workout-param-prefix', text: '⏸' }); restEl.createSpan({ cls: 'workout-param-value', text: formatDurationHuman(totals.totalRest) }); } @@ -222,11 +248,54 @@ export function renderExercise( } } - // Timer display (right side) - only if no sets - // (timer shows on active set when sets exist) + // For single-set exercises, render set params inline on mainRow + if (!hasMultipleSets && exercise.sets.length > 0) { + const singleSet = exercise.sets[0]; + if (singleSet && hasDisplayableSetParams(singleSet)) { + const paramsEl = mainRow.createSpan({ cls: 'workout-exercise-params' }); + const setParamInputs = new Map(); + const displayableParams = getDisplayableSetParams(singleSet); + + for (const param of displayableParams) { + const paramEl = paramsEl.createSpan({ cls: 'workout-param' }); + + // × prefix for params without units + if (!param.unit) { + paramEl.createSpan({ cls: 'workout-param-prefix', text: '×' }); + } + + if (param.editable && workoutState !== 'completed') { + const input = paramEl.createEl('input', { + cls: 'workout-param-input', + type: 'text', + value: param.value + }); + input.addEventListener('input', () => { + callbacks.onSetParamChange(index, 0, param.key, input.value); + }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + input.blur(); + } + }); + setParamInputs.set(param.key, 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}` }); + } + } + setInputs.set(0, setParamInputs); + } + } + + // Timer display (right side) - show on mainRow if no sets or single set, otherwise on active set let timerEl: HTMLElement | null = null; - if (exercise.sets.length === 0) { + if (exercise.sets.length === 0 || !hasMultipleSets) { timerEl = mainRow.createSpan({ cls: 'workout-exercise-timer' }); if (exercise.state === 'completed' && exercise.recordedDuration) { @@ -242,8 +311,8 @@ export function renderExercise( } } - // Render sets as indented rows - if (exercise.sets.length > 0) { + // Render sets as indented rows (only for multi-set exercises) + if (hasMultipleSets) { const setsContainer = exerciseEl.createDiv({ cls: 'workout-sets' }); for (let setIndex = 0; setIndex < exercise.sets.length; setIndex++) { const set = exercise.sets[setIndex]; @@ -277,6 +346,9 @@ export function renderExercise( ); } } + } else if (!hasMultipleSets && isActive && workoutState === 'started') { + // For single-set active exercises, store the timerEl from mainRow as setTimerEl + setTimerEl = timerEl; } // Controls row (only for active set during workout) diff --git a/styles.css b/styles.css index 7b15180..c78c0e9 100644 --- a/styles.css +++ b/styles.css @@ -184,9 +184,11 @@ } .workout-param-input { - width: 40px; + width: auto; + min-width: 20px; + max-width: 60px; height: 20px; - padding: 0; + padding: 0 4px; margin: 0; border: none; border-radius: 0; @@ -409,9 +411,11 @@ 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: 50px; + max-width: 100px; height: 28px; font-size: 1em; padding: 0 4px; From 42f8577c315b0e513279f368cd11b8b05506c744 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Thu, 9 Apr 2026 22:26:33 -0500 Subject: [PATCH 08/42] added css to fix spacing between params --- src/renderer/exercise.ts | 3 ++- styles.css | 23 +++++++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index 0bd455e..d30aa13 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -207,7 +207,8 @@ export function renderExercise( } // Params inline (between name and timer) - chip/pill style - if (exercise.params.length > 0) { + // For multi-set exercises, only totals are shown, not exercise params + if (exercise.params.length > 0 && !hasMultipleSets) { const paramsEl = mainRow.createSpan({ cls: 'workout-exercise-params' }); for (const param of exercise.params) { diff --git a/styles.css b/styles.css index c78c0e9..2149bb5 100644 --- a/styles.css +++ b/styles.css @@ -158,12 +158,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; @@ -185,7 +192,7 @@ .workout-param-input { width: auto; - min-width: 20px; + min-width: 42px; max-width: 60px; height: 20px; padding: 0 4px; @@ -403,6 +410,14 @@ justify-content: flex-start; } + .workout-set-params { + flex: 0 0 100%; + flex-wrap: wrap; + gap: 6px; + order: 10; + justify-content: flex-start; + } + /* Individual Param Pills - Larger touch targets, compact width */ .workout-param { flex: none; @@ -414,7 +429,7 @@ /* Input Fields - Much larger for touch, flexible width */ .workout-param-input { width: auto; - min-width: 50px; + min-width: 60px; max-width: 100px; height: 28px; font-size: 1em; From 978f25229f0f340258acce9db40b2a1f7b94a4a7 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Thu, 9 Apr 2026 23:20:57 -0500 Subject: [PATCH 09/42] refactored and added comments to main.ts --- src/main.ts | 642 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 402 insertions(+), 240 deletions(-) diff --git a/src/main.ts b/src/main.ts index 9db18e8..ed88fb4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,31 +4,56 @@ import { serializeWorkout, updateParamValue, updateSetParamValue, updateExercise import { renderWorkout } from './renderer'; import { TimerManager } from './timer/manager'; import { FileUpdater } from './file/updater'; -import { ParsedWorkout, WorkoutCallbacks, SectionInfo } from './types'; +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 el - DOM element where UI should be rendered + * @param ctx - Obsidian context (has file path, section info, etc) + */ private processWorkoutBlock( source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext ): void { + // Parse markdown source into ParsedWorkout structure const parsed = parseWorkout(source); const sectionInfo = ctx.getSectionInfo(el) as SectionInfo | null; @@ -37,25 +62,30 @@ export default class WorkoutLogPlugin extends Plugin { console.warn('Workout Log: sectionInfo is null for', ctx.sourcePath, '- file updates may not work correctly'); } + // 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); } } + // 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, parsed, @@ -65,216 +95,384 @@ 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: Branch based on whether there are more sets in this exercise + if (setIndex < exercise.sets.length - 1) { + // More sets exist - check if current set has a rest period attached + const currentSet = exercise.sets[setIndex]; + if (!currentSet) return currentParsed; + const restParam = currentSet.params.find(p => p.key.toLowerCase() === 'rest'); + + if (restParam) { + // Has rest period - save state first, then start rest timer + await this.updateFileWithParsed(ctx, sectionInfo, currentParsed); + const restDurationSeconds = parseDurationToSeconds(restParam.value); + await handleRestStart(exerciseIndex, restDurationSeconds); + } else { + // No rest - 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 { + // Last set in exercise - mark exercise as completed and look for next + 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 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); + + // 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 let currentParsed = parsed; let hasPendingChanges = false; const updateFile = async (newParsed: ParsedWorkout): Promise => { currentParsed = newParsed; 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); - - // Save to properties if enabled - if (currentParsed.metadata.saveToProperties) { - await this.fileUpdater?.saveToProperties(ctx.sourcePath, currentParsed); - } + await this.updateFileWithParsed(ctx, sectionInfo, newParsed); }; - // Flush any pending param changes to file const flushChanges = async (): Promise => { if (hasPendingChanges) { await updateFile(currentParsed); } }; - // Define callbacks object so methods can reference each other - const callbacks: WorkoutCallbacks = { + return { 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); }, 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); }, onExerciseFinish: async (exerciseIndex: number): Promise => { - // Deprecated: kept for backwards compatibility - // Delegates to onSetFinish or onRestEnd based on state const activeSetIndex = this.timerManager.getActiveSetIndex(workoutId); const timerState = this.timerManager.getTimerState(workoutId); if (timerState?.isRestActive) { - await callbacks.onRestEnd(exerciseIndex); + currentParsed = await this.handleRestEnd(exerciseIndex, currentParsed, ctx, sectionInfo, workoutId); } else { - await callbacks.onSetFinish(exerciseIndex, activeSetIndex); - } - }, - - onSetFinish: async (exerciseIndex: number, setIndex: number): Promise => { - hasPendingChanges = false; - const exercise = currentParsed.exercises[exerciseIndex]; - if (!exercise) return; - - const timerState = this.timerManager.getTimerState(workoutId); - - // Record duration for current set - if (timerState) { - currentParsed = setSetRecordedDuration( - currentParsed, + currentParsed = await this.handleSetFinish( exerciseIndex, - setIndex, - formatDurationHuman(timerState.exerciseElapsed) + activeSetIndex, + currentParsed, + ctx, + sectionInfo, + workoutId, + this.handleRestStart.bind(this, ctx, sectionInfo, workoutId) ); } + }, - // Mark current set as completed - currentParsed = updateSetState(currentParsed, exerciseIndex, setIndex, 'completed'); - - // Check if there are more sets in this exercise - if (setIndex < exercise.sets.length - 1) { - // Check if current set has a rest period - const currentSet = exercise.sets[setIndex]; - if (!currentSet) return; - const restParam = currentSet.params.find(p => p.key.toLowerCase() === 'rest'); - - if (restParam) { - // Start rest timer - save state first - await updateFile(currentParsed); - // Then start rest transition - const restDurationSeconds = parseDurationToSeconds(restParam.value); - await callbacks.onRestStart(exerciseIndex, restDurationSeconds); - } else { - // No rest, advance immediately to next set - const nextSetIndex = setIndex + 1; - currentParsed = updateSetState(currentParsed, exerciseIndex, nextSetIndex, 'inProgress'); - this.timerManager.advanceSet(workoutId, exerciseIndex, nextSetIndex); - await updateFile(currentParsed); - } - } else { - // No more sets, finish the exercise and move to next exercise - currentParsed = updateExerciseState(currentParsed, exerciseIndex, 'completed'); - - // Find next pending exercise - const nextPending = currentParsed.exercises.findIndex( - (e, i) => i > exerciseIndex && e.state === 'pending' - ); - - if (nextPending >= 0) { - // Activate next exercise - currentParsed = updateExerciseState(currentParsed, nextPending, 'inProgress'); - 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); - await updateFile(currentParsed); - this.timerManager.stopWorkoutTimer(workoutId); + onSetFinish: async (exerciseIndex: number, setIndex: number): Promise => { + currentParsed = await this.handleSetFinish( + exerciseIndex, + setIndex, + currentParsed, + ctx, + sectionInfo, + workoutId, + async (exIdx: number, restDur: number) => { + await this.handleRestStart(ctx, sectionInfo, workoutId, exIdx, restDur); } - } + ); }, onRestStart: async (exerciseIndex: number, restDuration: number): Promise => { - hasPendingChanges = false; - - // Start the rest timer in the timer manager - this.timerManager.startRest(workoutId, restDuration); - - // Save any pending changes (state is already in currentParsed from onSetFinish) - await updateFile(currentParsed); + await this.handleRestStart(ctx, sectionInfo, workoutId, exerciseIndex, restDuration); }, onRestEnd: async (exerciseIndex: number): Promise => { - hasPendingChanges = false; - const exercise = currentParsed.exercises[exerciseIndex]; - if (!exercise) return; - - const activeSetIndex = this.timerManager.getActiveSetIndex(workoutId); - - // Rest finished, advance to next set - const nextSetIndex = activeSetIndex + 1; - if (nextSetIndex < exercise.sets.length) { - currentParsed = updateSetState(currentParsed, exerciseIndex, nextSetIndex, 'inProgress'); - this.timerManager.exitRest(workoutId, exerciseIndex, nextSetIndex); - await updateFile(currentParsed); - } else { - // No more sets in this exercise, move to next exercise - currentParsed = updateExerciseState(currentParsed, exerciseIndex, 'completed'); - - const nextPending = currentParsed.exercises.findIndex( - (e, i) => i > exerciseIndex && e.state === 'pending' - ); - - if (nextPending >= 0) { - currentParsed = updateExerciseState(currentParsed, nextPending, 'inProgress'); - 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); - await updateFile(currentParsed); - this.timerManager.stopWorkoutTimer(workoutId); - } - } + currentParsed = await this.handleRestEnd(exerciseIndex, currentParsed, ctx, sectionInfo, workoutId); }, 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; const activeSetIndex = this.timerManager.getActiveSetIndex(workoutId); - - // Record duration for current set const timerState = this.timerManager.getTimerState(workoutId); if (timerState) { currentParsed = setSetRecordedDuration( @@ -285,32 +483,28 @@ export default class WorkoutLogPlugin extends Plugin { ); } - // Mark current set as completed + // Mark current set done and activate the new set currentParsed = updateSetState(currentParsed, exerciseIndex, activeSetIndex, 'completed'); - - // Add new set currentParsed = addSet(currentParsed, exerciseIndex); - // The new set is at exercise.sets.length - 1, activate it const newSetIndex = currentParsed.exercises[exerciseIndex]?.sets.length || 0 - 1; currentParsed = updateSetState(currentParsed, exerciseIndex, newSetIndex, 'inProgress'); - // Advance timer to new set + // Update timer to track new set this.timerManager.advanceSet(workoutId, exerciseIndex, newSetIndex); - await updateFile(currentParsed); }, 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; const activeSetIndex = this.timerManager.getActiveSetIndex(workoutId); - - // Record duration for current set const timerState = this.timerManager.getTimerState(workoutId); + + // Record how long the current set took if (timerState) { currentParsed = setSetRecordedDuration( currentParsed, @@ -320,105 +514,56 @@ export default class WorkoutLogPlugin extends Plugin { ); } - // Mark current set as completed + // Mark current set done, insert rest exercise at next position currentParsed = updateSetState(currentParsed, exerciseIndex, activeSetIndex, 'completed'); - - // Add rest exercise (inserts after current) currentParsed = addRest(currentParsed, exerciseIndex, restDuration); - - // The new rest is at exerciseIndex + 1, activate it with its first set currentParsed = updateExerciseState(currentParsed, exerciseIndex + 1, 'inProgress'); - // Advance timer to new exercise with set index 0 + // Update timer to track new rest exercise this.timerManager.advanceSet(workoutId, exerciseIndex + 1, 0); - await updateFile(currentParsed); }, onExerciseSkip: async (exerciseIndex: number): Promise => { - hasPendingChanges = false; // Will be saved by updateFile below - const exercise = currentParsed.exercises[exerciseIndex]; - if (!exercise) return; - - const activeSetIndex = this.timerManager.getActiveSetIndex(workoutId); - - // Mark current set as skipped - currentParsed = updateSetState(currentParsed, exerciseIndex, activeSetIndex, 'skipped'); - - // Check if there are more sets in this exercise - if (activeSetIndex < exercise.sets.length - 1) { - // Advance to next set - const nextSetIndex = activeSetIndex + 1; - currentParsed = updateSetState(currentParsed, exerciseIndex, nextSetIndex, 'inProgress'); - this.timerManager.advanceSet(workoutId, exerciseIndex, nextSetIndex); - await updateFile(currentParsed); - } else { - // No more sets, finish the exercise and move to next exercise - currentParsed = updateExerciseState(currentParsed, exerciseIndex, 'completed'); - - // Find next pending exercise - 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); - - await updateFile(currentParsed); - this.timerManager.stopWorkoutTimer(workoutId); - } - } + // User wants to skip current set/exercise + currentParsed = await this.handleExerciseSkip(exerciseIndex, currentParsed, ctx, sectionInfo, workoutId); }, 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 }, onSetParamChange: (exerciseIndex: number, setIndex: number, paramKey: string, newValue: string): void => { - // Check if value actually changed + // 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 update - } + if (param?.value === newValue) return; // No change, skip currentParsed = updateSetParamValue(currentParsed, exerciseIndex, setIndex, paramKey, newValue); - hasPendingChanges = true; - // Don't save to file yet - wait for flush + hasPendingChanges = true; // Flag for later flush }, onFlushChanges: flushChanges, onPauseExercise: (): void => { + // User paused the timer (stops counting but doesn't finish/advance) this.timerManager.pauseExercise(workoutId); }, onResumeExercise: (): void => { + // User resumed the paused timer to continue counting this.timerManager.resumeExercise(workoutId); }, onAddSample: async (): Promise => { + // User clicked "Load Sample" - creates a demo workout to explore features const sampleWorkout = createSampleWorkout(); const newContent = serializeWorkout(sampleWorkout); await this.fileUpdater?.updateCodeBlock( @@ -429,10 +574,27 @@ export default class WorkoutLogPlugin extends Plugin { ); } }; + } - return callbacks; + /** + * 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); } + /** + * 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'); From 8e96ea0c3f96a0e3c674a896d5c1b9f16fcec284 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Fri, 10 Apr 2026 00:14:33 -0500 Subject: [PATCH 10/42] added saving ~time and ~rest to each set --- src/main.ts | 13 +- src/parser/exercise.test.ts | 39 ++++-- src/parser/exercise.ts | 29 ++++- src/serializer.test.ts | 232 +++++++++++++++++++++++++++++++++++- src/serializer.ts | 166 +++++++++++++++++++++++--- src/types.ts | 2 + 6 files changed, 446 insertions(+), 35 deletions(-) diff --git a/src/main.ts b/src/main.ts index ed88fb4..0857b57 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,6 @@ import { Plugin, MarkdownPostProcessorContext } from 'obsidian'; import { parseWorkout } from './parser'; -import { serializeWorkout, updateParamValue, updateSetParamValue, updateExerciseState, updateSetState, addSet, addRest, setRecordedDuration, setSetRecordedDuration, 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'; @@ -284,6 +284,17 @@ export default class WorkoutLogPlugin extends Plugin { 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) { diff --git a/src/parser/exercise.test.ts b/src/parser/exercise.test.ts index 1fd0e0f..8cd0172 100644 --- a/src/parser/exercise.test.ts +++ b/src/parser/exercise.test.ts @@ -425,22 +425,41 @@ describe('Integration tests', () => { expect(exercise!.params).toHaveLength(0); }); - it('should handle param with only a value and no unit', () => { - const line = '- [ ] Exercise | Sets: 3'; + 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); - expect(exercise!.params[0].key).toBe('Sets'); - expect(exercise!.params[0].value).toBe('3'); - expect(exercise!.params[0].unit).toBeUndefined(); + // 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 handle bracketed params with spaces inside', () => { - const line = '- [ ] Exercise | Notes: [my note]'; + it('should allow system-managed totals parameters (~time and ~rest)', () => { + // ~time and ~rest are system-managed (locked) parameters + const line = '- [x] Exercise | ~rest: 5m | ~time: 30m'; const exercise = parseExercise(line, 0); - expect(exercise!.params[0].key).toBe('Notes'); - expect(exercise!.params[0].value).toBe('my note'); - expect(exercise!.params[0].editable).toBe(true); + expect(exercise!.params).toHaveLength(2); + expect(exercise!.params[0].key).toBe('~rest'); + expect(exercise!.params[0].value).toBe('5m'); + expect(exercise!.params[0].editable).toBe(false); + expect(exercise!.params[1].key).toBe('~time'); + expect(exercise!.params[1].value).toBe('30m'); + expect(exercise!.params[1].editable).toBe(false); }); }); diff --git a/src/parser/exercise.ts b/src/parser/exercise.ts index 1832dba..df44c90 100644 --- a/src/parser/exercise.ts +++ b/src/parser/exercise.ts @@ -94,18 +94,31 @@ export function parseSet(line: string, lineIndex: number): ExerciseSet | null { const paramStrings = parts; const params: ExerciseParam[] = []; + let recordedDuration: string | undefined; + let recordedRest: string | undefined; for (const paramStr of paramStrings) { const param = parseParam(paramStr); if (param) { - params.push(param); + // Extract recorded times without adding them to params + if (param.key.toLowerCase() === '~time') { + recordedDuration = param.value; + // Don't add to params - these are computed values + } else if (param.key.toLowerCase() === '~rest') { + recordedRest = param.value; + // Don't add to params - these are computed values + } else { + params.push(param); + } } } return { state, params, - lineIndex + lineIndex, + recordedDuration, + recordedRest }; } @@ -177,13 +190,21 @@ function parseParam(paramStr: string): ExerciseParam | null { 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)) { + return null; // Ignore unrecognized parameters + } + const unitToken = tokens.find(t => t.type === 'unit'); let finalValue = valueToken.value; let finalUnit = unitToken?.value; - // For Duration, combine value and unit since they're part of duration syntax (e.g., "3m2s") - if (keyToken.value.toLowerCase() === 'duration' && finalUnit) { + // For Duration, ~time, and ~rest, combine value and unit since they're part of duration syntax (e.g., "3m2s") + if ((keyToken.value.toLowerCase() === 'duration' || keyToken.value.toLowerCase() === '~time' || keyToken.value.toLowerCase() === '~rest') && finalUnit) { finalValue = finalValue + finalUnit; finalUnit = undefined; } diff --git a/src/serializer.test.ts b/src/serializer.test.ts index f46badb..38a56e3 100644 --- a/src/serializer.test.ts +++ b/src/serializer.test.ts @@ -263,8 +263,8 @@ describe('setSetRecordedDuration', () => { const parsed = parseWorkout(source); const updated = setSetRecordedDuration(parsed, 0, 0, '2m 30s'); - const durationParam = updated.exercises[0].sets[0].params.find(p => p.key === 'Duration'); - expect(durationParam?.value).toBe('2m 30s'); + // Should store to recordedDuration field, not Duration param + expect(updated.exercises[0].sets[0].recordedDuration).toBe('2m 30s'); }); }); @@ -280,6 +280,169 @@ describe('updateSetRestDuration', () => { }); }); +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); + + expect(parsed.exercises[0].params).toHaveLength(2); + expect(parsed.exercises[0].params.find(p => p.key === '~rest')).toBeDefined(); + expect(parsed.exercises[0].params.find(p => p.key === '~time')).toBeDefined(); + }); + + 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].recordedDuration = '1m 30s'; + parsed.exercises[0].sets[1].recordedDuration = '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].recordedDuration = '2m'; + parsed.exercises[0].sets[0].recordedRest = '45s'; + parsed.exercises[0].sets[1].recordedDuration = '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].recordedDuration = '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].recordedDuration = '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].recordedDuration = '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].recordedDuration = '1m'; + parsed.exercises[0].sets[0].recordedRest = '30s'; + + const serialized = serializeWorkout(parsed); + const reparsed = parseWorkout(serialized); + + // ~rest and ~time should be parsed as params + expect(reparsed.exercises[0].params.find(p => p.key === '~rest')).toBeDefined(); + expect(reparsed.exercises[0].params.find(p => p.key === '~time')).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].recordedDuration = '2m'; + parsed.exercises[0].sets[0].recordedRest = '60s'; + parsed.exercises[0].sets[1].recordedDuration = '2m 30s'; + parsed.exercises[0].sets[1].recordedRest = '60s'; + parsed.exercises[0].sets[2].recordedDuration = '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(); @@ -301,3 +464,68 @@ describe('serializeWorkoutAsTemplate', () => { 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].recordedDuration = '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].recordedDuration).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].recordedDuration).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].recordedDuration = '2m'; + parsed1.exercises[0].sets[0].recordedRest = '60s'; + parsed1.exercises[0].sets[1].recordedDuration = '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'); + expect(serialized1).toContain('- [x] | Rest: 60 s | ~time: 2m | ~rest: 60s'); + + // Parse back + const parsed2 = parseWorkout(serialized1); + + // Verify set totals are preserved + expect(parsed2.exercises[0].sets[0].recordedDuration).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].recordedDuration).toBeTruthy(); + expect(parsed2.exercises[0].sets[1].recordedDuration?.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 5c0e2b4..2ec9176 100644 --- a/src/serializer.ts +++ b/src/serializer.ts @@ -1,6 +1,6 @@ -import { ParsedWorkout, Exercise, ExerciseState, ExerciseSet } from './types'; +import { ParsedWorkout, Exercise, ExerciseState, ExerciseSet, ExerciseParam } from './types'; import { serializeMetadata } from './parser/metadata'; -import { serializeExercise, serializeSet, getStateChar } from './parser/exercise'; +import { serializeExercise, serializeSet, getStateChar, formatDurationHuman, parseDurationToSeconds } from './parser/exercise'; export function serializeWorkout(parsed: ParsedWorkout): string { const lines: string[] = []; @@ -14,10 +14,12 @@ export function serializeWorkout(parsed: ParsedWorkout): string { // 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 sets under the exercise - for (const set of exercise.sets) { + // Serialize enriched sets under the exercise + for (const set of enrichedExercise.sets) { lines.push(serializeSet(set)); } } @@ -25,6 +27,123 @@ export function serializeWorkout(parsed: ParsedWorkout): string { return lines.join('\n'); } +/** + * Enrich sets with their own totals (~time, ~rest), then compute exercise totals. + * For incomplete sets: strip any existing totals + * For completed sets: add ~time (duration) and ~rest (rest) params + * For incomplete exercises: strip exercise totals + * For completed exercises: sum set-level totals to create exercise 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 + const paramsWithoutTotals = exercise.params.filter( + p => p.key.toLowerCase() !== '~time' && p.key.toLowerCase() !== '~rest' + ); + + // If exercise is incomplete, don't add totals + 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 (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 its own totals (~time, ~rest). + * ~time comes from the set's recordedDuration (actual elapsed time during set) + * ~rest comes from the set's recordedRest (actual elapsed time during rest) + * For incomplete sets: strip any existing totals + */ +function enrichSetWithTotals(set: ExerciseSet): ExerciseSet { + // Remove old set totals + 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.recordedDuration) { + paramsWithoutTotals.push({ + key: '~time', + value: set.recordedDuration, + editable: false, + unit: '', + }); + } + + if (set.recordedRest) { + paramsWithoutTotals.push({ + key: '~rest', + value: set.recordedRest, + editable: false, + unit: '', + }); + } + + return { + ...set, + params: paramsWithoutTotals, + }; +} + +/** + * Sum a specific total param (~time or ~rest) across all sets and return formatted string. + * Returns empty string if no totals found. + */ +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 a specific param value in a workout (exercise-level params) export function updateParamValue( parsed: ParsedWorkout, @@ -221,20 +340,31 @@ export function setSetRecordedDuration( const set = exercise.sets[setIndex]; if (!set) return parsed; - // Find Duration param or add one - let durationParam = set.params.find(p => p.key.toLowerCase() === 'duration'); + // Store actual elapsed time to recordedDuration (not Duration param) + set.recordedDuration = durationStr; - if (durationParam) { - durationParam.value = durationStr; - durationParam.editable = false; - } else { - // Add Duration param - set.params.push({ - key: 'Duration', - value: durationStr, - editable: false - }); - } + 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; } diff --git a/src/types.ts b/src/types.ts index f240568..173d99e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,8 @@ export interface ExerciseSet { state: ExerciseState; params: ExerciseParam[]; lineIndex: number; // Line index relative to exercise section start + recordedDuration?: 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 From 98594081c9b03f7a702d17e5c47ed803ba6304fb Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Fri, 10 Apr 2026 20:46:32 -0500 Subject: [PATCH 11/42] added comments --- src/parser/exercise.ts | 151 +++++++++++++++++++++++++++++++++++------ src/parser/index.ts | 51 ++++++++++++-- 2 files changed, 176 insertions(+), 26 deletions(-) diff --git a/src/parser/exercise.ts b/src/parser/exercise.ts index df44c90..d0f7e4c 100644 --- a/src/parser/exercise.ts +++ b/src/parser/exercise.ts @@ -1,6 +1,9 @@ import { Exercise, ExerciseSet, ExerciseState, ExerciseParam, Token } from '../types'; -// Checkbox patterns: [ ] pending, [\] inProgress, [x] completed, [-] skipped +/** + * Maps checkbox characters to exercise/set states. + * Format: [state] where state = ' ' (pending), '\' (inProgress), 'x' (completed), '-' (skipped) + */ const STATE_MAP: Record = { ' ': 'pending', '\\': 'inProgress', @@ -8,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': '\\', @@ -15,6 +22,14 @@ const STATE_CHAR_MAP: Record = { 'skipped': '-' }; +/** + * 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; @@ -38,6 +53,16 @@ function tokenizeExerciseLine(line: string): { stateChar: string; remainder: str 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 = recorded time + * - All other params are stored in exercise.params + * - Returns empty sets array (filled by parent parseWorkout) + */ export function parseExercise(line: string, lineIndex: number): Exercise | null { const tokenized = tokenizeExerciseLine(line); if (!tokenized) return null; @@ -58,7 +83,8 @@ export function parseExercise(line: string, lineIndex: number): Exercise | null if (param) { params.push(param); - // Special handling for Duration key + // Extract duration information into dedicated fields + // Separate handling for countdown targets vs recorded times if (param.key.toLowerCase() === 'duration') { if (param.editable) { // Editable duration = countdown target @@ -75,13 +101,23 @@ export function parseExercise(line: string, lineIndex: number): Exercise | null state, name, params, - sets: [], - targetDuration, - recordedDuration, + sets: [], // Set by parent parseWorkout, not by this function + targetDuration, // Seconds: used for countdown timers + recordedDuration, // String: actual time recorded after completion lineIndex }; } +/** + * 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 recordedDuration/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); @@ -100,7 +136,8 @@ export function parseSet(line: string, lineIndex: number): ExerciseSet | null { for (const paramStr of paramStrings) { const param = parseParam(paramStr); if (param) { - // Extract recorded times without adding them to params + // System-managed totals: store separately, don't include in params list + // These are computed during serialization and should not be editable if (param.key.toLowerCase() === '~time') { recordedDuration = param.value; // Don't add to params - these are computed values @@ -117,11 +154,27 @@ export function parseSet(line: string, lineIndex: number): ExerciseSet | null { state, params, lineIndex, - recordedDuration, - recordedRest + recordedDuration, // 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[] = []; @@ -132,10 +185,11 @@ function tokenizeParam(paramStr: string): Token[] { const key = paramStr.substring(0, colonIndex).trim(); tokens.push({ type: 'key', value: key }); - // 2. Parse value and unit (after colon) + // 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 + // Check for bracketed value (editable) if (remainder.startsWith('[')) { const closeBracketIndex = remainder.indexOf(']'); if (closeBracketIndex !== -1) { @@ -144,10 +198,12 @@ function tokenizeParam(paramStr: string): Token[] { remainder = remainder.substring(closeBracketIndex + 1).trim(); } } else if (remainder.length > 0) { - // Unbracketed value - read until space + // 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); @@ -159,21 +215,22 @@ function tokenizeParam(paramStr: string): Token[] { } if (numericEnd > 0 && numericEnd < remainder.length) { - // Has both numeric value and non-numeric unit + // Split: numeric value + non-numeric unit (e.g., "100" + "kg") tokens.push({ type: 'value', value: remainder.substring(0, numericEnd) }); remainder = remainder.substring(numericEnd); } else { - // No unit attached or all numeric, entire remainder is value + // 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(); } } - // 3. Parse unit (whatever remains) + // Step 3: Parse unit (any remaining text after value) if (remainder) { tokens.push({ type: 'unit', value: remainder }); } @@ -181,6 +238,22 @@ function tokenizeParam(paramStr: string): Token[] { 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; @@ -203,8 +276,10 @@ function parseParam(paramStr: string): ExerciseParam | null { let finalValue = valueToken.value; let finalUnit = unitToken?.value; - // For Duration, ~time, and ~rest, combine value and unit since they're part of duration syntax (e.g., "3m2s") - if ((keyToken.value.toLowerCase() === 'duration' || keyToken.value.toLowerCase() === '~time' || keyToken.value.toLowerCase() === '~rest') && finalUnit) { + // 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', '~time', '~rest'].includes(keyToken.value.toLowerCase()); + if (isDurationParam && finalUnit) { finalValue = finalValue + finalUnit; finalUnit = undefined; } @@ -217,7 +292,17 @@ function parseParam(paramStr: string): ExerciseParam | null { }; } -// 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(); @@ -252,14 +337,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; @@ -272,20 +366,37 @@ 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) { @@ -297,9 +408,11 @@ export function serializeExercise(exercise: Exercise): string { } 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}: `; diff --git a/src/parser/index.ts b/src/parser/index.ts index 07ad896..a9aba3e 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -2,10 +2,36 @@ import { ParsedWorkout, Exercise, ExerciseSet } from '../types'; import { parseMetadata } from './metadata'; 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,13 +40,17 @@ 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 ---), handling nested sets + // 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); @@ -34,7 +64,8 @@ export function parseWorkout(source: string): ParsedWorkout { const isIndented = line.match(/^\s+/); if (isIndented) { - // This is a set (indented line) + // Indented line = Set + // Add to current exercise's sets array if (currentExercise) { const set = parseSet(line, i); if (set) { @@ -42,8 +73,9 @@ export function parseWorkout(source: string): ParsedWorkout { } } } else { - // This is a parent exercise (no indent) - // Save previous exercise if it has no sets, create a default one + // 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, @@ -64,7 +96,8 @@ export function parseWorkout(source: string): ParsedWorkout { } } - // Handle last exercise: if it has no sets, create one from its params + // 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, @@ -82,5 +115,9 @@ export function parseWorkout(source: string): ParsedWorkout { }; } +// Re-export metadata parsing and serialization export { parseMetadata, serializeMetadata } from './metadata'; + +// 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'; From 63e291921656d8535f1a302ab4f423910bdc61ae Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Fri, 10 Apr 2026 21:29:42 -0500 Subject: [PATCH 12/42] changed symbol name from recordedDuration to recordedTime in exercise --- src/parser/exercise.ts | 6 +++--- src/renderer/exercise.test.ts | 10 ++++----- src/serializer.test.ts | 40 +++++++++++++++++------------------ src/serializer.ts | 6 +++--- src/types.ts | 2 +- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/parser/exercise.ts b/src/parser/exercise.ts index d0f7e4c..395cbc0 100644 --- a/src/parser/exercise.ts +++ b/src/parser/exercise.ts @@ -130,7 +130,7 @@ export function parseSet(line: string, lineIndex: number): ExerciseSet | null { const paramStrings = parts; const params: ExerciseParam[] = []; - let recordedDuration: string | undefined; + let recordedTime: string | undefined; let recordedRest: string | undefined; for (const paramStr of paramStrings) { @@ -139,7 +139,7 @@ export function parseSet(line: string, lineIndex: number): ExerciseSet | null { // System-managed totals: store separately, don't include in params list // These are computed during serialization and should not be editable if (param.key.toLowerCase() === '~time') { - recordedDuration = param.value; + recordedTime = param.value; // Don't add to params - these are computed values } else if (param.key.toLowerCase() === '~rest') { recordedRest = param.value; @@ -154,7 +154,7 @@ export function parseSet(line: string, lineIndex: number): ExerciseSet | null { state, params, lineIndex, - recordedDuration, // Actual elapsed time during set + recordedTime, // Actual elapsed time during set recordedRest // Actual elapsed rest period after set }; } diff --git a/src/renderer/exercise.test.ts b/src/renderer/exercise.test.ts index 3d09957..012f802 100644 --- a/src/renderer/exercise.test.ts +++ b/src/renderer/exercise.test.ts @@ -843,8 +843,8 @@ describe('renderExercise & updateExerciseTimer', () => { { key: 'Reps', value: '30', editable: false, unit: '' } ], sets: [ - { state: 'completed', params: [], recordedDuration: '3m' }, - { state: 'completed', params: [], recordedDuration: '2m 45s' } + { state: 'completed', params: [], recordedTime: '3m' }, + { state: 'completed', params: [], recordedTime: '2m 45s' } ], lineIndex: 0, recordedDuration: '5m 45s' @@ -1191,7 +1191,7 @@ describe('renderExercise & updateExerciseTimer', () => { { state: 'completed', params: [], - recordedDuration: '15m 30s' + recordedTime: '15m 30s' } ], lineIndex: 0 @@ -1384,7 +1384,7 @@ describe('renderExercise & updateExerciseTimer', () => { { state: 'completed', params: [], - recordedDuration: '2m 10s', + recordedTime: '2m 10s', restDuration: '120s' } ], @@ -1628,7 +1628,7 @@ describe('renderExercise & updateExerciseTimer', () => { params: [], sets: [ { state: 'completed', params: [] }, - { state: 'completed', params: [], recordedDuration: '2m' }, + { state: 'completed', params: [], recordedTime: '2m' }, { state: 'in-progress', params: [] }, { state: 'pending', params: [] }, { state: 'skipped', params: [] } diff --git a/src/serializer.test.ts b/src/serializer.test.ts index 38a56e3..ffe24ee 100644 --- a/src/serializer.test.ts +++ b/src/serializer.test.ts @@ -264,7 +264,7 @@ describe('setSetRecordedDuration', () => { const updated = setSetRecordedDuration(parsed, 0, 0, '2m 30s'); // Should store to recordedDuration field, not Duration param - expect(updated.exercises[0].sets[0].recordedDuration).toBe('2m 30s'); + expect(updated.exercises[0].sets[0].recordedTime).toBe('2m 30s'); }); }); @@ -321,8 +321,8 @@ describe('totals persistence (~time and ~rest)', () => { const parsed = parseWorkout(source); // Set recorded durations (simulating actual elapsed time from timer) - parsed.exercises[0].sets[0].recordedDuration = '1m 30s'; - parsed.exercises[0].sets[1].recordedDuration = '2m'; + parsed.exercises[0].sets[0].recordedTime = '1m 30s'; + parsed.exercises[0].sets[1].recordedTime = '2m'; const serialized = serializeWorkout(parsed); @@ -335,9 +335,9 @@ describe('totals persistence (~time and ~rest)', () => { const parsed = parseWorkout(source); // Set recorded times - parsed.exercises[0].sets[0].recordedDuration = '2m'; + parsed.exercises[0].sets[0].recordedTime = '2m'; parsed.exercises[0].sets[0].recordedRest = '45s'; - parsed.exercises[0].sets[1].recordedDuration = '2m'; + parsed.exercises[0].sets[1].recordedTime = '2m'; parsed.exercises[0].sets[1].recordedRest = '45s'; const serialized = serializeWorkout(parsed); @@ -351,7 +351,7 @@ describe('totals persistence (~time and ~rest)', () => { const parsed = parseWorkout(source); // Set recorded times (should overwrite old ~rest/~time from params) - parsed.exercises[0].sets[0].recordedDuration = '1m'; + parsed.exercises[0].sets[0].recordedTime = '1m'; parsed.exercises[0].sets[0].recordedRest = '30s'; const serialized = serializeWorkout(parsed); @@ -368,7 +368,7 @@ describe('totals persistence (~time and ~rest)', () => { const parsed = parseWorkout(source); // Set only recordedDuration - parsed.exercises[0].sets[0].recordedDuration = '1m'; + parsed.exercises[0].sets[0].recordedTime = '1m'; const serialized = serializeWorkout(parsed); @@ -394,7 +394,7 @@ describe('totals persistence (~time and ~rest)', () => { const parsed = parseWorkout(source); // Set recorded times - parsed.exercises[0].sets[0].recordedDuration = '1m 30s'; + parsed.exercises[0].sets[0].recordedTime = '1m 30s'; parsed.exercises[0].sets[0].recordedRest = '45s'; const serialized = serializeWorkout(parsed); @@ -411,7 +411,7 @@ describe('totals persistence (~time and ~rest)', () => { const parsed = parseWorkout(source); // Set recorded times - parsed.exercises[0].sets[0].recordedDuration = '1m'; + parsed.exercises[0].sets[0].recordedTime = '1m'; parsed.exercises[0].sets[0].recordedRest = '30s'; const serialized = serializeWorkout(parsed); @@ -427,11 +427,11 @@ describe('totals persistence (~time and ~rest)', () => { const parsed = parseWorkout(source); // Set recorded times for each set - parsed.exercises[0].sets[0].recordedDuration = '2m'; + parsed.exercises[0].sets[0].recordedTime = '2m'; parsed.exercises[0].sets[0].recordedRest = '60s'; - parsed.exercises[0].sets[1].recordedDuration = '2m 30s'; + parsed.exercises[0].sets[1].recordedTime = '2m 30s'; parsed.exercises[0].sets[1].recordedRest = '60s'; - parsed.exercises[0].sets[2].recordedDuration = '2m 15s'; + parsed.exercises[0].sets[2].recordedTime = '2m 15s'; parsed.exercises[0].sets[2].recordedRest = '45s'; const serialized = serializeWorkout(parsed); @@ -472,7 +472,7 @@ describe('totals persistence roundtrip', () => { const parsed1 = parseWorkout(source); // Set recorded times - parsed1.exercises[0].sets[0].recordedDuration = '2m'; + parsed1.exercises[0].sets[0].recordedTime = '2m'; parsed1.exercises[0].sets[0].recordedRest = '45s'; // First serialize - should add ~time and ~rest @@ -484,7 +484,7 @@ describe('totals persistence roundtrip', () => { const parsed2 = parseWorkout(serialized1); // Check that ~time and ~rest were extracted into recordedDuration/recordedRest - expect(parsed2.exercises[0].sets[0].recordedDuration).toBe('2m'); + 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 @@ -494,7 +494,7 @@ describe('totals persistence roundtrip', () => { // Parse and check one more time const parsed3 = parseWorkout(serialized2); - expect(parsed3.exercises[0].sets[0].recordedDuration).toBe('2m'); + expect(parsed3.exercises[0].sets[0].recordedTime).toBe('2m'); expect(parsed3.exercises[0].sets[0].recordedRest).toBe('45s'); }); @@ -503,9 +503,9 @@ describe('totals persistence roundtrip', () => { const parsed1 = parseWorkout(source); // Set recorded times for both sets - parsed1.exercises[0].sets[0].recordedDuration = '2m'; + parsed1.exercises[0].sets[0].recordedTime = '2m'; parsed1.exercises[0].sets[0].recordedRest = '60s'; - parsed1.exercises[0].sets[1].recordedDuration = '2m 30s'; + parsed1.exercises[0].sets[1].recordedTime = '2m 30s'; parsed1.exercises[0].sets[1].recordedRest = '60s'; // First serialize - should calculate exercise totals @@ -517,11 +517,11 @@ describe('totals persistence roundtrip', () => { const parsed2 = parseWorkout(serialized1); // Verify set totals are preserved - expect(parsed2.exercises[0].sets[0].recordedDuration).toBe('2m'); + 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].recordedDuration).toBeTruthy(); - expect(parsed2.exercises[0].sets[1].recordedDuration?.replace(/\s+/g, '')).toBe('2m30s'); + 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 diff --git a/src/serializer.ts b/src/serializer.ts index 2ec9176..0deef5a 100644 --- a/src/serializer.ts +++ b/src/serializer.ts @@ -103,10 +103,10 @@ function enrichSetWithTotals(set: ExerciseSet): ExerciseSet { } // For completed sets, use recorded times as totals - if (set.recordedDuration) { + if (set.recordedTime) { paramsWithoutTotals.push({ key: '~time', - value: set.recordedDuration, + value: set.recordedTime, editable: false, unit: '', }); @@ -341,7 +341,7 @@ export function setSetRecordedDuration( if (!set) return parsed; // Store actual elapsed time to recordedDuration (not Duration param) - set.recordedDuration = durationStr; + set.recordedTime = durationStr; return newParsed; } diff --git a/src/types.ts b/src/types.ts index 173d99e..efd1394 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,7 +26,7 @@ export interface ExerciseSet { state: ExerciseState; params: ExerciseParam[]; lineIndex: number; // Line index relative to exercise section start - recordedDuration?: string; // Actual elapsed time during the set (from timer) + recordedTime?: string; // Actual elapsed time during the set (from timer) recordedRest?: string; // Actual elapsed time during rest period after this set } From 969a1de2f83ac76817e6aa65f5c05661c665a2d8 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Fri, 10 Apr 2026 22:48:53 -0500 Subject: [PATCH 13/42] added comments --- src/file/updater.ts | 159 +++++++++++++++++-- src/main.ts | 8 +- src/parser/exercise.ts | 11 ++ src/parser/metadata.ts | 74 ++++++++- src/renderer/controls.ts | 51 +++++- src/renderer/emptyState.ts | 26 ++++ src/renderer/exercise.ts | 263 ++++++++++++++++++++++++++----- src/renderer/header.ts | 59 ++++++- src/renderer/index.ts | 100 +++++++++--- src/serializer.ts | 307 +++++++++++++++++++++++++++++++++---- src/timer/manager.ts | 289 +++++++++++++++++++++++++++++++--- 11 files changed, 1206 insertions(+), 141 deletions(-) diff --git a/src/file/updater.ts b/src/file/updater.ts index 9d1c333..95240a0 100644 --- a/src/file/updater.ts +++ b/src/file/updater.ts @@ -1,26 +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, 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) {} - // Normalize exercise name to camelCase for property names + /** + * 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 + /** + * 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); @@ -36,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, @@ -67,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.'); @@ -75,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); @@ -107,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, @@ -127,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; @@ -139,6 +246,26 @@ export class FileUpdater { }); } + /** + * 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)) { @@ -152,10 +279,12 @@ export class FileUpdater { } await this.withLock(sourcePath, async () => { - // Update file properties/frontmatter + // Build properties object from parsed workout data const properties: Record = {}; const metadata = parsed.metadata; + + // Workout-level metadata if (metadata.title) { properties.workoutTitle = metadata.title; } @@ -172,7 +301,7 @@ export class FileUpdater { properties.workoutRestDuration = metadata.restDuration; } - // Extract exercise data + // Exercise data (overall structure) if (parsed.exercises.length > 0) { const exercises = parsed.exercises.map(exercise => ({ name: exercise.name, @@ -189,17 +318,17 @@ export class FileUpdater { })); properties.workoutExercises = exercises; - // Calculate totals for each exercise and add as properties + // Calculate and add per-exercise totals for (const exercise of parsed.exercises) { const normalizedName = this.normalizeToCamelCase(exercise.name); - // Calculate totals from all sets of this exercise + // 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 + // Extract weight from set params const weightParam = set.params.find(p => p.key.toLowerCase() === 'weight'); if (weightParam && weightParam.value) { const weight = parseFloat(weightParam.value); @@ -208,7 +337,7 @@ export class FileUpdater { } } - // Extract reps + // 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); @@ -217,7 +346,7 @@ export class FileUpdater { } } - // Extract duration + // 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); @@ -227,7 +356,7 @@ export class FileUpdater { } } - // Add properties if any totals exist + // Add properties for this exercise if any totals exist if (totalWeight > 0) { properties[`${normalizedName}TotalWeight`] = totalWeight; } @@ -240,10 +369,10 @@ export class FileUpdater { } } - // Use Obsidian's API to set properties + // Update file frontmatter with properties try { await this.app.fileManager.processFrontMatter(file, (frontmatter) => { - // Update or add properties to frontmatter + // Merge properties into frontmatter Object.assign(frontmatter, properties); }); } catch (error) { diff --git a/src/main.ts b/src/main.ts index 0857b57..5de9438 100644 --- a/src/main.ts +++ b/src/main.ts @@ -45,17 +45,17 @@ export default class WorkoutLogPlugin extends Plugin { * 5. Render the UI and connect timers * * @param source - Raw markdown text from the code block - * @param el - DOM element where UI should be rendered + * @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) { @@ -87,7 +87,7 @@ export default class WorkoutLogPlugin extends Plugin { // Render the UI and wire up the timers and callbacks renderWorkout({ - el, + containerElement, parsed, callbacks, workoutId, diff --git a/src/parser/exercise.ts b/src/parser/exercise.ts index 395cbc0..60ad290 100644 --- a/src/parser/exercise.ts +++ b/src/parser/exercise.ts @@ -407,6 +407,17 @@ export function serializeExercise(exercise: Exercise): string { 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); diff --git a/src/parser/metadata.ts b/src/parser/metadata.ts index c5228ad..d63ced1 100644 --- a/src/parser/metadata.ts +++ b/src/parser/metadata.ts @@ -1,64 +1,132 @@ +/** + * 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': - metadata.saveToProperties = value.toLowerCase() === 'true'; - break; } + 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}`); } 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.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.ts b/src/renderer/exercise.ts index d30aa13..089ce68 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -1,6 +1,33 @@ +/** + * 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 + */ + import { Exercise, ExerciseSet, ExerciseParam, ExerciseState, TimerState, WorkoutCallbacks } from '../types'; import { formatDuration, parseDurationToSeconds, formatDurationHuman } from '../parser/exercise'; +/** + * Visual indicators for exercise/set states. + * Maps ExerciseState to Unicode symbols: + * - pending: ○ (empty circle) + * - inProgress: ◐ (half circle) + * - completed: ✓ (checkmark) + * - skipped: — (dash) + */ const STATE_ICONS: Record = { 'pending': '○', 'inProgress': '◐', @@ -8,7 +35,16 @@ const STATE_ICONS: Record = { 'skipped': '—' }; -// 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,6 +56,17 @@ 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; @@ -28,7 +75,15 @@ export interface ExerciseElements { setInputs: Map>; // Indexed by set index } -// Check if set has params to display (includes duration, weight, reps in order) +/** + * 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(); @@ -36,7 +91,16 @@ function hasDisplayableSetParams(set: ExerciseSet): boolean { }); } -// Get params to display in order: duration, weight, reps (only if provided) +/** + * 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']; const paramsMap = new Map(); @@ -61,19 +125,50 @@ function getDisplayableSetParams(set: ExerciseSet): ExerciseParam[] { return orderedParams; } -// Get recorded duration from a set (if any) +/** + * Extract recorded duration from a set (system-managed ~time param). + * Recorded durations are locked (not editable) params created after set completion. + * + * Parameters: + * - set: ExerciseSet to extract from + * + * Returns: Recorded duration string (e.g., "1m 30s") or null if not recorded + */ function getSetRecordedDuration(set: ExerciseSet): string | null { const durationParam = set.params.find(p => p.key.toLowerCase() === 'duration' && !p.editable); return durationParam ? durationParam.value : null; } -// Get rest duration from a set (if any) +/** + * Extract rest duration from a set's rest parameter. + * Rest is the period between sets (after completing one set, before starting next). + * + * Parameters: + * - set: ExerciseSet to extract from + * + * Returns: Rest duration string (e.g., "60s") or null if not set + */ function getSetRestDuration(set: ExerciseSet): string | null { const restParam = set.params.find(p => p.key.toLowerCase() === 'rest'); return restParam ? restParam.value : null; } -// Compute totals from all sets +/** + * 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; @@ -104,7 +199,7 @@ function computeExerciseTotals(exercise: Exercise, isCompleted: boolean): { weightFound = true; } } else if (param.key.toLowerCase() === 'rest') { - // Sum rest durations from all sets (only if editable, meaning user-set) + // Sum rest durations from all sets (only editable means user-configured) if (param.editable) { const restSeconds = parseDurationToSeconds(param.value); totalRest += restSeconds; @@ -112,11 +207,11 @@ function computeExerciseTotals(exercise: Exercise, isCompleted: boolean): { } } - // If exercise is completed, sum up recorded durations from each set + // If exercise is completed, sum recorded durations from each set if (isCompleted) { for (const param of set.params) { if (param.key.toLowerCase() === 'duration' && !param.editable) { - // This is a recorded duration (not editable means it's recorded) + // Non-editable duration = recorded time (captured during set) const seconds = parseDurationToSeconds(param.value); totalRecordedTime += seconds; } @@ -133,6 +228,28 @@ function computeExerciseTotals(exercise: Exercise, isCompleted: boolean): { }; } +/** + * 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, @@ -155,9 +272,9 @@ export function renderExercise( const inputs = new Map(); const setInputs = new Map>(); - let setTimerEl: HTMLElement | null = null; // Track active set timer for updates + 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 @@ -168,12 +285,12 @@ export function renderExercise( const nameEl = mainRow.createSpan({ cls: 'workout-exercise-name' }); nameEl.textContent = exercise.name; - // Determine if exercise has multiple sets + // Determine multi-set layout and calculate totals const hasMultipleSets = exercise.sets.length > 1; const isCompleted = exercise.state === 'completed'; const totals = computeExerciseTotals(exercise, isCompleted); - // For multi-set exercises, display totals from all sets + // Display totals on multi-set exercise main row if (hasMultipleSets && (totals.reps !== null || totals.weight !== null || (isCompleted && totals.totalRecordedTime > 0) || totals.totalRest > 0)) { const totalsEl = mainRow.createSpan({ cls: 'workout-exercise-params' }); @@ -191,7 +308,7 @@ export function renderExercise( repsEl.createSpan({ cls: 'workout-param-value', text: String(totals.reps) }); } - // Show total recorded time when completed + // Show total recorded time when completed // TODO: change this to show total duration if provided. if (isCompleted && totals.totalRecordedTime > 0) { const timeEl = totalsEl.createSpan({ cls: 'workout-param' }); @@ -206,8 +323,8 @@ export function renderExercise( } } - // Params inline (between name and timer) - chip/pill style - // For multi-set exercises, only totals are shown, not exercise params + // Parameters inline (chip/pill style) - between name and timer + // For multi-set exercises, only totals shown (not exercise params) if (exercise.params.length > 0 && !hasMultipleSets) { const paramsEl = mainRow.createSpan({ cls: 'workout-exercise-params' }); @@ -293,7 +410,8 @@ export function renderExercise( } } - // Timer display (right side) - show on mainRow if no sets or single set, otherwise on active set + // Timer display (right side of mainRow) + // Shown on mainRow if no sets or single set, otherwise on active set let timerEl: HTMLElement | null = null; if (exercise.sets.length === 0 || !hasMultipleSets) { @@ -360,6 +478,10 @@ export function renderExercise( 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, @@ -374,6 +496,29 @@ function renderSet( 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, @@ -395,11 +540,11 @@ function renderSetWithTimerElement( const iconEl = setRow.createSpan({ cls: 'workout-set-icon' }); iconEl.textContent = STATE_ICONS[set.state]; - // Set label + // Set label ("Set 1", "Set 2", etc.) const labelEl = setRow.createSpan({ cls: 'workout-set-label' }); labelEl.textContent = `Set ${setIndex + 1}`; - // Set params as inline chips + // Set parameters as inline chips if (hasDisplayableSetParams(set)) { const paramsEl = setRow.createSpan({ cls: 'workout-set-params' }); @@ -442,7 +587,7 @@ function renderSetWithTimerElement( setInputs.set(setIndex, setParamInputs); } - // Show recorded duration for completed sets (similar to exercise totals) + // Show recorded duration for completed sets const recordedDuration = getSetRecordedDuration(set); if (recordedDuration && workoutState === 'completed') { const setDurationEl = setRow.createSpan({ cls: 'workout-set-duration' }); @@ -462,27 +607,27 @@ function renderSetWithTimerElement( if (isActive && timerState) { timerEl = setRow.createSpan({ cls: 'workout-set-timer' }); - // If in rest mode, show rest timer + // 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; - // Calculate rest progress and apply color phase class + // Apply color phase classes based on rest progress if (restDuration > 0) { const restProgress = remaining / restDuration; if (restProgress > 0.66) { - // Green phase: 66-100% remaining + // 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 phase: 33-66% remaining + // Yellow: 33-66% remaining setEl.removeClass('rest-phase-green'); setEl.removeClass('rest-phase-red'); setEl.addClass('rest-phase-yellow'); } else { - // Red phase: 0-33% remaining + // Red: 0-33% remaining setEl.removeClass('rest-phase-green'); setEl.removeClass('rest-phase-yellow'); setEl.addClass('rest-phase-red'); @@ -493,12 +638,13 @@ function renderSetWithTimerElement( timerEl.textContent = formatDuration(remaining); timerEl.createSpan({ cls: 'timer-indicator rest', text: ' ⏸' }); } else { + // Rest time exceeded (overtime) timerEl.textContent = formatDuration(Math.abs(remaining)); timerEl.addClass('rest-overtime'); timerEl.createSpan({ cls: 'timer-indicator', text: ' ⏸' }); } } else { - // Show exercise timer + // Not in rest: show exercise timer (count-up or countdown) updateExerciseTimer(timerEl, timerState, undefined); } } @@ -506,6 +652,30 @@ function renderSetWithTimerElement( return timerEl; } +/** + * 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, exerciseIndex: number, @@ -518,7 +688,7 @@ function renderSetControls( ): 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') { @@ -530,22 +700,22 @@ function renderSetControls( } }); - // Skip button + // Skip button (marks exercise as skipped) const skipBtn = controlsEl.createEl('button', { cls: 'workout-btn', text: 'Skip' }); skipBtn.addEventListener('click', () => { callbacks.onExerciseSkip(exerciseIndex); }); - // Finish group container + // Group for finish buttons const finishGroup = controlsEl.createDiv({ cls: 'workout-btn-group' }); - // Add Set button (for additional sets) + // Add Set button (to add more sets to exercise) const addSetBtn = finishGroup.createEl('button', { cls: 'workout-btn', text: '+ Set' }); addSetBtn.addEventListener('click', () => { 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', () => { @@ -553,19 +723,22 @@ function renderSetControls( }); } - // Determine button text based on rest state + // Determine next button text based on position and state let nextBtnText: string; if (timerState?.isRestActive) { - // During rest, show "Skip Rest" or "Start Next" + // During rest period, offer to start next nextBtnText = 'Start 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 Set'; } } @@ -573,7 +746,7 @@ function renderSetControls( const nextBtn = finishGroup.createEl('button', { cls: 'workout-btn', text: nextBtnText }); nextBtn.addEventListener('click', () => { if (timerState?.isRestActive) { - // Currently in rest period, end it and advance to next set + // Currently in rest, end it and advance callbacks.onRestEnd(exerciseIndex); } else { // Finishing a set, may start rest or advance @@ -582,6 +755,21 @@ function renderSetControls( }); } +/** + * 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, @@ -590,19 +778,20 @@ export function updateExerciseTimer( timerEl.empty(); if (targetDuration !== undefined) { - // Countdown mode + // 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: ' ▼' }); } 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: ' ⚠' }); } } else { - // Count up mode + // Count-up mode: show elapsed time from start (no target limit) timerEl.textContent = formatDuration(timerState.exerciseElapsed); timerEl.createSpan({ cls: 'timer-indicator count-up', text: ' ▲' }); } diff --git a/src/renderer/header.ts b/src/renderer/header.ts index 15a3ee1..e16b849 100644 --- a/src/renderer/header.ts +++ b/src/renderer/header.ts @@ -1,50 +1,103 @@ +/** + * 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 { 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: ' ✓' }); } 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: ' ▲' }); } 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)}`; + + // Add count-up indicator to show timer actively running timerEl.createSpan({ cls: 'workout-timer-indicator count-up', text: ' ▲' }); } diff --git a/src/renderer/index.ts b/src/renderer/index.ts index 65a02a6..b972963 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,31 +84,32 @@ 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 is now handled in the callbacks + // 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, @@ -77,44 +125,46 @@ export function renderWorkout(ctx: RendererContext): void { 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; 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); - // 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) { return; } - // Update active exercise timer or set timer + // Update the active exercise's timer display const activeElements = exerciseElements[currentActiveIndex]; const activeExercise = parsed.exercises[currentActiveIndex]; if (activeExercise) { - // Update set timer if sets exist and active set timer element exists + // Update set timer if exercise has sets if (activeElements?.setTimerEl) { updateExerciseTimer( activeElements.setTimerEl, @@ -130,7 +180,8 @@ export function renderWorkout(ctx: RendererContext): void { ); } - // Check for auto-advance on rest completion + // Auto-advance to next exercise when rest completes + // Conditions: (1) on last set, (2) not last exercise, (3) rest finished const isLastSet = Array.isArray(activeExercise.sets) && activeElements?.setTimerEl && (timerManager.getActiveSetIndex(workoutId) === activeExercise.sets.length - 1); const isLastExercise = currentActiveIndex === parsed.exercises.length - 1; @@ -139,7 +190,7 @@ export function renderWorkout(ctx: RendererContext): void { typeof state.restRemaining === 'number' && state.restRemaining <= 0 && !hasAutoAdvanced ) { hasAutoAdvanced = true; - // Rest period completed, advance to next set + // Trigger advance to next exercise callbacks.onRestEnd(currentActiveIndex); } } @@ -147,6 +198,7 @@ export function renderWorkout(ctx: RendererContext): void { } } +// 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.ts b/src/serializer.ts index 0deef5a..e9c226d 100644 --- a/src/serializer.ts +++ b/src/serializer.ts @@ -1,15 +1,50 @@ +/** + * 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, 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 with their sets @@ -28,22 +63,36 @@ export function serializeWorkout(parsed: ParsedWorkout): string { } /** - * Enrich sets with their own totals (~time, ~rest), then compute exercise totals. - * For incomplete sets: strip any existing totals - * For completed sets: add ~time (duration) and ~rest (rest) params - * For incomplete exercises: strip exercise totals - * For completed exercises: sum set-level totals to create exercise totals + * 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 + // 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 + // If exercise is incomplete, don't add totals (nothing finalized yet) if (exercise.state !== 'completed') { return { ...exercise, @@ -56,7 +105,7 @@ function enrichExerciseWithSetTotals(exercise: Exercise): Exercise { const totalRest = sumSetTotals(enrichedSets, '~rest'); const totalTime = sumSetTotals(enrichedSets, '~time'); - // Add exercise-level totals + // Add exercise-level totals (if any sets recorded values) if (totalRest) { paramsWithoutTotals.push({ key: '~rest', @@ -83,13 +132,30 @@ function enrichExerciseWithSetTotals(exercise: Exercise): Exercise { } /** - * Enrich an individual set with its own totals (~time, ~rest). - * ~time comes from the set's recordedDuration (actual elapsed time during set) - * ~rest comes from the set's recordedRest (actual elapsed time during rest) - * For incomplete sets: strip any existing totals + * 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 + // Remove old set totals (will recalculate if needed) const paramsWithoutTotals = set.params.filter( p => p.key.toLowerCase() !== '~time' && p.key.toLowerCase() !== '~rest' ); @@ -128,8 +194,19 @@ function enrichSetWithTotals(set: ExerciseSet): ExerciseSet { } /** - * Sum a specific total param (~time or ~rest) across all sets and return formatted string. - * Returns empty string if no totals found. + * 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; @@ -144,7 +221,20 @@ function sumSetTotals(sets: ExerciseSet[], paramKey: string): string { return totalSeconds > 0 ? formatDurationHuman(totalSeconds) : ''; } -// Update a specific param value in a workout (exercise-level params) +/** + * 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, @@ -163,7 +253,21 @@ export function updateParamValue( return newParsed; } -// Update a specific param value in a set +/** + * 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, @@ -186,7 +290,20 @@ export function updateSetParamValue( return newParsed; } -// Update exercise state +/** + * 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, @@ -200,7 +317,21 @@ export function updateExerciseState( return newParsed; } -// Update set state +/** + * 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, @@ -218,7 +349,18 @@ export function updateSetState( return newParsed; } -// Lock all editable fields (remove brackets) +/** + * 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); @@ -236,7 +378,21 @@ export function lockAllFields(parsed: ParsedWorkout): ParsedWorkout { 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]; @@ -268,7 +424,20 @@ export function addRest(parsed: ParsedWorkout, exerciseIndex: number, restDurati return newParsed; } -// Add a new set to 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]; @@ -297,7 +466,21 @@ export function addSet(parsed: ParsedWorkout, exerciseIndex: number): ParsedWork 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, @@ -326,7 +509,22 @@ export function setRecordedDuration( return newParsed; } -// Set Duration param value for a specific set +/** + * 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, @@ -369,7 +567,22 @@ export function setSetRecordedRest( return newParsed; } -// Update Rest param value for a specific set +/** + * 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, @@ -401,7 +614,23 @@ export function updateSetRestDuration( return newParsed; } -// Create a sample workout with comprehensive exercise examples +/** + * 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', @@ -525,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[] = []; diff --git a/src/timer/manager.ts b/src/timer/manager.ts index a703fee..16da6c0 100644 --- a/src/timer/manager.ts +++ b/src/timer/manager.ts @@ -1,14 +1,57 @@ +/** + * 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 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(); + /** + * Create a TimerManager instance. + * Listens for tab visibility changes to resume timers when tab becomes active. + */ constructor() { - // Track tab visibility changes + // 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 @@ -17,16 +60,37 @@ export class TimerManager { }); } + /** + * 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; } + /** + * 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) + */ startWorkoutTimer(workoutId: string, activeExerciseIndex: number = 0): void { const now = Date.now(); 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; @@ -36,18 +100,19 @@ export class TimerManager { existing.restPausedTime = 0; existing.restDuration = 0; } else { + // Create new timer instance this.timers.set(workoutId, { workoutId, - workoutStartTime: now, - exerciseStartTime: now, - exercisePausedTime: 0, + workoutStartTime: now, // 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, - restDuration: 0, + restPausedTime: 0, // Accumulated time for paused rest + restDuration: 0, // Total rest seconds to count down from callbacks: new Set() }); } @@ -55,36 +120,69 @@ export class TimerManager { 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; + 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 next set within the same exercise + /** + * 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; } - // Start rest period after completing a set + /** + * 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; @@ -93,29 +191,49 @@ export class TimerManager { timer.restStartTime = Date.now(); timer.restPausedTime = 0; timer.isPaused = false; - timer.restDuration = restDurationSeconds; + timer.restDuration = restDurationSeconds; // Countdown target } - // Exit rest and advance to next set + /** + * 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(); if (timer.isRestActive) { timer.restPausedTime += now - timer.restStartTime; @@ -124,11 +242,20 @@ export class TimerManager { } } + /** + * 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; + // Reset start time to now (continue from pause point with accumulated time) if (timer.isRestActive) { timer.restStartTime = Date.now(); } else { @@ -136,10 +263,20 @@ export class TimerManager { } } + /** + * 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); + // Cancel animation frame if no more active timers if (this.timers.size === 0 && this.frameId !== null) { cancelAnimationFrame(this.frameId); this.frameId = null; @@ -147,6 +284,18 @@ export class TimerManager { } } + /** + * 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) { @@ -163,10 +312,11 @@ export class TimerManager { this.lastCalledState.set(workoutId, state); } + // Return unsubscribe function return () => { timer.callbacks.delete(callback); - // Clean up if no more subscribers + // Auto-cleanup: delete timer if no more subscribers if (timer.callbacks.size === 0) { this.timers.delete(workoutId); this.lastCalledState.delete(workoutId); @@ -181,16 +331,27 @@ export class TimerManager { }; } + /** + * 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); @@ -199,7 +360,7 @@ export class TimerManager { exerciseElapsed = Math.floor((timer.exercisePausedTime + currentExerciseTime) / 1000); } - // Rest elapsed (respects pause) + // Rest elapsed and remaining: only if rest is active let restElapsed: number | undefined; let restRemaining: number | undefined; @@ -223,16 +384,36 @@ export class TimerManager { }; } + /** + * 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; @@ -247,11 +428,24 @@ export class TimerManager { } } + /** + * 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); } - // Notify all subscribers of current state immediately (used when state changes urgently need UI update) + /** + * 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; @@ -269,13 +463,27 @@ export class TimerManager { } } + /** + * 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; } + /** + * 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; + if (this.frameId !== null) return; // Already scheduled const scheduleNextFrame = () => { this.frameId = requestAnimationFrame(() => { @@ -284,12 +492,13 @@ export class TimerManager { const now = Date.now(); const currentSecond = Math.floor(now / 1000); - // Only process on actual second change + // 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(); } @@ -299,6 +508,15 @@ export class TimerManager { 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 @@ -318,6 +536,18 @@ export class TimerManager { } } + /** + * 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; @@ -329,7 +559,16 @@ export class TimerManager { ); } - // Called when we need to check for auto-advance (countdown completed) + /** + * 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; @@ -341,7 +580,13 @@ 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.frameId !== null) { cancelAnimationFrame(this.frameId); From 8cafc2939a897ed259ae6617238d1477e2c0ec2c Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Sat, 11 Apr 2026 10:40:24 -0500 Subject: [PATCH 14/42] fixed skipping rest on last set of exercise --- src/main.ts | 111 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 99 insertions(+), 12 deletions(-) diff --git a/src/main.ts b/src/main.ts index 5de9438..247493f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -249,19 +249,32 @@ export default class WorkoutLogPlugin extends Plugin { await this.updateFileWithParsed(ctx, sectionInfo, currentParsed); } } else { - // Last set in exercise - mark exercise as completed and look for next - currentParsed = updateExerciseState(currentParsed, exerciseIndex, 'completed'); - - const nextPending = this.findNextPendingExercise(exerciseIndex, currentParsed.exercises); + // Last set in exercise - check if there's a rest period even after the last set + const lastSet = exercise.sets[setIndex]; + if (!lastSet) return currentParsed; + const restParam = lastSet.params.find(p => p.key.toLowerCase() === 'rest'); - if (nextPending >= 0) { - // Found next exercise - activate it - currentParsed = updateExerciseState(currentParsed, nextPending, 'inProgress'); - this.timerManager.advanceExercise(workoutId, nextPending); + if (restParam) { + // Has rest period after last set - save state first, then start rest + // When rest ends, handleRestEnd will detect this is the last set and advance to next exercise await this.updateFileWithParsed(ctx, sectionInfo, currentParsed); + const restDurationSeconds = parseDurationToSeconds(restParam.value); + await handleRestStart(exerciseIndex, restDurationSeconds); } else { - // No more exercises - complete the entire workout - await this.completeWorkout(currentParsed, ctx, sectionInfo, workoutId); + // No rest period - mark exercise as completed and look for next + 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); + } } } @@ -412,37 +425,72 @@ export default class WorkoutLogPlugin extends Plugin { parsed: ParsedWorkout, workoutId: string ): WorkoutCallbacks { + // 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; + // Serialize to markdown and write to file (also saves to properties if enabled) await this.updateFileWithParsed(ctx, sectionInfo, newParsed); }; + // 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 => { 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 => { 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 => { + // 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); + // 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, activeSetIndex, @@ -455,28 +503,41 @@ export default class WorkoutLogPlugin extends Plugin { } }, + // 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); } ); }, + // 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); }, + // 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 => { // User wants to add a set to current exercise while working out // This records the current set duration before creating a new one @@ -506,6 +567,8 @@ export default class WorkoutLogPlugin extends Plugin { 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 => { // User wants to insert a rest period (creates a new rest exercise after current one) const exercise = currentParsed.exercises[exerciseIndex]; @@ -515,7 +578,7 @@ export default class WorkoutLogPlugin extends Plugin { const activeSetIndex = this.timerManager.getActiveSetIndex(workoutId); const timerState = this.timerManager.getTimerState(workoutId); - // Record how long the current set took + // Record how long the current set took before rest if (timerState) { currentParsed = setSetRecordedDuration( currentParsed, @@ -525,9 +588,10 @@ export default class WorkoutLogPlugin extends Plugin { ); } - // Mark current set done, insert rest exercise at next position + // Mark current set done, insert new Rest exercise after current one currentParsed = updateSetState(currentParsed, exerciseIndex, activeSetIndex, 'completed'); currentParsed = addRest(currentParsed, exerciseIndex, restDuration); + // Activate the rest exercise (this is at index exerciseIndex+1) currentParsed = updateExerciseState(currentParsed, exerciseIndex + 1, 'inProgress'); // Update timer to track new rest exercise @@ -535,11 +599,18 @@ export default class WorkoutLogPlugin extends Plugin { 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 => { // 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 => { // User edited an exercise param (e.g., weight, reps) // Mark as pending change (debounced - won't save until blur/flush) @@ -550,6 +621,8 @@ export default class WorkoutLogPlugin extends Plugin { 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) @@ -561,22 +634,36 @@ export default class WorkoutLogPlugin extends Plugin { 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, From 0ce57d4042bf9259e25db14de36dfbb9b03ec698 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Sat, 11 Apr 2026 10:45:58 -0500 Subject: [PATCH 15/42] refactored handleSetFinish for better flow and maintainability --- src/main.ts | 71 +++++++++++++++++++++-------------------------------- 1 file changed, 28 insertions(+), 43 deletions(-) diff --git a/src/main.ts b/src/main.ts index 247493f..0bc8e0e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -229,52 +229,37 @@ export default class WorkoutLogPlugin extends Plugin { // Step 2: Mark current set as completed currentParsed = updateSetState(currentParsed, exerciseIndex, setIndex, 'completed'); - // Step 3: Branch based on whether there are more sets in this exercise - if (setIndex < exercise.sets.length - 1) { - // More sets exist - check if current set has a rest period attached - const currentSet = exercise.sets[setIndex]; - if (!currentSet) return currentParsed; - const restParam = currentSet.params.find(p => p.key.toLowerCase() === 'rest'); - - if (restParam) { - // Has rest period - save state first, then start rest timer - await this.updateFileWithParsed(ctx, sectionInfo, currentParsed); - const restDurationSeconds = parseDurationToSeconds(restParam.value); - await handleRestStart(exerciseIndex, restDurationSeconds); - } else { - // No rest - 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); - } + // 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 { - // Last set in exercise - check if there's a rest period even after the last set - const lastSet = exercise.sets[setIndex]; - if (!lastSet) return currentParsed; - const restParam = lastSet.params.find(p => p.key.toLowerCase() === 'rest'); - - if (restParam) { - // Has rest period after last set - save state first, then start rest - // When rest ends, handleRestEnd will detect this is the last set and advance to next exercise - await this.updateFileWithParsed(ctx, sectionInfo, currentParsed); - const restDurationSeconds = parseDurationToSeconds(restParam.value); - await handleRestStart(exerciseIndex, restDurationSeconds); - } else { - // No rest period - mark exercise as completed and look for next - currentParsed = updateExerciseState(currentParsed, exerciseIndex, 'completed'); + // 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); + 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); - } + 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); } } From 8180236e6a872f6f6f82b1bfaa83a8100f2a8c90 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Sat, 11 Apr 2026 11:22:30 -0500 Subject: [PATCH 16/42] updated unit tests --- src/renderer/exercise.test.ts | 1828 +++++++++++++++++++++++++++++++++ src/renderer/index.test.ts | 116 +-- 2 files changed, 1881 insertions(+), 63 deletions(-) diff --git a/src/renderer/exercise.test.ts b/src/renderer/exercise.test.ts index 012f802..a63d748 100644 --- a/src/renderer/exercise.test.ts +++ b/src/renderer/exercise.test.ts @@ -1878,4 +1878,1832 @@ describe('updateExerciseTimer', () => { 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, + { elapsed: 10, isRestActive: false }, + mockCallbacks, + 'started', + 1, // totalExercises + restDuration // restDuration parameter + ); + 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, + { elapsed: 30, isRestActive: false }, + mockCallbacks, + 'started', + 2, // totalExercises (not last) + undefined + ); + 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, + { elapsed: 30, isRestActive: false }, + mockCallbacks, + 'started', + 2, // totalExercises (there's another after this) + undefined + ); + 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, + { elapsed: 30, isRestActive: false }, + mockCallbacks, + 'started', + 3, // totalExercises (3 total, on exercise 2 which is last) + undefined + ); + 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, + { elapsed: 30, isRestActive: true, restRemaining: 20 }, + mockCallbacks, + 'started', + 1, + undefined + ); + 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, + { elapsed: 30, isRestActive: false }, + mockCallbacks, + 'started' + ); + // 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, + { elapsed: 30, isRestActive: false }, + mockCallbacks, + 'started' + ); + 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, + { elapsed: 30, isRestActive: false }, + mockCallbacks, + 'started' + ); + 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, + { elapsed: 5, isRestActive: false }, + mockCallbacks, + 'started' + ); + 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, + { elapsed: 10, isRestActive: false }, + mockCallbacks, + 'started' + ); + 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' + } + ], + recordedDuration: '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' + } + ], + recordedDuration: '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 = { + elapsed: 30, + isRestActive: true, + 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 = { + elapsed: 100, + isRestActive: true, + 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 = { + elapsed: 250, + isRestActive: true, + 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 = { + elapsed: 75, + isRestActive: true, + 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 = { + elapsed: 60, + isRestActive: true, + 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 = { + elapsed: 30, + isRestActive: true, + 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, + recordedDuration: '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, + recordedDuration: '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, + { elapsed: 5, isRestActive: false }, + 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, + { elapsed: 40, isRestActive: true, restRemaining: 220 }, // 220/300 = 73% + 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, + { elapsed: 10, isRestActive: false }, + mockCallbacks, + 'started' + ); + 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, + { elapsed: 25, isRestActive: false }, + mockCallbacks, + 'started', + undefined, + 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 + { elapsed: 30, isRestActive: false }, + mockCallbacks, + 'started' + ); + 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, + { elapsed: 60, isRestActive: true, restRemaining: 65 }, + mockCallbacks, + 'started' + ); + 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, + { elapsed: 20, isRestActive: false }, + mockCallbacks, + 'started', + undefined, + 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, + { elapsed: 5, isRestActive: false }, + 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, + { elapsed: 5, isRestActive: false }, + 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, + { elapsed: 5, isRestActive: false }, + 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, + { elapsed: 5, isRestActive: false }, + mockCallbacks, + 'started', + 60 // restDuration + ); + + 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, + { elapsed: 5, isRestActive: false }, + mockCallbacks, + 'started', + undefined, + 2 // totalExercises + ); + + const buttons = result.container.querySelectorAll('button'); + const nextBtn = buttons.find(b => b.textContent === 'Next Set'); + 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, + { elapsed: 5, isRestActive: false }, + mockCallbacks, + 'started', + undefined, + 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, + { elapsed: 5, isRestActive: false }, + mockCallbacks, + 'started', + undefined, + 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, + { elapsed: 120, isRestActive: true, restRemaining: 30 }, + mockCallbacks, + 'started', + undefined, + 2 // totalExercises + ); + + const buttons = result.container.querySelectorAll('button'); + const startNextBtn = buttons.find(b => b.textContent === 'Start 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, + { elapsed: 25, 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, + { elapsed: 15, 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, + { elapsed: 15, isRestActive: false }, + 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, + recordedDuration: '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 = { + elapsed: 45, + workoutElapsed: 100, + exerciseElapsed: 45, + isRestActive: false, + 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 = { + elapsed: 30, + workoutElapsed: 60, + exerciseElapsed: 30, + isRestActive: false + }; + 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/index.test.ts b/src/renderer/index.test.ts index 859a1ba..4fe9573 100644 --- a/src/renderer/index.test.ts +++ b/src/renderer/index.test.ts @@ -169,7 +169,7 @@ class MockElement { } describe('renderWorkout', () => { - let el: MockElement; + let containerElement: MockElement; let timerManager: any; let mockCallbacks: WorkoutCallbacks; const mockWorkout: ParsedWorkout = { @@ -189,40 +189,30 @@ describe('renderWorkout', () => { }; beforeEach(() => { - el = new MockElement('div'); + containerElement = new MockElement('div'); timerManager = new (TimerManager as any)(); 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() + 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', () => { - el.createDiv({ text: 'Old Content' }); - expect(el.children.length).toBe(1); + containerElement.createDiv({ text: 'Old Content' }); + expect(containerElement.children.length).toBe(1); renderWorkout({ - el, + containerElement, parsed: mockWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', @@ -230,32 +220,32 @@ describe('renderWorkout', () => { }); // After render, old content should be gone - expect(el.children.length).toBeGreaterThanOrEqual(1); + expect(containerElement.children.length).toBeGreaterThanOrEqual(1); }); it('should create workout container', () => { renderWorkout({ - el, + containerElement, parsed: mockWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', timerManager }); - const container = el.querySelector('.workout-container'); + const container = containerElement.querySelector('.workout-container'); expect(container).toBeTruthy(); }); it('should add state class to container', () => { renderWorkout({ - el, + containerElement, parsed: mockWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', timerManager }); - const container = el.querySelector('.workout-container'); + const container = containerElement.querySelector('.workout-container'); expect(container?.className).toContain('state-planned'); }); @@ -266,14 +256,14 @@ describe('renderWorkout', () => { }; renderWorkout({ - el, + containerElement, parsed: completedWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', timerManager }); - const container = el.querySelector('.workout-container'); + const container = containerElement.querySelector('.workout-container'); expect(container?.className).toContain('state-completed'); }); }); @@ -282,7 +272,7 @@ describe('renderWorkout', () => { it('should render header', () => { const { renderHeader } = require('./header'); renderWorkout({ - el, + containerElement, parsed: mockWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', @@ -295,7 +285,7 @@ describe('renderWorkout', () => { it('should pass workout metadata to header', () => { const { renderHeader } = require('./header'); renderWorkout({ - el, + containerElement, parsed: mockWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', @@ -320,7 +310,7 @@ describe('renderWorkout', () => { }; renderWorkout({ - el, + containerElement, parsed: emptyWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', @@ -336,7 +326,7 @@ describe('renderWorkout', () => { it('should not render empty state for non-empty workout', () => { const { renderEmptyState } = require('./emptyState'); renderWorkout({ - el, + containerElement, parsed: mockWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', @@ -354,7 +344,7 @@ describe('renderWorkout', () => { }; renderWorkout({ - el, + containerElement, parsed: emptyCompletedWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', @@ -368,21 +358,21 @@ describe('renderWorkout', () => { describe('Exercise rendering', () => { it('should render exercises container', () => { renderWorkout({ - el, + containerElement, parsed: mockWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', timerManager }); - const container = el.querySelector('.workout-exercises'); + const container = containerElement.querySelector('.workout-exercises'); expect(container).toBeTruthy(); }); it('should render each exercise', () => { const { renderExercise } = require('./exercise'); renderWorkout({ - el, + containerElement, parsed: mockWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', @@ -415,7 +405,7 @@ describe('renderWorkout', () => { }; renderWorkout({ - el, + containerElement, parsed: multiExerciseWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', @@ -427,14 +417,14 @@ describe('renderWorkout', () => { it('should set max-name-chars CSS variable', () => { renderWorkout({ - el, + containerElement, parsed: mockWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', timerManager }); - const exercisesContainer = el.querySelector('.workout-exercises'); + const exercisesContainer = containerElement.querySelector('.workout-exercises'); expect((exercisesContainer?.style.setProperty as jest.Mock).mock.calls.some( call => call[0] === '--max-name-chars' )).toBe(true); @@ -445,7 +435,7 @@ describe('renderWorkout', () => { it('should render workout controls', () => { const { renderWorkoutControls } = require('./controls'); renderWorkout({ - el, + containerElement, parsed: mockWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', @@ -458,7 +448,7 @@ describe('renderWorkout', () => { it('should pass state to controls', () => { const { renderWorkoutControls } = require('./controls'); renderWorkout({ - el, + containerElement, parsed: mockWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', @@ -477,27 +467,27 @@ describe('renderWorkout', () => { describe('Focus event handling', () => { it('should add focusout listener to container', () => { renderWorkout({ - el, + containerElement, parsed: mockWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', timerManager }); - const container = el.querySelector('.workout-container') as MockElement; + const container = containerElement.querySelector('.workout-container') as MockElement; expect(container?.listeners['focusout']).toBeDefined(); }); it('should call onFlushChanges when focus leaves container', () => { renderWorkout({ - el, + containerElement, parsed: mockWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', timerManager }); - const container = el.querySelector('.workout-container') as MockElement; + const container = containerElement.querySelector('.workout-container') as MockElement; const focusoutEvent = new Event('focusout') as any; focusoutEvent.relatedTarget = null; @@ -512,14 +502,14 @@ describe('renderWorkout', () => { it('should not call onFlushChanges when focus moves within container', () => { renderWorkout({ - el, + containerElement, parsed: mockWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', timerManager }); - const container = el.querySelector('.workout-container') as MockElement; + const container = containerElement.querySelector('.workout-container') as MockElement; const targetEl = new MockElement('input'); container.children.push(targetEl); @@ -541,14 +531,14 @@ describe('renderWorkout', () => { describe('Timer subscription', () => { it('should handle planned state', () => { renderWorkout({ - el, + containerElement, parsed: mockWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', timerManager }); - const container = el.querySelector('.workout-container'); + const container = containerElement.querySelector('.workout-container'); expect(container?.className).toContain('state-planned'); }); @@ -559,14 +549,14 @@ describe('renderWorkout', () => { }; renderWorkout({ - el, + containerElement, parsed: startedWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', timerManager }); - const container = el.querySelector('.workout-container'); + const container = containerElement.querySelector('.workout-container'); expect(container?.className).toContain('state-started'); }); @@ -577,14 +567,14 @@ describe('renderWorkout', () => { }; renderWorkout({ - el, + containerElement, parsed: completedWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', timerManager }); - const container = el.querySelector('.workout-container'); + const container = containerElement.querySelector('.workout-container'); expect(container?.className).toContain('state-completed'); }); }); @@ -621,7 +611,7 @@ describe('renderWorkout', () => { }; renderWorkout({ - el, + containerElement, parsed: parsedWorkout as any, callbacks: mockCallbacks, workoutId: 'test-workout:0', @@ -662,7 +652,7 @@ describe('renderWorkout', () => { }; renderWorkout({ - el, + containerElement, parsed: parsedWorkout as any, callbacks: mockCallbacks, workoutId: 'test-workout:0', @@ -717,7 +707,7 @@ describe('renderWorkout', () => { }; renderWorkout({ - el, + containerElement, parsed: parsedWorkout as any, callbacks: mockCallbacks, workoutId: 'test-workout:0', @@ -766,7 +756,7 @@ describe('renderWorkout', () => { }; renderWorkout({ - el, + containerElement, parsed: parsedWorkout as any, callbacks: mockCallbacks, workoutId: 'test-workout:0', @@ -834,7 +824,7 @@ describe('renderWorkout', () => { }; renderWorkout({ - el, + containerElement, parsed: parsedWorkout as any, callbacks: mockCallbacks, workoutId: 'test-workout:0', @@ -871,26 +861,26 @@ describe('renderWorkout', () => { }; renderWorkout({ - el, + containerElement, parsed: emptyWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', timerManager }); - expect(el.children.length).toBeGreaterThan(0); + expect(containerElement.children.length).toBeGreaterThan(0); }); it('should handle single exercise', () => { renderWorkout({ - el, + containerElement, parsed: mockWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', timerManager }); - expect(el.children.length).toBeGreaterThan(0); + expect(containerElement.children.length).toBeGreaterThan(0); }); it('should handle very long workout title', () => { @@ -903,14 +893,14 @@ describe('renderWorkout', () => { }; renderWorkout({ - el, + containerElement, parsed: longTitleWorkout, callbacks: mockCallbacks, workoutId: 'test-workout', timerManager }); - expect(el.children.length).toBeGreaterThan(0); + expect(containerElement.children.length).toBeGreaterThan(0); }); }); }); From d13554e61daef3436d7748bb53b05c5c727ee0bd Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Sat, 11 Apr 2026 13:24:36 -0500 Subject: [PATCH 17/42] moved function --- src/main.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/main.ts b/src/main.ts index 0bc8e0e..54329af 100644 --- a/src/main.ts +++ b/src/main.ts @@ -266,6 +266,21 @@ export default class WorkoutLogPlugin extends Plugin { 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. @@ -659,21 +674,6 @@ export default class WorkoutLogPlugin extends Plugin { }; } - /** - * 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); - } - /** * Formats a date into workout metadata format: YYYY-MM-DD HH:MM * Used for recording workout start time. From 92746cd0cd1840e9fdba67426d93fedc02c5a665 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Sat, 11 Apr 2026 23:17:47 -0500 Subject: [PATCH 18/42] fixed exercise header row render issues --- src/renderer/exercise.ts | 259 ++++++++++++++++++++------------------- 1 file changed, 135 insertions(+), 124 deletions(-) diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index 089ce68..f0664d7 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -35,6 +35,12 @@ const STATE_ICONS: Record = { 'skipped': '—' }; +const PARAM_PREFIX_ICONS: Record = { + 'duration': '⏱️', + 'reps': '×', + 'rest': '⏸️' +}; + /** * Generate a consistent color hue from exercise name using djb2 hash. * Ensures each exercise gets a unique, visually distinct color. @@ -87,7 +93,7 @@ export interface ExerciseElements { function hasDisplayableSetParams(set: ExerciseSet): boolean { return set.params.some(p => { const key = p.key.toLowerCase(); - return key === 'duration' || key === 'weight' || key === 'reps'; + return key === 'duration' || key === 'weight' || key === 'reps' || key === 'rest'; }); } @@ -102,7 +108,7 @@ function hasDisplayableSetParams(set: ExerciseSet): boolean { * Returns: Array of params in order [duration, weight, reps] */ function getDisplayableSetParams(set: ExerciseSet): ExerciseParam[] { - const paramOrder = ['duration', 'weight', 'reps']; + const paramOrder = ['duration', 'weight', 'reps', 'rest']; const paramsMap = new Map(); // Build map of params by key @@ -153,6 +159,83 @@ function getSetRestDuration(set: ExerciseSet): string | null { return restParam ? restParam.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 (PARAM_PREFIX_ICONS[keyLower]) { + paramEl.createSpan({ cls: 'workout-param-prefix', text: PARAM_PREFIX_ICONS[keyLower] }); + } + + if (param.editable && workoutState !== 'completed') { + 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. @@ -210,7 +293,7 @@ function computeExerciseTotals(exercise: Exercise, isCompleted: boolean): { // If exercise is completed, sum recorded durations from each set if (isCompleted) { for (const param of set.params) { - if (param.key.toLowerCase() === 'duration' && !param.editable) { + if (param.key.toLowerCase() === '~time' && !param.editable) { // Non-editable duration = recorded time (captured during set) const seconds = parseDurationToSeconds(param.value); totalRecordedTime += seconds; @@ -290,123 +373,75 @@ export function renderExercise( const isCompleted = exercise.state === 'completed'; const totals = computeExerciseTotals(exercise, isCompleted); - // Display totals on multi-set exercise main row - if (hasMultipleSets && (totals.reps !== null || totals.weight !== null || (isCompleted && totals.totalRecordedTime > 0) || totals.totalRest > 0)) { + // Display totals on multi-set exercise main row (only when completed) + if (hasMultipleSets && isCompleted) { const totalsEl = mainRow.createSpan({ cls: 'workout-exercise-params' }); // Show weight if (totals.weight !== null) { - const weightEl = totalsEl.createSpan({ cls: 'workout-param' }); - weightEl.createSpan({ cls: 'workout-param-value', text: String(totals.weight) }); - weightEl.createSpan({ cls: 'workout-param-unit', text: ' lbs' }); + renderTotalParam(totalsEl, totals.weight, ' lbs'); } // Show total reps if (totals.reps !== null) { - const repsEl = totalsEl.createSpan({ cls: 'workout-param' }); - repsEl.createSpan({ cls: 'workout-param-prefix', text: '×' }); - repsEl.createSpan({ cls: 'workout-param-value', text: String(totals.reps) }); - } - - // Show total recorded time when completed - // TODO: change this to show total duration if provided. - if (isCompleted && totals.totalRecordedTime > 0) { - const timeEl = totalsEl.createSpan({ cls: 'workout-param' }); - timeEl.createSpan({ cls: 'workout-param-value', text: formatDurationHuman(totals.totalRecordedTime) }); + renderTotalParam(totalsEl, totals.reps); } // Show total rest time if (totals.totalRest > 0) { - const restEl = totalsEl.createSpan({ cls: 'workout-param' }); - restEl.createSpan({ cls: 'workout-param-prefix', text: '⏸' }); - restEl.createSpan({ cls: 'workout-param-value', text: formatDurationHuman(totals.totalRest) }); + renderTotalParam(totalsEl, formatDurationHuman(totals.totalRest)); } - } - - // Parameters inline (chip/pill style) - between name and timer - // For multi-set exercises, only totals shown (not exercise params) - if (exercise.params.length > 0 && !hasMultipleSets) { - const paramsEl = mainRow.createSpan({ cls: 'workout-exercise-params' }); - - for (const param of exercise.params) { - // Skip Duration param (shown in timer) - if (param.key.toLowerCase() === 'duration') continue; - - const paramEl = paramsEl.createSpan({ cls: 'workout-param' }); - // × prefix for params without units (plain numbers) - if (!param.unit) { - paramEl.createSpan({ cls: 'workout-param-prefix', text: '×' }); - } - - 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 }); - } - - // Unit after value - if (param.unit) { - paramEl.createSpan({ cls: 'workout-param-unit', text: ` ${param.unit}` }); - } + // Show total recorded time when completed + if (totals.totalRecordedTime > 0) { + renderTotalParam(totalsEl, formatDurationHuman(totals.totalRecordedTime)); } } - // For single-set exercises, render set params inline on mainRow + // 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 paramEl = paramsEl.createSpan({ cls: 'workout-param' }); - - // × prefix for params without units - if (!param.unit) { - paramEl.createSpan({ cls: 'workout-param-prefix', text: '×' }); - } - - if (param.editable && workoutState !== 'completed') { - const input = paramEl.createEl('input', { - cls: 'workout-param-input', - type: 'text', - value: param.value - }); - input.addEventListener('input', () => { - callbacks.onSetParamChange(index, 0, param.key, input.value); - }); - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - input.blur(); - } - }); - setParamInputs.set(param.key, 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}` }); - } + 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); } } @@ -547,41 +582,17 @@ function renderSetWithTimerElement( // 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 paramEl = paramsEl.createSpan({ cls: 'workout-param' }); - - // × prefix for params without units - if (!param.unit) { - paramEl.createSpan({ cls: 'workout-param-prefix', text: '×' }); - } - - if (param.editable && workoutState !== 'completed') { - const input = paramEl.createEl('input', { - cls: 'workout-param-input', - type: 'text', - value: param.value - }); - input.addEventListener('input', () => { - callbacks.onSetParamChange(exerciseIndex, setIndex, param.key, input.value); - }); - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - input.blur(); - } - }); - setParamInputs.set(param.key, 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}` }); - } + 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); From b081215c00c6b0002e8025ec4cc9e905561d9f00 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Sat, 11 Apr 2026 23:36:26 -0500 Subject: [PATCH 19/42] removed extra left aligned rest value --- src/renderer/exercise.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index f0664d7..85d35dc 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -605,13 +605,8 @@ function renderSetWithTimerElement( setDurationEl.createSpan({ cls: 'workout-param-value', text: recordedDuration }); } - // Show Rest duration if set has one + // Get rest duration for timer logic const restDurationStr = getSetRestDuration(set); - if (restDurationStr) { - const restEl = setRow.createSpan({ cls: 'workout-set-rest-info' }); - restEl.createSpan({ cls: 'workout-param-prefix', text: '⏸' }); - restEl.createSpan({ cls: 'workout-param-value', text: restDurationStr }); - } // Timer display for active set (right side) let timerEl: HTMLElement | null = null; From c9e91418122aa06264bdd58681ab08c1708142a3 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Sun, 12 Apr 2026 13:09:01 -0500 Subject: [PATCH 20/42] fixed render exercise timerm, set timer needs work --- src/renderer/exercise.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index 85d35dc..0510a65 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -447,20 +447,32 @@ export function renderExercise( // 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; if (exercise.sets.length === 0 || !hasMultipleSets) { timerEl = mainRow.createSpan({ cls: 'workout-exercise-timer' }); + // 1. Exercise completed: show recorded duration with checkmark + // This is the final state after workout finishes if (exercise.state === 'completed' && exercise.recordedDuration) { timerEl.textContent = exercise.recordedDuration; timerEl.createSpan({ cls: 'timer-indicator recorded', text: ' ✓' }); - } else if (isActive && timerState) { + } + // 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); - } else if (exercise.targetDuration) { + } + // 3. Static target display (when not active) + // Only show countdown indicator if single excersie. + else if (!hasMultipleSets && exercise.targetDuration) { timerEl.textContent = formatDuration(exercise.targetDuration); timerEl.createSpan({ cls: 'timer-indicator count-down', text: ' ▼' }); - } else if (exercise.state === 'pending') { + } + // 4. Pending exercise with no target: show placeholder + // Indicates exercise not yet started and has no defined duration + else if (!hasMultipleSets && exercise.state === 'pending') { timerEl.textContent = '--'; } } From 20963406d06dd14d6d987cbd4e486a428e5015c3 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Sun, 12 Apr 2026 13:38:22 -0500 Subject: [PATCH 21/42] changed rest and duration to only render non-editable --- src/renderer/exercise.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index 0510a65..849367d 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -207,7 +207,12 @@ function renderParamElement( paramEl.createSpan({ cls: 'workout-param-prefix', text: PARAM_PREFIX_ICONS[keyLower] }); } - if (param.editable && workoutState !== 'completed') { + // 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', From fb8d68a6731b03a7eba721349a79a3edde62aac2 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Sun, 12 Apr 2026 14:39:39 -0500 Subject: [PATCH 22/42] changed style to light up items better --- styles.css | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/styles.css b/styles.css index 2149bb5..590d303 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 */ @@ -136,6 +136,14 @@ 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-aligh: center; +} + /* Set Timer - positioned on the right */ .workout-set-timer { font-family: var(--font-monospace); From 8e421552a30a8c3500e37511ba84253db832a26c Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Sun, 12 Apr 2026 14:58:46 -0500 Subject: [PATCH 23/42] fixed typo --- styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/styles.css b/styles.css index 590d303..cb779c1 100644 --- a/styles.css +++ b/styles.css @@ -141,7 +141,7 @@ color: var(--text-normal); white-space: nowrap; min-width: calc((var(--max-name-chars, 10)*0.80) * 1ch); - text-aligh: center; + text-align: center; } /* Set Timer - positioned on the right */ From 8516218038ad28bc6d4c3950d82e8a263220475b Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Sun, 12 Apr 2026 15:54:38 -0500 Subject: [PATCH 24/42] updated timer logic for set row --- src/renderer/exercise.ts | 64 +++++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index 849367d..9cacf3c 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -159,6 +159,20 @@ function getSetRestDuration(set: ExerciseSet): string | null { 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. @@ -615,20 +629,27 @@ function renderSetWithTimerElement( setInputs.set(setIndex, setParamInputs); } - // Show recorded duration for completed sets - const recordedDuration = getSetRecordedDuration(set); - if (recordedDuration && workoutState === 'completed') { - const setDurationEl = setRow.createSpan({ cls: 'workout-set-duration' }); - setDurationEl.createSpan({ cls: 'workout-param-value', text: recordedDuration }); - } - // Get rest duration for timer logic - const restDurationStr = getSetRestDuration(set); - // Timer display for active set (right side) + + + // 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; - if (isActive && timerState) { - timerEl = setRow.createSpan({ cls: 'workout-set-timer' }); + timerEl = setRow.createSpan({ cls: 'workout-set-timer' }); + + // Show recorded duration for completed sets + const recordedDuration = getSetRecordedDuration(set); + + if ( workoutState === 'completed' && recordedDuration) { + timerEl.textContent = recordedDuration; + timerEl.createSpan({ cls: 'timer-indicator recorded', text: ' ✓' }); + } + // Set currently active: show live timer (updates in real-time) + else if (isActive && timerState) { + // Get rest duration for timer logic + const restDurationStr = getSetRestDuration(set); // In rest mode: show rest timer with phase-based color coding if (timerState.isRestActive && timerState.restRemaining !== undefined) { @@ -671,6 +692,27 @@ function renderSetWithTimerElement( updateExerciseTimer(timerEl, timerState, undefined); } } + else if (set.state === 'pending') { + // Pending set with defined rest: show static rest duration as placeholder + const restDurationStr = getSetRestDuration(set); + if (restDurationStr) { + timerEl.textContent = restDurationStr; + timerEl.createSpan({ cls: 'timer-indicator', text: ' ⏸' }); + } else { + // No rest defined: show placeholder + timerEl.textContent = '--'; + } + } + else { + const setDurationStr = getSetDuration(set); + if (setDurationStr) { + timerEl.textContent = setDurationStr; + timerEl.createSpan({ cls: 'timer-indicator', text: ' ⏸' }); + } else { + // No duration defined: show placeholder + timerEl.textContent = '--'; + } + } return timerEl; } From 9dc2dcdea54daa2cc2cb866190fd6cd31cdbc0e0 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Sun, 12 Apr 2026 22:06:32 -0500 Subject: [PATCH 25/42] changes to render and parsing, updated unit tests --- jest.config.js | 4 +-- src/file/updater.test.ts | 4 +-- src/file/updater.ts | 2 +- src/parser/exercise.test.ts | 21 +++++++------- src/parser/exercise.ts | 52 +++++++++++++++++++---------------- src/parser/index.test.ts | 3 +- src/parser/index.ts | 10 +++++-- src/renderer/exercise.test.ts | 20 +++++++------- src/renderer/exercise.ts | 44 ++++++++++++----------------- src/serializer.test.ts | 12 ++++---- src/serializer.ts | 2 +- src/types.ts | 4 ++- 12 files changed, 91 insertions(+), 87 deletions(-) diff --git a/jest.config.js b/jest.config.js index 13c7f3a..987344d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -25,8 +25,8 @@ export default { 'src/parser/exercise.ts': { branches: 85, functions: 100, - lines: 98, - statements: 98 + lines: 95, + statements: 95 }, 'src/parser/metadata.ts': { branches: 90, diff --git a/src/file/updater.test.ts b/src/file/updater.test.ts index b2cc029..06b3a55 100644 --- a/src/file/updater.test.ts +++ b/src/file/updater.test.ts @@ -434,7 +434,7 @@ describe('FileUpdater', () => { { name: 'Bench Press', state: 'completed', - recordedDuration: '10m', + recordedTime: '10m', params: [], sets: [ { @@ -689,7 +689,7 @@ describe('FileUpdater', () => { { name: 'Exercise', state: 'completed', - recordedDuration: '10m', + recordedTime: '10m', params: [], sets: [ { diff --git a/src/file/updater.ts b/src/file/updater.ts index 95240a0..f544168 100644 --- a/src/file/updater.ts +++ b/src/file/updater.ts @@ -306,7 +306,7 @@ export class FileUpdater { const exercises = parsed.exercises.map(exercise => ({ name: exercise.name, state: exercise.state, - recordedDuration: exercise.recordedDuration, + 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); diff --git a/src/parser/exercise.test.ts b/src/parser/exercise.test.ts index 8cd0172..ee5f31c 100644 --- a/src/parser/exercise.test.ts +++ b/src/parser/exercise.test.ts @@ -80,15 +80,16 @@ describe('parseExercise', () => { const line = '- [x] Cardio | Duration: 125 s'; const exercise = parseExercise(line, 0); - // Duration values are combined with their units in parseParam - expect(exercise!.recordedDuration).toBe('125s'); + // 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); - expect(exercise!.recordedDuration).toBe('3m2s'); + // Duration is converted to seconds: 3m2s = 182 seconds + expect(exercise!.targetDuration).toBe(182); }); it('should parse exercise with empty name', () => { @@ -386,8 +387,10 @@ describe('Integration tests', () => { 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); - expect(exercise!.recordedDuration).toBe('3m2s'); + // Duration is also extracted into targetDuration (in seconds) + expect(exercise!.targetDuration).toBe(182); // 3m2s = 182 seconds }); it('should roundtrip duration formats through parsing', () => { @@ -450,16 +453,12 @@ describe('Integration tests', () => { 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!.params).toHaveLength(2); - expect(exercise!.params[0].key).toBe('~rest'); - expect(exercise!.params[0].value).toBe('5m'); - expect(exercise!.params[0].editable).toBe(false); - expect(exercise!.params[1].key).toBe('~time'); - expect(exercise!.params[1].value).toBe('30m'); - expect(exercise!.params[1].editable).toBe(false); + expect(exercise!.recordedRest).toBe('5m'); + expect(exercise!.recordedTime).toBe('30m'); }); }); diff --git a/src/parser/exercise.ts b/src/parser/exercise.ts index 60ad290..2a4f67e 100644 --- a/src/parser/exercise.ts +++ b/src/parser/exercise.ts @@ -76,23 +76,24 @@ export function parseExercise(line: string, lineIndex: number): Exercise | null const params: ExerciseParam[] = []; let targetDuration: number | undefined; - let recordedDuration: string | undefined; + let recordedTime: string | undefined; + let recordedRest: string | undefined; for (const paramStr of paramStrings) { const param = parseParam(paramStr); if (param) { - params.push(param); - - // Extract duration information into dedicated fields - // Separate handling for countdown targets vs recorded times 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() === '~time') { + recordedTime = param.value; + } + else if (param.key.toLowerCase() === '~rest') { + recordedRest = param.value; + } + else { + params.push(param); } } } @@ -103,7 +104,8 @@ export function parseExercise(line: string, lineIndex: number): Exercise | null params, sets: [], // Set by parent parseWorkout, not by this function targetDuration, // Seconds: used for countdown timers - recordedDuration, // String: actual time recorded after completion + recordedTime, // Seconds: actual time recorded after completion + recordedRest, // Seconds: actual rest time recorded after completion lineIndex }; } @@ -114,7 +116,7 @@ export function parseExercise(line: string, lineIndex: number): Exercise | null * Format: " - [STATE] | Key: [value] unit | Key: value" * * Special handling: - * - ~time and ~rest are extracted into recordedDuration/recordedRest fields + * - ~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 */ @@ -130,21 +132,24 @@ export function parseSet(line: string, lineIndex: number): ExerciseSet | null { const paramStrings = parts; const params: ExerciseParam[] = []; + let targetDuration: number | undefined; let recordedTime: string | undefined; let recordedRest: string | undefined; for (const paramStr of paramStrings) { const param = parseParam(paramStr); if (param) { - // System-managed totals: store separately, don't include in params list - // These are computed during serialization and should not be editable - if (param.key.toLowerCase() === '~time') { + if (param.key.toLowerCase() === 'duration') { + targetDuration = parseDurationToSeconds(param.value); + params.push(param); + } + else if (param.key.toLowerCase() === '~time') { recordedTime = param.value; - // Don't add to params - these are computed values - } else if (param.key.toLowerCase() === '~rest') { + } + else if (param.key.toLowerCase() === '~rest') { recordedRest = param.value; - // Don't add to params - these are computed values - } else { + } + else { params.push(param); } } @@ -154,8 +159,9 @@ export function parseSet(line: string, lineIndex: number): ExerciseSet | null { state, params, lineIndex, - recordedTime, // Actual elapsed time during set - recordedRest // Actual elapsed rest period after set + targetDuration, // Seconds: used for countdown timers + recordedTime, // Actual elapsed time during set + recordedRest // Actual elapsed rest period after set }; } diff --git a/src/parser/index.test.ts b/src/parser/index.test.ts index eb8fe13..8709751 100644 --- a/src/parser/index.test.ts +++ b/src/parser/index.test.ts @@ -178,7 +178,8 @@ describe('parseWorkout', () => { const source = '---\n- [x] Cardio | Duration: 65s'; const result = parseWorkout(source); - expect(result.exercises[0].recordedDuration).toBe('65s'); + // 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', () => { diff --git a/src/parser/index.ts b/src/parser/index.ts index a9aba3e..112620a 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -80,7 +80,10 @@ export function parseWorkout(source: string): ParsedWorkout { currentExercise.sets.push({ state: currentExercise.state, params: currentExercise.params, - lineIndex: currentExercise.lineIndex + lineIndex: currentExercise.lineIndex, + targetDuration: currentExercise.targetDuration, + recordedTime: currentExercise.recordedTime, + recordedRest: currentExercise.recordedRest }); currentExercise.params = []; } @@ -102,7 +105,10 @@ export function parseWorkout(source: string): ParsedWorkout { currentExercise.sets.push({ state: currentExercise.state, params: currentExercise.params, - lineIndex: currentExercise.lineIndex + lineIndex: currentExercise.lineIndex, + targetDuration: currentExercise.targetDuration, + recordedTime: currentExercise.recordedTime, + recordedRest: currentExercise.recordedRest }); currentExercise.params = []; } diff --git a/src/renderer/exercise.test.ts b/src/renderer/exercise.test.ts index a63d748..031d316 100644 --- a/src/renderer/exercise.test.ts +++ b/src/renderer/exercise.test.ts @@ -635,7 +635,7 @@ describe('renderExercise & updateExerciseTimer', () => { } ], lineIndex: 0, - recordedDuration: '5m 30s' + recordedTime: '5m 30s' }; const result = renderExercise( container, @@ -847,7 +847,7 @@ describe('renderExercise & updateExerciseTimer', () => { { state: 'completed', params: [], recordedTime: '2m 45s' } ], lineIndex: 0, - recordedDuration: '5m 45s' + recordedTime: '5m 45s' }; const result = renderExercise( container, @@ -977,7 +977,7 @@ describe('renderExercise & updateExerciseTimer', () => { params: [], sets: [], lineIndex: 0, - recordedDuration: '10m 25s' + recordedTime: '10m 25s' }; const result = renderExercise( container, @@ -1388,7 +1388,7 @@ describe('renderExercise & updateExerciseTimer', () => { restDuration: '120s' } ], - recordedDuration: '2m 30s', + recordedTime: '2m 30s', lineIndex: 0 }; const result = renderExercise( @@ -1553,7 +1553,7 @@ describe('renderExercise & updateExerciseTimer', () => { const completedExercise: Exercise = { ...baseMockExercise, state: 'completed', - recordedDuration: '5m 30s', + recordedTime: '5m 30s', sets: [] }; const result = renderExercise( @@ -2189,7 +2189,7 @@ describe('updateExerciseTimer', () => { recordedTime: '5m 30s' } ], - recordedDuration: '5m 30s', + recordedTime: '5m 30s', lineIndex: 0 }; const result = renderExercise( @@ -2226,7 +2226,7 @@ describe('updateExerciseTimer', () => { recordedTime: '3m' } ], - recordedDuration: '6m 2m', + recordedTime: '6m 2m', lineIndex: 0 }; const result = renderExercise( @@ -2480,7 +2480,7 @@ describe('updateExerciseTimer', () => { } ], lineIndex: 0, - recordedDuration: '5m 30s' + recordedTime: '5m 30s' }; renderExercise( container, @@ -2520,7 +2520,7 @@ describe('updateExerciseTimer', () => { } ], lineIndex: 0, - recordedDuration: '6m 2m' + recordedTime: '6m 2m' }; renderExercise( container, @@ -3606,7 +3606,7 @@ describe('updateExerciseTimer', () => { } ], lineIndex: 0, - recordedDuration: '5m 50m' + recordedTime: '5m 50m' }; renderExercise( container, diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index 9cacf3c..13aad73 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -41,6 +41,12 @@ const PARAM_PREFIX_ICONS: Record = { 'rest': '⏸️' }; +const TIMER_ICONS: Record = { + 'countdown': '▼', + 'countup': '▲', + 'recorded': '✓' +}; + /** * Generate a consistent color hue from exercise name using djb2 hash. * Ensures each exercise gets a unique, visually distinct color. @@ -141,7 +147,7 @@ function getDisplayableSetParams(set: ExerciseSet): ExerciseParam[] { * Returns: Recorded duration string (e.g., "1m 30s") or null if not recorded */ function getSetRecordedDuration(set: ExerciseSet): string | null { - const durationParam = set.params.find(p => p.key.toLowerCase() === 'duration' && !p.editable); + const durationParam = set.params.find(p => p.key.toLowerCase() === '~time'); return durationParam ? durationParam.value : null; } @@ -155,7 +161,7 @@ function getSetRecordedDuration(set: ExerciseSet): string | null { * Returns: Rest duration string (e.g., "60s") or null if not set */ function getSetRestDuration(set: ExerciseSet): string | null { - const restParam = set.params.find(p => p.key.toLowerCase() === 'rest'); + const restParam = set.params.find(p => p.key.toLowerCase() === '~rest'); return restParam ? restParam.value : null; } @@ -312,8 +318,7 @@ function computeExerciseTotals(exercise: Exercise, isCompleted: boolean): { // If exercise is completed, sum recorded durations from each set if (isCompleted) { for (const param of set.params) { - if (param.key.toLowerCase() === '~time' && !param.editable) { - // Non-editable duration = recorded time (captured during set) + if (param.key.toLowerCase() === '~time') { const seconds = parseDurationToSeconds(param.value); totalRecordedTime += seconds; } @@ -474,9 +479,9 @@ export function renderExercise( // 1. Exercise completed: show recorded duration with checkmark // This is the final state after workout finishes - if (exercise.state === 'completed' && exercise.recordedDuration) { - timerEl.textContent = exercise.recordedDuration; - timerEl.createSpan({ cls: 'timer-indicator recorded', text: ' ✓' }); + if (exercise.state === 'completed' && exercise.recordedTime) { + timerEl.textContent = exercise.recordedTime; + timerEl.createSpan({ cls: 'timer-indicator recorded', text: 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() @@ -487,7 +492,7 @@ export function renderExercise( // Only show countdown indicator if single excersie. else if (!hasMultipleSets && exercise.targetDuration) { timerEl.textContent = formatDuration(exercise.targetDuration); - timerEl.createSpan({ cls: 'timer-indicator count-down', text: ' ▼' }); + timerEl.createSpan({ cls: 'timer-indicator count-down', text: TIMER_ICONS['countdown'] }); } // 4. Pending exercise with no target: show placeholder // Indicates exercise not yet started and has no defined duration @@ -629,10 +634,6 @@ function renderSetWithTimerElement( setInputs.set(setIndex, setParamInputs); } - - - - // 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 @@ -644,7 +645,7 @@ function renderSetWithTimerElement( if ( workoutState === 'completed' && recordedDuration) { timerEl.textContent = recordedDuration; - timerEl.createSpan({ cls: 'timer-indicator recorded', text: ' ✓' }); + timerEl.createSpan({ cls: 'timer-indicator recorded', text: TIMER_ICONS['recorded'] }); } // Set currently active: show live timer (updates in real-time) else if (isActive && timerState) { @@ -680,34 +681,23 @@ function renderSetWithTimerElement( if (remaining > 0) { timerEl.textContent = formatDuration(remaining); - timerEl.createSpan({ cls: 'timer-indicator rest', text: ' ⏸' }); + timerEl.createSpan({ cls: 'timer-indicator rest', text: TIMER_ICONS['countdown'] }); } else { // Rest time exceeded (overtime) timerEl.textContent = formatDuration(Math.abs(remaining)); timerEl.addClass('rest-overtime'); - timerEl.createSpan({ cls: 'timer-indicator', text: ' ⏸' }); + timerEl.createSpan({ cls: 'timer-indicator', text: TIMER_ICONS['countup'] }); } } else { // Not in rest: show exercise timer (count-up or countdown) updateExerciseTimer(timerEl, timerState, undefined); } } - else if (set.state === 'pending') { - // Pending set with defined rest: show static rest duration as placeholder - const restDurationStr = getSetRestDuration(set); - if (restDurationStr) { - timerEl.textContent = restDurationStr; - timerEl.createSpan({ cls: 'timer-indicator', text: ' ⏸' }); - } else { - // No rest defined: show placeholder - timerEl.textContent = '--'; - } - } else { const setDurationStr = getSetDuration(set); if (setDurationStr) { timerEl.textContent = setDurationStr; - timerEl.createSpan({ cls: 'timer-indicator', text: ' ⏸' }); + timerEl.createSpan({ cls: 'timer-indicator', text: TIMER_ICONS['countdown'] }); } else { // No duration defined: show placeholder timerEl.textContent = '--'; diff --git a/src/serializer.test.ts b/src/serializer.test.ts index ffe24ee..81bce47 100644 --- a/src/serializer.test.ts +++ b/src/serializer.test.ts @@ -285,9 +285,9 @@ describe('totals persistence (~time and ~rest)', () => { const source = '---\n- [x] Exercise | ~rest: 5m | ~time: 38m\n - [x] | Rest: 60s | Duration: 2:30'; const parsed = parseWorkout(source); - expect(parsed.exercises[0].params).toHaveLength(2); - expect(parsed.exercises[0].params.find(p => p.key === '~rest')).toBeDefined(); - expect(parsed.exercises[0].params.find(p => p.key === '~time')).toBeDefined(); + // ~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', () => { @@ -417,9 +417,9 @@ describe('totals persistence (~time and ~rest)', () => { const serialized = serializeWorkout(parsed); const reparsed = parseWorkout(serialized); - // ~rest and ~time should be parsed as params - expect(reparsed.exercises[0].params.find(p => p.key === '~rest')).toBeDefined(); - expect(reparsed.exercises[0].params.find(p => p.key === '~time')).toBeDefined(); + // ~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', () => { diff --git a/src/serializer.ts b/src/serializer.ts index e9c226d..cdf7a8e 100644 --- a/src/serializer.ts +++ b/src/serializer.ts @@ -505,7 +505,7 @@ export function setRecordedDuration( }); } - exercise.recordedDuration = durationStr; + exercise.recordedTime = durationStr; return newParsed; } diff --git a/src/types.ts b/src/types.ts index efd1394..8f7192b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,7 @@ 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) recordedTime?: string; // Actual elapsed time during the set (from timer) recordedRest?: string; // Actual elapsed time during rest period after this set } @@ -47,7 +48,8 @@ export interface Exercise { params: ExerciseParam[]; // Exercise-level params (e.g., Duration) sets: ExerciseSet[]; // Nested sets targetDuration?: number; // Target duration in seconds (for countdown) - recordedDuration?: string; // Recorded duration after completion + recordedTime?: string; // Recorded duration after completion + recordedRest?: string; // Recorded rest duration after completion lineIndex: number; // Line index relative to exercise section start } From a48987d307a890c34bbdb6ec5093706e36c45010 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Sun, 12 Apr 2026 22:24:06 -0500 Subject: [PATCH 26/42] fixed render issue with rest --- src/parser/exercise.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/parser/exercise.ts b/src/parser/exercise.ts index 2a4f67e..ee088af 100644 --- a/src/parser/exercise.ts +++ b/src/parser/exercise.ts @@ -274,6 +274,7 @@ function parseParam(paramStr: string): ExerciseParam | null { const allowedParams = ['duration', 'weight', 'reps', 'rest', '~time', '~rest']; const paramKeyLower = keyToken.value.toLowerCase(); if (!allowedParams.includes(paramKeyLower)) { + console.warn(`Ignoring unrecognized parameter: ${keyToken.value}`); return null; // Ignore unrecognized parameters } @@ -284,7 +285,7 @@ function parseParam(paramStr: string): ExerciseParam | null { // 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', '~time', '~rest'].includes(keyToken.value.toLowerCase()); + const isDurationParam = ['duration', 'rest', '~time', '~rest'].includes(keyToken.value.toLowerCase()); if (isDurationParam && finalUnit) { finalValue = finalValue + finalUnit; finalUnit = undefined; From 808316d15e776ceada4dd5680854aa1a77f2c17a Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Sun, 12 Apr 2026 22:27:03 -0500 Subject: [PATCH 27/42] comment change --- src/parser/exercise.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/parser/exercise.ts b/src/parser/exercise.ts index ee088af..7b2810f 100644 --- a/src/parser/exercise.ts +++ b/src/parser/exercise.ts @@ -59,9 +59,9 @@ function tokenizeExerciseLine(line: string): { stateChar: string; remainder: str * Format: "- [STATE] Exercise Name | Key: [value] unit | Key: value" * * Special handling: - * - Duration params: editable [60s] = countdown target, locked 45s = recorded time + * - Duration params: editable [60s] = countdown target, locked 45s = countdown timer + auto advance * - All other params are stored in exercise.params - * - Returns empty sets array (filled by parent parseWorkout) + * - Sets array is empty (filled by parent parseWorkout) */ export function parseExercise(line: string, lineIndex: number): Exercise | null { const tokenized = tokenizeExerciseLine(line); @@ -104,8 +104,8 @@ export function parseExercise(line: string, lineIndex: number): Exercise | null params, sets: [], // Set by parent parseWorkout, not by this function targetDuration, // Seconds: used for countdown timers - recordedTime, // Seconds: actual time recorded after completion - recordedRest, // Seconds: actual rest time recorded after completion + recordedTime, // Seconds: actual time recorded after completion + recordedRest, // Seconds: actual rest time recorded after completion lineIndex }; } From 61a8f2b8038aefc6803566b9529c0b3a59509f45 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Sun, 12 Apr 2026 22:43:05 -0500 Subject: [PATCH 28/42] added targetRest value to exercise and set --- src/parser/exercise.ts | 14 +++++++++++++- src/parser/index.ts | 2 ++ src/types.ts | 20 +++++++++++--------- tsconfig.json | 3 ++- 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/parser/exercise.ts b/src/parser/exercise.ts index 7b2810f..2e86d07 100644 --- a/src/parser/exercise.ts +++ b/src/parser/exercise.ts @@ -76,6 +76,7 @@ export function parseExercise(line: string, lineIndex: number): Exercise | null const params: ExerciseParam[] = []; let targetDuration: number | undefined; + let targetRest: number | undefined; let recordedTime: string | undefined; let recordedRest: string | undefined; @@ -85,7 +86,11 @@ export function parseExercise(line: string, lineIndex: number): Exercise | null 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; } @@ -104,6 +109,7 @@ export function parseExercise(line: string, lineIndex: number): Exercise | null params, 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 @@ -133,6 +139,7 @@ export function parseSet(line: string, lineIndex: number): ExerciseSet | null { const params: ExerciseParam[] = []; let targetDuration: number | undefined; + let targetRest: number | undefined; let recordedTime: string | undefined; let recordedRest: string | undefined; @@ -143,6 +150,10 @@ export function parseSet(line: string, lineIndex: number): ExerciseSet | null { 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; } @@ -160,6 +171,7 @@ export function parseSet(line: string, lineIndex: number): ExerciseSet | null { 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 }; diff --git a/src/parser/index.ts b/src/parser/index.ts index 112620a..7844ccb 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -82,6 +82,7 @@ export function parseWorkout(source: string): ParsedWorkout { params: currentExercise.params, lineIndex: currentExercise.lineIndex, targetDuration: currentExercise.targetDuration, + targetRest: currentExercise.targetRest, recordedTime: currentExercise.recordedTime, recordedRest: currentExercise.recordedRest }); @@ -107,6 +108,7 @@ export function parseWorkout(source: string): ParsedWorkout { params: currentExercise.params, lineIndex: currentExercise.lineIndex, targetDuration: currentExercise.targetDuration, + targetRest: currentExercise.targetRest, recordedTime: currentExercise.recordedTime, recordedRest: currentExercise.recordedRest }); diff --git a/src/types.ts b/src/types.ts index 8f7192b..28bec07 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,10 +25,11 @@ export interface ExerciseParam { export interface ExerciseSet { state: ExerciseState; params: ExerciseParam[]; - lineIndex: number; // Line index relative to exercise section start + lineIndex: number; // Line index relative to exercise section start targetDuration?: number; // Target 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 + 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 @@ -45,12 +46,13 @@ export interface WorkoutMetadata { export interface Exercise { state: ExerciseState; name: string; - params: ExerciseParam[]; // Exercise-level params (e.g., Duration) - sets: ExerciseSet[]; // Nested sets - targetDuration?: number; // Target 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 + 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 diff --git a/tsconfig.json b/tsconfig.json index bdd1b01..b5cfa81 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,8 @@ "DOM", "ES5", "ES6", - "ES7" + "ES7", + "ES2019" ] }, "include": [ From b38aa6e10a2c0aa47e9d79fd29b11fdeded63d82 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Sun, 12 Apr 2026 22:49:49 -0500 Subject: [PATCH 29/42] fixed comment --- src/renderer/exercise.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index 13aad73..d2df5af 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -152,7 +152,7 @@ function getSetRecordedDuration(set: ExerciseSet): string | null { } /** - * Extract rest duration from a set's rest parameter. + * Extract recorded rest duration from a set (system-managed ~rest param). * Rest is the period between sets (after completing one set, before starting next). * * Parameters: From 997b0f742c064067a55f3df2b51e5e774409b747 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Mon, 13 Apr 2026 19:43:12 -0500 Subject: [PATCH 30/42] fixed timer rendering duration --- src/renderer/exercise.ts | 51 ++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index d2df5af..f11cb4e 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -138,29 +138,29 @@ function getDisplayableSetParams(set: ExerciseSet): ExerciseParam[] { } /** - * Extract recorded duration from a set (system-managed ~time param). - * Recorded durations are locked (not editable) params created after set completion. + * 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 duration string (e.g., "1m 30s") or null if not recorded + * Returns: Recorded time string (e.g., "1m 30s") or null if not recorded */ -function getSetRecordedDuration(set: ExerciseSet): string | null { +function getSetRecordedTime(set: ExerciseSet): string | null { const durationParam = set.params.find(p => p.key.toLowerCase() === '~time'); return durationParam ? durationParam.value : null; } /** - * Extract recorded rest duration from a set (system-managed ~rest param). + * 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 duration string (e.g., "60s") or null if not set + * Returns: Rest string (e.g., "60s") or null if not set */ -function getSetRestDuration(set: ExerciseSet): string | null { +function getSetRecordedRest(set: ExerciseSet): string | null { const restParam = set.params.find(p => p.key.toLowerCase() === '~rest'); return restParam ? restParam.value : null; } @@ -470,13 +470,16 @@ export function renderExercise( } // Timer display (right side of mainRow) - // Shown on mainRow if no sets or single set, otherwise on active set + // Shown on mainRow if single set, otherwise on active set // Priority order: completed > active > static target > pending placeholder let timerEl: HTMLElement | null = null; + + console.log('Exercise.sets.length: ', exercise.sets.length); - if (exercise.sets.length === 0 || !hasMultipleSets) { + if (!hasMultipleSets) { timerEl = mainRow.createSpan({ cls: 'workout-exercise-timer' }); + 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) { @@ -489,20 +492,23 @@ export function renderExercise( updateExerciseTimer(timerEl, timerState, exercise.targetDuration); } // 3. Static target display (when not active) - // Only show countdown indicator if single excersie. - else if (!hasMultipleSets && exercise.targetDuration) { + // Priority order: set row duration > exercise row duration + else if (singleSetDuration) { + timerEl.textContent = formatDuration(singleSetDuration); + timerEl.createSpan({ cls: 'timer-indicator count-down', text: TIMER_ICONS['countdown'] }); + } + else if (exercise.targetDuration) { timerEl.textContent = formatDuration(exercise.targetDuration); timerEl.createSpan({ cls: 'timer-indicator count-down', text: TIMER_ICONS['countdown'] }); } // 4. Pending exercise with no target: show placeholder - // Indicates exercise not yet started and has no defined duration - else if (!hasMultipleSets && exercise.state === 'pending') { + else { + console.log('No duration defined for set:', exercise); timerEl.textContent = '--'; } } - // Render sets as indented rows (only for multi-set exercises) - if (hasMultipleSets) { + else { const setsContainer = exerciseEl.createDiv({ cls: 'workout-sets' }); for (let setIndex = 0; setIndex < exercise.sets.length; setIndex++) { const set = exercise.sets[setIndex]; @@ -536,10 +542,7 @@ export function renderExercise( ); } } - } else if (!hasMultipleSets && isActive && workoutState === 'started') { - // For single-set active exercises, store the timerEl from mainRow as setTimerEl - setTimerEl = timerEl; - } + } // Controls row (only for active set during workout) if (isActive && workoutState === 'started') { @@ -641,7 +644,7 @@ function renderSetWithTimerElement( timerEl = setRow.createSpan({ cls: 'workout-set-timer' }); // Show recorded duration for completed sets - const recordedDuration = getSetRecordedDuration(set); + const recordedDuration = getSetRecordedTime(set); if ( workoutState === 'completed' && recordedDuration) { timerEl.textContent = recordedDuration; @@ -650,7 +653,7 @@ function renderSetWithTimerElement( // Set currently active: show live timer (updates in real-time) else if (isActive && timerState) { // Get rest duration for timer logic - const restDurationStr = getSetRestDuration(set); + const restDurationStr = getSetRecordedRest(set); // In rest mode: show rest timer with phase-based color coding if (timerState.isRestActive && timerState.restRemaining !== undefined) { @@ -695,10 +698,12 @@ function renderSetWithTimerElement( } else { const setDurationStr = getSetDuration(set); - if (setDurationStr) { - timerEl.textContent = setDurationStr; + const setDuration = setDurationStr ? parseDurationToSeconds(setDurationStr) : 0; + if (setDuration != 0 ) { + timerEl.textContent = formatDuration(setDuration); timerEl.createSpan({ cls: 'timer-indicator', text: TIMER_ICONS['countdown'] }); } else { + console.log('No duration defined for set:', set); // No duration defined: show placeholder timerEl.textContent = '--'; } From 3e7bd7cba5c1eab12ab81dc1c6040a77e312745c Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Mon, 13 Apr 2026 20:14:40 -0500 Subject: [PATCH 31/42] changed logic, only display duration from set row, ignore exercise row --- src/renderer/exercise.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index f11cb4e..a2f25cc 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -492,15 +492,10 @@ export function renderExercise( updateExerciseTimer(timerEl, timerState, exercise.targetDuration); } // 3. Static target display (when not active) - // Priority order: set row duration > exercise row duration else if (singleSetDuration) { timerEl.textContent = formatDuration(singleSetDuration); timerEl.createSpan({ cls: 'timer-indicator count-down', text: TIMER_ICONS['countdown'] }); } - else if (exercise.targetDuration) { - timerEl.textContent = formatDuration(exercise.targetDuration); - timerEl.createSpan({ cls: 'timer-indicator count-down', text: TIMER_ICONS['countdown'] }); - } // 4. Pending exercise with no target: show placeholder else { console.log('No duration defined for set:', exercise); @@ -701,7 +696,7 @@ function renderSetWithTimerElement( const setDuration = setDurationStr ? parseDurationToSeconds(setDurationStr) : 0; if (setDuration != 0 ) { timerEl.textContent = formatDuration(setDuration); - timerEl.createSpan({ cls: 'timer-indicator', text: TIMER_ICONS['countdown'] }); + timerEl.createSpan({ cls: 'timer-indicator count-down', text: TIMER_ICONS['countdown'] }); } else { console.log('No duration defined for set:', set); // No duration defined: show placeholder From 789ca3a799adfdff5f93bb56b7b90d4045385b41 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Mon, 13 Apr 2026 22:49:34 -0500 Subject: [PATCH 32/42] fixed: stale render now unsubscribe --- src/renderer/exercise.ts | 3 --- src/renderer/index.ts | 8 +++++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index a2f25cc..16af136 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -473,8 +473,6 @@ export function renderExercise( // Shown on mainRow if single set, otherwise on active set // Priority order: completed > active > static target > pending placeholder let timerEl: HTMLElement | null = null; - - console.log('Exercise.sets.length: ', exercise.sets.length); if (!hasMultipleSets) { timerEl = mainRow.createSpan({ cls: 'workout-exercise-timer' }); @@ -498,7 +496,6 @@ export function renderExercise( } // 4. Pending exercise with no target: show placeholder else { - console.log('No duration defined for set:', exercise); timerEl.textContent = '--'; } } diff --git a/src/renderer/index.ts b/src/renderer/index.ts index b972963..153d997 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -145,8 +145,9 @@ export function renderWorkout(ctx: RendererContext): void { let lastKnownActiveIndex = initialActiveIndex; // Prevent multiple auto-advances from the same render instance let hasAutoAdvanced = false; + let unsubscribe: () => void; - timerManager.subscribe(workoutId, (state: TimerState) => { + unsubscribe = timerManager.subscribe(workoutId, (state: TimerState) => { // Update the header timer display updateHeaderTimer(headerTimerEl, state); @@ -156,8 +157,13 @@ export function renderWorkout(ctx: RendererContext): void { // 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; } + else { + console.log('Valid render. Current active index:', currentActiveIndex) + } // Update the active exercise's timer display const activeElements = exerciseElements[currentActiveIndex]; From 03c064484da50740abe778d06044bf611bc4a24c Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Tue, 14 Apr 2026 15:06:52 -0500 Subject: [PATCH 33/42] added write to file after parsing and single set exercise is found --- src/main.ts | 15 ++++++++++++++- src/parser/exercise.ts | 2 +- src/renderer/index.ts | 3 --- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/main.ts b/src/main.ts index 54329af..cc9b205 100644 --- a/src/main.ts +++ b/src/main.ts @@ -62,6 +62,19 @@ export default class WorkoutLogPlugin extends Plugin { 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}`; @@ -559,7 +572,7 @@ export default class WorkoutLogPlugin extends Plugin { currentParsed = updateSetState(currentParsed, exerciseIndex, activeSetIndex, 'completed'); currentParsed = addSet(currentParsed, exerciseIndex); - const newSetIndex = currentParsed.exercises[exerciseIndex]?.sets.length || 0 - 1; + const newSetIndex = (currentParsed.exercises[exerciseIndex]?.sets.length ?? 1) - 1; currentParsed = updateSetState(currentParsed, exerciseIndex, newSetIndex, 'inProgress'); // Update timer to track new set diff --git a/src/parser/exercise.ts b/src/parser/exercise.ts index 2e86d07..5491d76 100644 --- a/src/parser/exercise.ts +++ b/src/parser/exercise.ts @@ -286,7 +286,7 @@ function parseParam(paramStr: string): ExerciseParam | null { const allowedParams = ['duration', 'weight', 'reps', 'rest', '~time', '~rest']; const paramKeyLower = keyToken.value.toLowerCase(); if (!allowedParams.includes(paramKeyLower)) { - console.warn(`Ignoring unrecognized parameter: ${keyToken.value}`); + console.log(`Ignoring unrecognized parameter: ${keyToken.value}`); return null; // Ignore unrecognized parameters } diff --git a/src/renderer/index.ts b/src/renderer/index.ts index 153d997..3bb5518 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -161,9 +161,6 @@ export function renderWorkout(ctx: RendererContext): void { if (unsubscribe) unsubscribe(); return; } - else { - console.log('Valid render. Current active index:', currentActiveIndex) - } // Update the active exercise's timer display const activeElements = exerciseElements[currentActiveIndex]; From a8c3a906f141e78e8328bdab922536038924d8c3 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Tue, 14 Apr 2026 19:40:08 -0500 Subject: [PATCH 34/42] fixed timer issues, pending colors --- src/renderer/exercise.ts | 76 +++++++++++++++++----------------------- src/renderer/header.ts | 8 ++--- src/renderer/index.ts | 38 ++++++++++++++++---- src/types.ts | 23 ++++++++++++ styles.css | 8 +++++ 5 files changed, 98 insertions(+), 55 deletions(-) diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index 16af136..871fc0a 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -17,36 +17,9 @@ * - Helper functions extract and compute exercise/set data */ -import { Exercise, ExerciseSet, ExerciseParam, ExerciseState, TimerState, WorkoutCallbacks } from '../types'; +import { Constants, Exercise, ExerciseSet, ExerciseParam, ExerciseState, TimerState, WorkoutCallbacks } from '../types'; import { formatDuration, parseDurationToSeconds, formatDurationHuman } from '../parser/exercise'; -/** - * Visual indicators for exercise/set states. - * Maps ExerciseState to Unicode symbols: - * - pending: ○ (empty circle) - * - inProgress: ◐ (half circle) - * - completed: ✓ (checkmark) - * - skipped: — (dash) - */ -const STATE_ICONS: Record = { - 'pending': '○', - 'inProgress': '◐', - 'completed': '✓', - 'skipped': '—' -}; - -const PARAM_PREFIX_ICONS: Record = { - 'duration': '⏱️', - 'reps': '×', - 'rest': '⏸️' -}; - -const TIMER_ICONS: Record = { - 'countdown': '▼', - 'countup': '▲', - 'recorded': '✓' -}; - /** * Generate a consistent color hue from exercise name using djb2 hash. * Ensures each exercise gets a unique, visually distinct color. @@ -223,8 +196,8 @@ function renderParamElement( // Render prefix icon if icon exists for this param key const keyLower = param.key.toLowerCase(); - if (PARAM_PREFIX_ICONS[keyLower]) { - paramEl.createSpan({ cls: 'workout-param-prefix', text: PARAM_PREFIX_ICONS[keyLower] }); + 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 @@ -386,7 +359,7 @@ export function renderExercise( // 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' }); @@ -482,7 +455,7 @@ export function renderExercise( // 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: TIMER_ICONS['recorded'] }); + 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() @@ -492,7 +465,7 @@ export function renderExercise( // 3. Static target display (when not active) else if (singleSetDuration) { timerEl.textContent = formatDuration(singleSetDuration); - timerEl.createSpan({ cls: 'timer-indicator count-down', text: TIMER_ICONS['countdown'] }); + timerEl.createSpan({ cls: 'timer-indicator count-down', text: Constants.TIMER_ICONS['count-down'] }); } // 4. Pending exercise with no target: show placeholder else { @@ -604,7 +577,7 @@ function renderSetWithTimerElement( // State icon const iconEl = setRow.createSpan({ cls: 'workout-set-icon' }); - iconEl.textContent = STATE_ICONS[set.state]; + iconEl.textContent = Constants.STATE_ICONS[set.state]; // Set label ("Set 1", "Set 2", etc.) const labelEl = setRow.createSpan({ cls: 'workout-set-label' }); @@ -640,7 +613,7 @@ function renderSetWithTimerElement( if ( workoutState === 'completed' && recordedDuration) { timerEl.textContent = recordedDuration; - timerEl.createSpan({ cls: 'timer-indicator recorded', text: TIMER_ICONS['recorded'] }); + 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) { @@ -676,12 +649,12 @@ function renderSetWithTimerElement( if (remaining > 0) { timerEl.textContent = formatDuration(remaining); - timerEl.createSpan({ cls: 'timer-indicator rest', text: TIMER_ICONS['countdown'] }); + timerEl.createSpan({ cls: 'timer-indicator rest', text: Constants.TIMER_ICONS['count-down'] }); } else { // Rest time exceeded (overtime) timerEl.textContent = formatDuration(Math.abs(remaining)); timerEl.addClass('rest-overtime'); - timerEl.createSpan({ cls: 'timer-indicator', text: TIMER_ICONS['countup'] }); + timerEl.createSpan({ cls: 'timer-indicator overtime', text: Constants.TIMER_ICONS['count-up'] }); } } else { // Not in rest: show exercise timer (count-up or countdown) @@ -693,9 +666,8 @@ function renderSetWithTimerElement( const setDuration = setDurationStr ? parseDurationToSeconds(setDurationStr) : 0; if (setDuration != 0 ) { timerEl.textContent = formatDuration(setDuration); - timerEl.createSpan({ cls: 'timer-indicator count-down', text: TIMER_ICONS['countdown'] }); + timerEl.createSpan({ cls: 'timer-indicator count-down', text: Constants.TIMER_ICONS['count-down'] }); } else { - console.log('No duration defined for set:', set); // No duration defined: show placeholder timerEl.textContent = '--'; } @@ -829,22 +801,38 @@ export function updateExerciseTimer( ): void { timerEl.empty(); - if (targetDuration !== undefined) { + 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: 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 { + } + 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.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('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.ts b/src/renderer/header.ts index e16b849..a3145da 100644 --- a/src/renderer/header.ts +++ b/src/renderer/header.ts @@ -12,7 +12,7 @@ * - Timer shows different info based on workout state (planned, started, completed) */ -import { WorkoutMetadata, TimerState } from '../types'; +import { Constants, WorkoutMetadata, TimerState } from '../types'; import { formatDuration, formatDurationHuman } from '../parser/exercise'; /** @@ -59,11 +59,11 @@ export function renderHeader( if (metadata.state === 'completed' && metadata.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) { // 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 = '--:--'; @@ -99,5 +99,5 @@ export function updateHeaderTimer( timerEl.textContent = `Total: ${formatDuration(timerState.workoutElapsed)}`; // Add count-up indicator to show timer actively running - timerEl.createSpan({ cls: 'workout-timer-indicator count-up', text: ' ▲' }); + timerEl.createSpan({ cls: 'workout-timer-indicator count-up', text: Constants.TIMER_ICONS['count-up'] }); } diff --git a/src/renderer/index.ts b/src/renderer/index.ts index 3bb5518..233ee7e 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -153,6 +153,8 @@ export function renderWorkout(ctx: RendererContext): void { // Get current active index from timer manager (not stale capture) const currentActiveIndex = timerManager.getActiveExerciseIndex(workoutId); + const currentSetActiveIndex = timerManager.getActiveSetIndex(workoutId); + // Stale render detection: if active index changed, a new render is handling updates // Stop processing - this render is obsolete @@ -165,26 +167,48 @@ export function renderWorkout(ctx: RendererContext): void { // 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 sets - if (activeElements?.setTimerEl) { + // 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, - undefined // Set timers are count-up, not countdown + activeSet?.targetDuration // Set timers are count-up, not countdown ); - } else if (activeElements?.timerEl) { - // Update exercise timer if no sets + } + // 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.targetDuration + activeExercise.sets[0]?.targetRest ); } // Auto-advance to next exercise when rest completes - // Conditions: (1) on last set, (2) not last exercise, (3) rest finished + // Conditions: (1) on last set, (2) not last exercise, (3) const isLastSet = Array.isArray(activeExercise.sets) && activeElements?.setTimerEl && (timerManager.getActiveSetIndex(workoutId) === activeExercise.sets.length - 1); const isLastExercise = currentActiveIndex === parsed.exercises.length - 1; diff --git a/src/types.ts b/src/types.ts index 28bec07..a7aea61 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,29 @@ export interface Token { 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; diff --git a/styles.css b/styles.css index cb779c1..5736713 100644 --- a/styles.css +++ b/styles.css @@ -259,6 +259,14 @@ color: var(--color-red); } +.timer-indicator.rest { + color: var(--color-blue); +} + +.timer-indicator.rest-overtime { + color: var(--color-purple); +} + /* Exercise Controls */ .workout-exercise-controls { display: flex; From 2c9c3a077349555b0a1e70de39f26407bfdc13e5 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Tue, 14 Apr 2026 20:00:38 -0500 Subject: [PATCH 35/42] fixed timer rest and overtime --- src/renderer/exercise.ts | 3 ++- styles.css | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index 871fc0a..c80d349 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -822,11 +822,12 @@ export function updateExerciseTimer( 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('overtime'); + timerEl.addClass('rest-overtime'); timerEl.createSpan({ cls: 'timer-indicator rest-overtime', text: Constants.TIMER_ICONS['rest-overtime'] }); } } diff --git a/styles.css b/styles.css index 5736713..fca8ff9 100644 --- a/styles.css +++ b/styles.css @@ -238,6 +238,34 @@ color: var(--color-red); } +.workout-exercise-timer.rest { + color: #5be2fb; +} + +.workout-exercise-timer.rest-overtime { + color: #2d92a6; +} + +.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: #5be2fb; +} + +.workout-set-timer.rest-overtime { + color: #2d92a6; +} + .timer-indicator { margin-left: 2px; font-size: 0.85em; @@ -260,11 +288,11 @@ } .timer-indicator.rest { - color: var(--color-blue); + color: #5be2fb; } .timer-indicator.rest-overtime { - color: var(--color-purple); + color: #2d92a6; } /* Exercise Controls */ From 0388674bdf7d7fb124543386e065339dc34d8ed5 Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Tue, 14 Apr 2026 21:48:23 -0500 Subject: [PATCH 36/42] changed controls text for next button --- src/renderer/exercise.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/exercise.ts b/src/renderer/exercise.ts index c80d349..9660cf4 100644 --- a/src/renderer/exercise.ts +++ b/src/renderer/exercise.ts @@ -649,10 +649,12 @@ function renderSetWithTimerElement( 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'] }); } @@ -751,7 +753,7 @@ function renderSetControls( let nextBtnText: string; if (timerState?.isRestActive) { // During rest period, offer to start next - nextBtnText = 'Start Next'; + nextBtnText = 'Next'; } else { const isLastSet = setIndex === totalSets - 1; const isLastExercise = typeof totalExercises === 'number' ? (exerciseIndex === totalExercises - 1) : false; @@ -763,7 +765,7 @@ function renderSetControls( nextBtnText = 'Done'; } else { // Not last set - nextBtnText = 'Next Set'; + nextBtnText = 'Next'; } } From 08eb4077ab7a90bfa4aeadcf1e02459e312e290d Mon Sep 17 00:00:00 2001 From: Erik Parra Date: Tue, 14 Apr 2026 22:34:27 -0500 Subject: [PATCH 37/42] fixed unit tests --- src/renderer/exercise.test.ts | 277 +++++++++++++++++++++++----------- src/renderer/index.test.ts | 13 +- src/serializer.test.ts | 5 +- src/timer/manager.test.ts | 8 +- 4 files changed, 200 insertions(+), 103 deletions(-) diff --git a/src/renderer/exercise.test.ts b/src/renderer/exercise.test.ts index 031d316..7459dba 100644 --- a/src/renderer/exercise.test.ts +++ b/src/renderer/exercise.test.ts @@ -694,8 +694,11 @@ describe('renderExercise & updateExerciseTimer', () => { lineIndex: 0 }; const timerState: TimerState = { - elapsed: 30, + workoutElapsed: 0, + exerciseElapsed: 0, + isOvertime: false, isRestActive: true, + restElapsed: 30, // Assuming 30s elapsed in rest restRemaining: 60 }; const result = renderExercise( @@ -727,9 +730,12 @@ describe('renderExercise & updateExerciseTimer', () => { lineIndex: 0 }; const timerState: TimerState = { - elapsed: 60, + workoutElapsed: 0, + exerciseElapsed: 0, + isOvertime: false, isRestActive: true, - restRemaining: 45 // 45 / 90 = 50% (yellow phase) + restElapsed: 45, // Assuming 45s elapsed in rest + restRemaining: 45 }; const result = renderExercise( container, @@ -760,9 +766,12 @@ describe('renderExercise & updateExerciseTimer', () => { lineIndex: 0 }; const timerState: TimerState = { - elapsed: 90, + workoutElapsed: 0, + exerciseElapsed: 0, + isOvertime: false, isRestActive: true, - restRemaining: 20 // 20 / 90 = 22% (red phase) + restElapsed: 70, // Assuming 70s elapsed in rest + restRemaining: 20 }; const result = renderExercise( container, @@ -793,9 +802,12 @@ describe('renderExercise & updateExerciseTimer', () => { lineIndex: 0 }; const timerState: TimerState = { - elapsed: 75, + workoutElapsed: 0, + exerciseElapsed: 0, + isOvertime: true, isRestActive: true, - restRemaining: -15 // Negative = overtime + restElapsed: 75, // Assuming 75s elapsed in rest + restRemaining: -15 }; const result = renderExercise( container, @@ -954,8 +966,11 @@ describe('renderExercise & updateExerciseTimer', () => { lineIndex: 0 }; const timerState: TimerState = { - elapsed: 30, - isRestActive: false + workoutElapsed: 0, + exerciseElapsed: 30, + isOvertime: false, + isRestActive: false, + restRemaining: 0 }; const result = renderExercise( container, @@ -1082,8 +1097,11 @@ describe('renderExercise & updateExerciseTimer', () => { lineIndex: 0 }; const timerState: TimerState = { - elapsed: 30, - isRestActive: false + workoutElapsed: 0, + exerciseElapsed: 30, + isOvertime: false, + isRestActive: false, + restRemaining: 0 }; const result = renderExercise( container, @@ -1665,7 +1683,10 @@ describe('renderExercise & updateExerciseTimer', () => { 0, true, 0, - { setIndex: 0, phase: 'rest' }, + { workoutElapsed: 0, exerciseElapsed: 15, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, + mockCallbacks, + 'started', + undefined, mockCallbacks, 'started' ); @@ -1775,8 +1796,11 @@ describe('updateExerciseTimer', () => { it('should display formatted elapsed time', () => { const timerState: TimerState = { workoutElapsed: 120, - exerciseElapsed: 45, + exerciseElapsed: 45, // Assuming this is the relevant elapsed time for count-up + isOvertime: false, isRestActive: false, + restElapsed: 0, + exerciseRemaining: undefined, restRemaining: 0 }; @@ -1788,8 +1812,11 @@ describe('updateExerciseTimer', () => { timerEl.textContent = 'Previous Content'; const timerState: TimerState = { workoutElapsed: 120, - exerciseElapsed: 45, + exerciseElapsed: 45, // Assuming this is the relevant elapsed time for count-up + isOvertime: false, isRestActive: false, + restElapsed: 0, + exerciseRemaining: undefined, restRemaining: 0 }; @@ -1800,8 +1827,11 @@ describe('updateExerciseTimer', () => { it('should handle zero elapsed time', () => { const timerState: TimerState = { workoutElapsed: 0, - exerciseElapsed: 0, + exerciseElapsed: 0, // Assuming this is the relevant elapsed time for count-up + isOvertime: false, isRestActive: false, + restElapsed: 0, + exerciseRemaining: undefined, restRemaining: 0 }; @@ -1814,8 +1844,11 @@ describe('updateExerciseTimer', () => { it('should display countdown timer', () => { const timerState: TimerState = { workoutElapsed: 120, - exerciseElapsed: 45, + exerciseElapsed: 45, // Assuming this is the relevant elapsed time for countdown + isOvertime: false, isRestActive: false, + restElapsed: 0, + exerciseRemaining: undefined, restRemaining: 0 }; const targetDuration = 120; @@ -1828,8 +1861,11 @@ describe('updateExerciseTimer', () => { it('should handle target duration provided', () => { const timerState: TimerState = { workoutElapsed: 120, - exerciseElapsed: 45, + exerciseElapsed: 45, // Assuming this is the relevant elapsed time for countdown + isOvertime: false, isRestActive: false, + restElapsed: 0, + exerciseRemaining: undefined, restRemaining: 0 }; const targetDuration = 60; @@ -1842,8 +1878,11 @@ describe('updateExerciseTimer', () => { it('should display overtime when elapsed exceeds target', () => { const timerState: TimerState = { workoutElapsed: 150, - exerciseElapsed: 125, + 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; @@ -1855,8 +1894,11 @@ describe('updateExerciseTimer', () => { it('should add overtime class on overtime', () => { const timerState: TimerState = { workoutElapsed: 200, - exerciseElapsed: 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; @@ -1868,8 +1910,11 @@ describe('updateExerciseTimer', () => { it('should handle exact target duration match', () => { const timerState: TimerState = { workoutElapsed: 100, - exerciseElapsed: 60, + exerciseElapsed: 60, // Assuming this is the relevant elapsed time for countdown + isOvertime: false, isRestActive: false, + restElapsed: 0, + exerciseRemaining: undefined, restRemaining: 0 }; const targetDuration = 60; @@ -1920,11 +1965,11 @@ describe('updateExerciseTimer', () => { 0, true, 0, - { elapsed: 10, isRestActive: false }, - mockCallbacks, - 'started', - 1, // totalExercises - restDuration // restDuration parameter + { 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(); }); @@ -1947,7 +1992,10 @@ describe('updateExerciseTimer', () => { 0, true, 1, - { elapsed: 30, isRestActive: false }, + { workoutElapsed: 0, exerciseElapsed: 30, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, + mockCallbacks, + 'started', + undefined, mockCallbacks, 'started', 2, // totalExercises (not last) @@ -1973,7 +2021,10 @@ describe('updateExerciseTimer', () => { 0, true, 1, - { elapsed: 30, isRestActive: false }, + { workoutElapsed: 0, exerciseElapsed: 30, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, + mockCallbacks, + 'started', + undefined, mockCallbacks, 'started', 2, // totalExercises (there's another after this) @@ -1999,7 +2050,10 @@ describe('updateExerciseTimer', () => { 2, // Last exercise index true, 1, - { elapsed: 30, isRestActive: false }, + { workoutElapsed: 0, exerciseElapsed: 30, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, + mockCallbacks, + 'started', + undefined, mockCallbacks, 'started', 3, // totalExercises (3 total, on exercise 2 which is last) @@ -2022,7 +2076,10 @@ describe('updateExerciseTimer', () => { 0, true, 0, - { elapsed: 30, isRestActive: true, restRemaining: 20 }, + { workoutElapsed: 0, exerciseElapsed: 0, isOvertime: false, isRestActive: true, restElapsed: 30, restRemaining: 20 }, + mockCallbacks, + 'started', + undefined, mockCallbacks, 'started', 1, @@ -2045,9 +2102,11 @@ describe('updateExerciseTimer', () => { 0, true, 0, - { elapsed: 30, isRestActive: false }, - mockCallbacks, - 'started' + { 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(); @@ -2067,9 +2126,11 @@ describe('updateExerciseTimer', () => { 0, true, 0, - { elapsed: 30, isRestActive: false }, - mockCallbacks, - 'started' + { 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(); }); @@ -2088,9 +2149,11 @@ describe('updateExerciseTimer', () => { 0, true, 0, - { elapsed: 30, isRestActive: false }, - mockCallbacks, - 'started' + { 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(); }); @@ -2139,9 +2202,11 @@ describe('updateExerciseTimer', () => { 0, true, 0, - { elapsed: 5, isRestActive: false }, - mockCallbacks, - 'started' + { 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); }); @@ -2168,9 +2233,11 @@ describe('updateExerciseTimer', () => { 0, true, 0, - { elapsed: 10, isRestActive: false }, - mockCallbacks, - 'started' + { 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); }); @@ -2663,7 +2730,10 @@ describe('updateExerciseTimer', () => { 0, true, 0, - { elapsed: 5, isRestActive: false }, + { workoutElapsed: 0, exerciseElapsed: 0, isOvertime: true, isRestActive: true, restElapsed: 75, restRemaining: -15 }, // Overtime by 15 seconds + mockCallbacks, + 'started', + undefined, mockCallbacks, 'started' ); @@ -2722,7 +2792,10 @@ describe('updateExerciseTimer', () => { 0, true, 0, - { elapsed: 40, isRestActive: true, restRemaining: 220 }, // 220/300 = 73% + { workoutElapsed: 0, exerciseElapsed: 0, isOvertime: false, isRestActive: true, restElapsed: 50, restRemaining: 250 }, // 250/300 = 83% + mockCallbacks, + 'started', + undefined, mockCallbacks, 'started' ); @@ -2753,9 +2826,11 @@ describe('updateExerciseTimer', () => { 0, true, 0, - { elapsed: 10, isRestActive: false }, - mockCallbacks, - 'started' + { 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(); }); @@ -2786,10 +2861,10 @@ describe('updateExerciseTimer', () => { 0, true, 0, - { elapsed: 25, isRestActive: false }, - mockCallbacks, - 'started', - undefined, + { 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(); @@ -2814,9 +2889,11 @@ describe('updateExerciseTimer', () => { 0, true, 2, // active set is index 2 - { elapsed: 30, isRestActive: false }, - mockCallbacks, - 'started' + { 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); }); @@ -2848,9 +2925,11 @@ describe('updateExerciseTimer', () => { 0, true, 1, - { elapsed: 60, isRestActive: true, restRemaining: 65 }, - mockCallbacks, - 'started' + { 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(); }); @@ -2873,10 +2952,10 @@ describe('updateExerciseTimer', () => { 0, true, 1, - { elapsed: 20, isRestActive: false }, - mockCallbacks, - 'started', - undefined, + { 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'); @@ -2931,7 +3010,10 @@ describe('updateExerciseTimer', () => { 0, true, 0, - { elapsed: 5, isRestActive: false }, + { workoutElapsed: 0, exerciseElapsed: 0, isOvertime: false, isRestActive: true, restElapsed: 0, restRemaining: 30 }, // No rest duration param + mockCallbacks, + 'started', + undefined, mockCallbacks, 'started' ); @@ -2969,7 +3051,10 @@ describe('updateExerciseTimer', () => { 0, true, 0, - { elapsed: 5, isRestActive: false }, + { workoutElapsed: 0, exerciseElapsed: 5, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, + mockCallbacks, + 'started', + undefined, mockCallbacks, 'started' ); @@ -3003,7 +3088,10 @@ describe('updateExerciseTimer', () => { 0, true, 0, - { elapsed: 5, isRestActive: false }, + { workoutElapsed: 0, exerciseElapsed: 5, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, + mockCallbacks, + 'started', + undefined, mockCallbacks, 'started' ); @@ -3037,10 +3125,11 @@ describe('updateExerciseTimer', () => { 0, true, 0, - { elapsed: 5, isRestActive: false }, - mockCallbacks, - 'started', - 60 // restDuration + { 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'); @@ -3074,15 +3163,15 @@ describe('updateExerciseTimer', () => { 0, true, 1, - { elapsed: 5, isRestActive: false }, - mockCallbacks, - 'started', - undefined, + { 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 Set'); + const nextBtn = buttons.find(b => b.textContent === 'Next'); expect(nextBtn).toBeTruthy(); if (nextBtn) { nextBtn.click(); @@ -3111,10 +3200,10 @@ describe('updateExerciseTimer', () => { 0, true, 1, - { elapsed: 5, isRestActive: false }, - mockCallbacks, - 'started', - undefined, + { workoutElapsed: 0, exerciseElapsed: 5, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, // timerState + mockCallbacks, // callbacks + 'started', // workoutState + undefined, // restDuration 3 // totalExercises - not the last ); @@ -3148,10 +3237,10 @@ describe('updateExerciseTimer', () => { 0, true, 1, - { elapsed: 5, isRestActive: false }, - mockCallbacks, - 'started', - undefined, + { 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 ); @@ -3185,15 +3274,15 @@ describe('updateExerciseTimer', () => { 0, true, 1, - { elapsed: 120, isRestActive: true, restRemaining: 30 }, - mockCallbacks, - 'started', - undefined, + { 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 === 'Start Next'); + const startNextBtn = buttons.find(b => b.textContent === 'Next'); expect(startNextBtn).toBeTruthy(); if (startNextBtn) { startNextBtn.click(); @@ -3572,7 +3661,10 @@ describe('updateExerciseTimer', () => { 0, true, 0, - { elapsed: 15, isRestActive: false }, + { workoutElapsed: 0, exerciseElapsed: 5, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, + mockCallbacks, + 'started', + undefined, mockCallbacks, 'started' ); @@ -3624,10 +3716,10 @@ describe('updateExerciseTimer', () => { it('should exercise updateExerciseTimer with various timer states', () => { const timerEl = new MockElement('span'); const timerState: TimerState = { - elapsed: 45, workoutElapsed: 100, exerciseElapsed: 45, isRestActive: false, + isOvertime: false, // Added missing property restRemaining: 0 }; updateExerciseTimer(timerEl, timerState, 60); @@ -3637,10 +3729,11 @@ describe('updateExerciseTimer', () => { it('should exercise updateExerciseTimer with target duration', () => { const timerEl = new MockElement('span'); const timerState: TimerState = { - elapsed: 30, workoutElapsed: 60, exerciseElapsed: 30, - isRestActive: false + isRestActive: false, + isOvertime: false, // Added missing property + restRemaining: 0 // Added missing property }; updateExerciseTimer(timerEl, timerState,120); expect(timerEl.textContent).toBeTruthy(); diff --git a/src/renderer/index.test.ts b/src/renderer/index.test.ts index 4fe9573..d8ec6aa 100644 --- a/src/renderer/index.test.ts +++ b/src/renderer/index.test.ts @@ -14,8 +14,8 @@ jest.mock('./header', () => ({ jest.mock('./exercise', () => ({ renderExercise: jest.fn(() => ({ container: { tag: 'div' }, - timerEl: null, - setTimerEl: null, + timerEl: { textContent: '--:--' }, + setTimerEl: { textContent: '--:--' }, inputs: new Map(), setInputs: new Map() })), @@ -671,10 +671,13 @@ describe('renderWorkout', () => { restRemaining: 0 }; + const { updateHeaderTimer } = require('./header'); + const { updateExerciseTimer } = require('./exercise'); timerCallback(timerState); // Verify header timer was updated - expect(mockCallbacks).toBeDefined(); + expect(updateHeaderTimer).toHaveBeenCalled(); + expect(updateExerciseTimer).toHaveBeenCalled(); }); it('should not subscribe to timer when workout is not running', () => { @@ -848,8 +851,8 @@ describe('renderWorkout', () => { // Call the timer callback timerCallback(timerState); - // Verify callback ran without errors - expect(mockCallbacks).toBeDefined(); + // Verify onRestEnd was called to auto-advance + expect(mockCallbacks.onRestEnd).toHaveBeenCalledWith(0); }); }); diff --git a/src/serializer.test.ts b/src/serializer.test.ts index 81bce47..d8a35f1 100644 --- a/src/serializer.test.ts +++ b/src/serializer.test.ts @@ -26,7 +26,7 @@ describe('serializeWorkout', () => { expect(serialized).toContain('state: planned'); expect(serialized).toContain('---'); expect(serialized).toContain('- [ ] Exercise'); - expect(serialized).toContain('- [ ] | Weight: [100] kg'); + expect(serialized).toContain(' - [ ] | Weight: [100] kg'); }); it('should roundtrip parse and serialize', () => { @@ -511,7 +511,8 @@ describe('totals persistence roundtrip', () => { // First serialize - should calculate exercise totals const serialized1 = serializeWorkout(parsed1); expect(serialized1).toContain('- [x] Squats | ~rest: 2m | ~time: 4m 30s'); - expect(serialized1).toContain('- [x] | Rest: 60 s | ~time: 2m | ~rest: 60s'); + // ~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); diff --git a/src/timer/manager.test.ts b/src/timer/manager.test.ts index 2e5d3cd..96167b4 100644 --- a/src/timer/manager.test.ts +++ b/src/timer/manager.test.ts @@ -278,10 +278,10 @@ describe('TimerManager', () => { it('should calculate rest elapsed time', () => { manager.startWorkoutTimer('workout1'); manager.startRest('workout1', 60); - dateNowSpy.mockReturnValue(1035); + dateNowSpy.mockReturnValue(36000); const elapsed = manager.getRestElapsedSeconds('workout1'); - expect(elapsed).toBe(35); + expect(elapsed).toBe(35); // 36000ms (now) - 1000ms (start) = 35000ms = 35s }); }); @@ -289,10 +289,10 @@ describe('TimerManager', () => { it('should calculate rest remaining seconds', () => { manager.startWorkoutTimer('workout1'); manager.startRest('workout1', 60); - dateNowSpy.mockReturnValue(1020); + dateNowSpy.mockReturnValue(21000); const remaining = manager.getRestRemainingSeconds('workout1'); - expect(remaining).toBeCloseTo(40, 1); + expect(remaining).toBeCloseTo(40, 1); // 60s duration - (21000ms - 1000ms) = 40s }); }); From 3965f37d2e11bf9fb6999754145e2fe8d3a4f88b Mon Sep 17 00:00:00 2001 From: Erik Parra <6844225+erikparra@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:47:57 -0500 Subject: [PATCH 38/42] Revert "Replaced regex matching with tokenizer for better maintainability" --- manifest.json | 2 +- src/parser/exercise.ts | 80 +++++++++++++----------------------------- src/types.ts | 4 +-- 3 files changed, 28 insertions(+), 58 deletions(-) diff --git a/manifest.json b/manifest.json index 9d7cff2..3dfa7b1 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "workout-log", "name": "Workout Log", - "version": "1.1.2", + "version": "1.1.1", "minAppVersion": "1.5.0", "description": "Render workout blocks as interactive trackers with built-in timers.", "author": "Lukasz", diff --git a/src/parser/exercise.ts b/src/parser/exercise.ts index 84bc93b..4c968e7 100644 --- a/src/parser/exercise.ts +++ b/src/parser/exercise.ts @@ -1,4 +1,7 @@ -import { Exercise, ExerciseState, ParameterToken } from '../types'; +import { Exercise, ExerciseState, ExerciseParam } from '../types'; + +// Checkbox patterns: [ ] pending, [\] inProgress, [x] completed, [-] skipped +const EXERCISE_PATTERN = /^-\s*\[(.)\]\s*(.+)$/; const STATE_MAP: Record = { ' ': 'pending', @@ -14,49 +17,30 @@ const STATE_CHAR_MAP: Record = { 'skipped': '-' }; -// Tokenizer for parsing exercise line -// Format: - [STATE] Exercise Name | Param: value | Param2: [value] unit -function tokenizeExerciseLine(line: string): { - stateChar: string; - remainder: string; -} | null { - // Must start with "- [" - if (!line.startsWith('- [')) return null; - - // Find closing bracket - const closeBracketIdx = line.indexOf(']', 3); - if (closeBracketIdx === -1) return null; - - const stateChar = line[3] ?? ''; - - // Remainder is everything after "] " - const spaceAfterBracket = closeBracketIdx + 1; - if (spaceAfterBracket >= line.length || line[spaceAfterBracket] !== ' ') return null; - - const remainder = line.substring(spaceAfterBracket + 1); - return { stateChar, remainder: remainder }; -} - // Parse value with optional brackets: [value] = editable, value = locked // Also handle Duration special case for timer +const PARAM_PATTERN = /^([^:]+):\s*(\[([^\]]*)\]|([^\s\[]+))(\s+(.+))?$/; + export function parseExercise(line: string, lineIndex: number): Exercise | null { - const parsed = tokenizeExerciseLine(line); - if (!parsed) return null; + const match = line.match(EXERCISE_PATTERN); + if (!match) return null; + + const stateChar = match[1] ?? ' '; + const rest = match[2] ?? ''; - const state = STATE_MAP[parsed.stateChar]; - if (!state) return null; + const state = STATE_MAP[stateChar] ?? 'pending'; // Split by | to get name and params - const parts = parsed.remainder.split('|').map(p => p.trim()); + const parts = rest.split('|').map(p => p.trim()); const name = parts[0] ?? ''; const paramStrings = parts.slice(1); - const params: ParameterToken[] = []; + const params: ExerciseParam[] = []; let targetDuration: number | undefined; let recordedDuration: string | undefined; for (const paramStr of paramStrings) { - const param = tokenizeParam(paramStr); + const param = parseParam(paramStr); if (param) { params.push(param); @@ -83,8 +67,7 @@ export function parseExercise(line: string, lineIndex: number): Exercise | null }; } - -function tokenizeParam(paramStr: string): ParameterToken | null { +function parseParam(paramStr: string): ExerciseParam | null { // Handle simple format: Key: value or Key: [value] or Key: [value] unit const colonIndex = paramStr.indexOf(':'); if (colonIndex === -1) return null; @@ -92,16 +75,11 @@ function tokenizeParam(paramStr: string): ParameterToken | null { const key = paramStr.substring(0, colonIndex).trim(); const rest = paramStr.substring(colonIndex + 1).trim(); - // Check for bracketed value using indexOf - const openBracketIdx = rest.indexOf('['); - if (openBracketIdx === 0) { - // Has brackets - const closeBracketIdx = rest.indexOf(']', 1); - if (closeBracketIdx === -1) return null; - - const value = rest.substring(1, closeBracketIdx); - const afterBracket = rest.substring(closeBracketIdx + 1).trim(); - + // Check for bracketed value + const bracketMatch = rest.match(/^\[([^\]]*)\](.*)$/); + if (bracketMatch) { + const value = bracketMatch[1] ?? ''; + const afterBracket = (bracketMatch[2] ?? '').trim(); return { key, value, @@ -110,18 +88,10 @@ function tokenizeParam(paramStr: string): ParameterToken | null { }; } - // No brackets - find first space for value and unit - const spaceIdx = rest.indexOf(' '); - let value: string; - let unit: string | undefined; - - if (spaceIdx === -1) { - // No space = just value - value = rest; - } else { - value = rest.substring(0, spaceIdx); - unit = rest.substring(spaceIdx + 1).trim() || undefined; - } + // 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; return { key, diff --git a/src/types.ts b/src/types.ts index 96577fe..f20b273 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,7 +8,7 @@ export type WorkoutState = 'planned' | 'started' | 'completed'; export type ExerciseState = 'pending' | 'inProgress' | 'completed' | 'skipped'; // Key-value pairs for exercise parameters -export interface ParameterToken { +export interface ExerciseParam { key: string; value: string; editable: boolean; // true if wrapped in [brackets] @@ -28,7 +28,7 @@ export interface WorkoutMetadata { export interface Exercise { state: ExerciseState; name: string; - params: ParameterToken[]; + 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 From 8e6e0390cefc5737aa89ed182284c1011f1606ef Mon Sep 17 00:00:00 2001 From: Erik Parra <6844225+erikparra@users.noreply.github.com> Date: Sat, 18 Apr 2026 18:41:26 -0500 Subject: [PATCH 39/42] fixed mobile style (#5) Co-authored-by: Erik Parra --- src/types.ts | 4 +- styles.css | 105 +++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 83 insertions(+), 26 deletions(-) diff --git a/src/types.ts b/src/types.ts index a7aea61..81a1f78 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,8 +29,8 @@ export abstract class Constants { 'count-down': ' ▼', 'count-up': ' ▲', 'recorded': ' ✓', - 'rest': ' ⏸', - 'rest-overtime': ' ⏸', + 'rest': ' ‖', + 'rest-overtime': ' ‖', 'overtime': ' ⚠' }; diff --git a/styles.css b/styles.css index fca8ff9..1269d3c 100644 --- a/styles.css +++ b/styles.css @@ -239,11 +239,11 @@ } .workout-exercise-timer.rest { - color: #5be2fb; + color: var(--color-cyan); } .workout-exercise-timer.rest-overtime { - color: #2d92a6; + color: var(--color-blue); } .workout-set-timer { @@ -259,11 +259,11 @@ } .workout-set-timer.rest { - color: #5be2fb; + color: var(--color-cyan); } .workout-set-timer.rest-overtime { - color: #2d92a6; + color: var(--color-blue); } .timer-indicator { @@ -288,11 +288,11 @@ } .timer-indicator.rest { - color: #5be2fb; + color: var(--color-cyan); } .timer-indicator.rest-overtime { - color: #2d92a6; + color: var(--color-blue); } /* Exercise Controls */ @@ -396,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 { @@ -432,33 +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; } - /* Params Container - Own line but compact width */ - .workout-exercise-params { - flex: 0 0 100%; - flex-wrap: wrap; - gap: 6px; - order: 10; - justify-content: flex-start; + .workout-set-label { + min-width: 5ch; + flex: 0 1 auto; + white-space: normal; + word-break: break-word; } + /* Params Container - Flexible width */ + .workout-exercise-params, .workout-set-params { - flex: 0 0 100%; + flex: 1 1 auto; flex-wrap: wrap; gap: 6px; - order: 10; justify-content: flex-start; } @@ -480,11 +483,6 @@ 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; @@ -510,6 +508,65 @@ } } +/* 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: 6px; + } + + /* Params Container - Tighter gaps */ + .workout-exercise-params, + .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% { From 67512bf52adaf60457095c04cf76ec7b5d7561fb Mon Sep 17 00:00:00 2001 From: Erik Parra <6844225+erikparra@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:51:24 -0500 Subject: [PATCH 40/42] fixed mobile sty le and updated readme with multiset exercise format (#6) Co-authored-by: Erik Parra --- README.md | 24 ++++++++++++++++++------ styles.css | 6 ++++-- 2 files changed, 22 insertions(+), 8 deletions(-) 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/styles.css b/styles.css index 1269d3c..0fd22dd 100644 --- a/styles.css +++ b/styles.css @@ -535,11 +535,13 @@ /* Exercise & Set Main Row - Tighter gaps */ .workout-exercise-main, .workout-set-main { - gap: 6px; + gap: 4px; } /* Params Container - Tighter gaps */ - .workout-exercise-params, + .workout-exercise-params{ + gap: 2px; + } .workout-set-params { gap: 4px; } From cb29b5db2dcc26157c687b2048970b4c94b934e5 Mon Sep 17 00:00:00 2001 From: Erik Parra <6844225+erikparra@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:19:28 -0500 Subject: [PATCH 41/42] updated version to 1.1.2 (#7) Co-authored-by: Erik Parra --- manifest.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index 3dfa7b1..34cbecd 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { "id": "workout-log", "name": "Workout Log", - "version": "1.1.1", + "version": "1.1.2", "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.json b/package.json index 596b7ad..7267170 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-workout-log", - "version": "1.1.1", + "version": "1.1.2", "description": "A block-rendered workout tracker for Obsidian with timer functionality", "main": "main.js", "type": "module", From aad5e7f36c81fdde87c23f1f64206e8a9bee88fb Mon Sep 17 00:00:00 2001 From: Erik Parra <6844225+erikparra@users.noreply.github.com> Date: Fri, 24 Apr 2026 20:52:56 -0500 Subject: [PATCH 42/42] Fix/workout stop after rest auto advance (#8) * updated rest overtime color to red * added stale render detection and made rest auto advance if editable * fixed unit test * added 5min grace period before timer cleans up. added resume to workout if cleaned up * fixed unit tests * updated version to 1.1.3 --- manifest.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/file/updater.test.ts | 86 ++++-- src/main.ts | 44 +++ src/parser/exercise.test.ts | 553 +++++++++++++++++++++------------- src/renderer/controls.test.ts | 14 +- src/renderer/exercise.test.ts | 74 ++--- src/renderer/index.test.ts | 36 ++- src/renderer/index.ts | 20 +- src/timer/manager.test.ts | 16 +- src/timer/manager.ts | 50 ++- styles.css | 4 +- 13 files changed, 583 insertions(+), 322 deletions(-) diff --git a/manifest.json b/manifest.json index 34cbecd..123678c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "workout-log", "name": "Workout Log", - "version": "1.1.2", + "version": "1.1.3", "minAppVersion": "1.5.0", "description": "Render workout blocks as interactive trackers with built-in timers.", "author": "erikparra", diff --git a/package-lock.json b/package-lock.json index 0db0b78..41fa306 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-workout-log", - "version": "1.1.2", + "version": "1.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-workout-log", - "version": "1.1.2", + "version": "1.1.3", "license": "MIT", "dependencies": { "obsidian": "latest" diff --git a/package.json b/package.json index 7267170..f519ea8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-workout-log", - "version": "1.1.2", + "version": "1.1.3", "description": "A block-rendered workout tracker for Obsidian with timer functionality", "main": "main.js", "type": "module", diff --git a/src/file/updater.test.ts b/src/file/updater.test.ts index 06b3a55..7813437 100644 --- a/src/file/updater.test.ts +++ b/src/file/updater.test.ts @@ -10,7 +10,7 @@ class MockTFile extends TFile { } class MockApp { - private files: Map }> = new Map(); + private files: Map }> = new Map(); setFileContent(path: string, content: string): void { if (!this.files.has(path)) { @@ -25,8 +25,8 @@ class MockApp { return this.files.get(path)?.content; } - getFileFrontmatter(path: string): Record | undefined { - return this.files.get(path)?.frontmatter; + getFileFrontmatter(path: string): Record { + return this.files.get(path)?.frontmatter || {}; } vault = { @@ -43,7 +43,7 @@ class MockApp { }; fileManager = { - processFrontMatter: async (file: MockTFile, callback: (frontmatter: Record) => void) => { + processFrontMatter: async (file: MockTFile, callback: (frontmatter: Record) => void) => { const fileData = this.files.get(file.path); if (fileData) { callback(fileData.frontmatter); @@ -385,7 +385,9 @@ describe('FileUpdater', () => { state: 'started', saveToProperties: false }, - exercises: [] + exercises: [], + rawLines: [], + metadataEndIndex: -1 }; await updater.saveToProperties(filePath, parsed); @@ -407,7 +409,9 @@ describe('FileUpdater', () => { restDuration: 300, saveToProperties: true }, - exercises: [] + exercises: [], + rawLines: [], + metadataEndIndex: -1 }; await updater.saveToProperties(filePath, parsed); @@ -436,16 +440,20 @@ describe('FileUpdater', () => { 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); @@ -476,24 +484,29 @@ describe('FileUpdater', () => { 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); @@ -518,22 +531,27 @@ describe('FileUpdater', () => { 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); @@ -557,13 +575,15 @@ describe('FileUpdater', () => { 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 } ] }, @@ -571,17 +591,21 @@ describe('FileUpdater', () => { 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); @@ -608,17 +632,21 @@ describe('FileUpdater', () => { 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); @@ -643,14 +671,18 @@ describe('FileUpdater', () => { name: 'NoParams', state: 'completed', params: [], + lineIndex: 0, sets: [ { state: 'completed', - params: [] + params: [], + lineIndex: 1 } ] } - ] + ], + rawLines: [], + metadataEndIndex: -1 }; await updater.saveToProperties(filePath, parsed); @@ -668,7 +700,9 @@ describe('FileUpdater', () => { state: 'started', saveToProperties: true }, - exercises: [] + exercises: [], + rawLines: [], + metadataEndIndex: -1 }; await updater.saveToProperties('nonexistent.md', parsed); @@ -691,16 +725,20 @@ describe('FileUpdater', () => { 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); diff --git a/src/main.ts b/src/main.ts index cc9b205..219be03 100644 --- a/src/main.ts +++ b/src/main.ts @@ -93,6 +93,50 @@ export default class WorkoutLogPlugin extends Plugin { // 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 diff --git a/src/parser/exercise.test.ts b/src/parser/exercise.test.ts index ee5f31c..46b2a5c 100644 --- a/src/parser/exercise.test.ts +++ b/src/parser/exercise.test.ts @@ -10,199 +10,276 @@ import { } 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', () => { - 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'); - }); + // 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 parse exercise with completed state', () => { - const line = '- [x] Running | Duration: 30m'; - const exercise = parseExercise(line, 0); + 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!.state).toBe('completed'); - }); + 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 parse exercise with in-progress state', () => { - const line = '- [\\] Squats | Weight: [150] lbs'; - const exercise = parseExercise(line, 0); + 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!.state).toBe('inProgress'); - }); + 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 parse exercise with skipped state', () => { - const line = '- [-] Swimming | Duration: 1h'; - const exercise = parseExercise(line, 0); + it('should preserve lineIndex', () => { + const line = '- [ ] Bench Press'; + const exercise = parseExercise(line, 42); - expect(exercise!.state).toBe('skipped'); + expect(exercise!.lineIndex).toBe(42); + }); }); - it('should return null for invalid exercise line', () => { - const line = 'invalid line'; - const exercise = parseExercise(line, 0); - - expect(exercise).toBeNull(); - }); + // 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 return null if no checkbox found', () => { - const line = '- Bench Press | Weight: [100] kg'; - const exercise = parseExercise(line, 0); + it('should parse exercise with in-progress state', () => { + const line = '- [\\] Squats | Weight: [150] lbs'; + const exercise = parseExercise(line, 0); + expect(exercise!.state).toBe('inProgress'); + }); - expect(exercise).toBeNull(); + it('should parse exercise with skipped state', () => { + const line = '- [-] Swimming | Duration: 1h'; + const exercise = parseExercise(line, 0); + expect(exercise!.state).toBe('skipped'); + }); }); - it('should handle exercise with multiple params', () => { - const line = '- [x] Squats | Weight: [200] kg | Reps: [10] | Duration: 60s'; - const exercise = parseExercise(line, 0); + // 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).toHaveLength(3); - expect(exercise!.params[0].key).toBe('Weight'); - expect(exercise!.params[1].key).toBe('Reps'); - expect(exercise!.params[2].key).toBe('Duration'); - }); + 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 targetDuration from editable Duration param', () => { - const line = '- [ ] Cardio | Duration: [120]'; - const exercise = parseExercise(line, 0); + it('should handle recordedDuration from non-editable Duration param', () => { + const line = '- [x] Cardio | Duration: 125 s'; + const exercise = parseExercise(line, 0); - expect(exercise!.targetDuration).toBe(120); - }); + // Duration values are parsed as targetDuration in seconds + expect(exercise!.targetDuration).toBe(125); + }); - it('should handle recordedDuration from non-editable Duration param', () => { - const line = '- [x] Cardio | Duration: 125 s'; - const exercise = parseExercise(line, 0); + it('should handle Duration with compound format like 3m2s', () => { + const line = '- [x] Workout | Duration: 3m2s'; + const exercise = parseExercise(line, 0); - // Duration values are parsed as targetDuration in seconds - expect(exercise!.targetDuration).toBe(125); + // Duration is converted to seconds: 3m2s = 182 seconds + expect(exercise!.targetDuration).toBe(182); + }); }); - it('should handle Duration with compound format like 3m2s', () => { - const line = '- [x] Workout | Duration: 3m2s'; - const exercise = parseExercise(line, 0); + // 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 🏋️‍♂️'); + }); - // Duration is converted to seconds: 3m2s = 182 seconds - expect(exercise!.targetDuration).toBe(182); - }); + 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 parse exercise with empty name', () => { - const line = '- [ ] | Weight: [100] kg'; - const exercise = parseExercise(line, 0); + it('should return null for invalid exercise line', () => { + const line = 'invalid line'; + const exercise = parseExercise(line, 0); + expect(exercise).toBeNull(); + }); - // Parser allows empty names (they just have no name field) - expect(exercise!.name).toBe(''); - }); + 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 preserve lineIndex', () => { - const line = '- [ ] Bench Press'; - const exercise = parseExercise(line, 42); + it('should parse exercise with empty name', () => { + const line = '- [ ] | Weight: [100] kg'; + const exercise = parseExercise(line, 0); - expect(exercise!.lineIndex).toBe(42); + // 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', () => { - 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); - }); + 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 handle set with completed state', () => { - const line = ' - [x] | Weight: 100 kg | Reps: 10'; - const set = parseSet(line, 0); + it('should parse set without indentation (trim applied)', () => { + const line = '- [\\] | Reps: [8]'; + const set = parseSet(line, 0); - expect(set!.state).toBe('completed'); - }); + expect(set!.state).toBe('inProgress'); + expect(set!.params).toHaveLength(1); + }); - it('should parse set without indentation (trim applied)', () => { - const line = '- [\\] | Reps: [8]'; - const set = parseSet(line, 0); + it('should preserve lineIndex', () => { + const line = ' - [ ] | Weight: [50] kg'; + const set = parseSet(line, 99); - expect(set!.state).toBe('inProgress'); - expect(set!.params).toHaveLength(1); + expect(set!.lineIndex).toBe(99); + }); }); - it('should return null for invalid set line', () => { - const line = ' invalid set'; - const set = parseSet(line, 0); + 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'); + }); - expect(set).toBeNull(); - }); + 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 preserve lineIndex', () => { - const line = ' - [ ] | Weight: [50] kg'; - const set = parseSet(line, 99); + it('should handle set with duration', () => { + const line = ' - [x] | Duration: 45s'; + const set = parseSet(line, 0); - expect(set!.lineIndex).toBe(99); + expect(set!.params).toHaveLength(1); + expect(set!.params[0].key).toBe('Duration'); + expect(set!.params[0].value).toBe('45s'); + }); }); - 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', () => { - it('should parse simple seconds format', () => { - expect(parseDurationToSeconds('60s')).toBe(60); - expect(parseDurationToSeconds('30s')).toBe(30); - }); + 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 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 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 parse just seconds without unit', () => { + expect(parseDurationToSeconds('120')).toBe(120); + expect(parseDurationToSeconds('45')).toBe(45); + }); - it('should handle decimal seconds', () => { - expect(parseDurationToSeconds('60')).toBe(60); + it('should handle minutes without seconds', () => { + expect(parseDurationToSeconds('1m')).toBe(60); + expect(parseDurationToSeconds('5m')).toBe(300); + }); }); - 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 return 0 for invalid format', () => { - expect(parseDurationToSeconds('invalid')).toBe(0); - expect(parseDurationToSeconds('')).toBe(0); - expect(parseDurationToSeconds('abc123')).toBe(0); - }); + it('should handle values over 60 seconds', () => { + expect(parseDurationToSeconds('120s')).toBe(120); + expect(parseDurationToSeconds('1m 90s')).toBe(150); + }); - it('should handle whitespace', () => { - expect(parseDurationToSeconds(' 60s ')).toBe(60); - expect(parseDurationToSeconds('1m 30s')).toBe(90); - }); + 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 3m2 format (no unit at end, assumes seconds)', () => { - expect(parseDurationToSeconds('3m2')).toBe(182); + 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); + }); }); }); @@ -363,102 +440,144 @@ describe('serializeSet', () => { }); }); -describe('Integration tests', () => { - 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!); +/** + * 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); + }); - 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!); - 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); + }); - 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); + }); }); - 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 - }); + 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); - 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); + 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); + 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); - }); + 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); + 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'); + expect(exercise!.params[0].value).toBe('5.5'); + expect(exercise!.params[0].unit).toBe('lbs'); + }); }); - it('should parse exercise without any params', () => { - const line = '- [x] Rest'; - const exercise = parseExercise(line, 0); + 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); - }); + 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); + 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); - }); + // 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 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(''); + }); }); - 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); + 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'); + expect(exercise!.recordedRest).toBe('5m'); + expect(exercise!.recordedTime).toBe('30m'); + }); }); }); - diff --git a/src/renderer/controls.test.ts b/src/renderer/controls.test.ts index fcf6e81..de3284b 100644 --- a/src/renderer/controls.test.ts +++ b/src/renderer/controls.test.ts @@ -209,10 +209,12 @@ describe('renderWorkoutControls', () => { name: 'Push Ups', state: 'pending', params: [], - sets: [{ state: 'pending', params: [] }], + sets: [{ state: 'pending', params: [], lineIndex: 1 }], lineIndex: 0 } - ] + ], + rawLines: [], + metadataEndIndex: -1 }; jest.clearAllMocks(); }); @@ -510,17 +512,19 @@ describe('renderWorkoutControls', () => { name: 'Push Ups', state: 'completed', params: [], - sets: [{ state: 'completed', params: [] }], + sets: [{ state: 'completed', params: [], lineIndex: 1 }], lineIndex: 0 }, { name: 'Pull Ups', state: 'completed', params: [], - sets: [{ state: 'completed', params: [] }], + sets: [{ state: 'completed', params: [], lineIndex: 4 }], lineIndex: 3 } - ] + ], + rawLines: [], + metadataEndIndex: -1 }; renderWorkoutControls(container, 'completed', mockCallbacks, multiExerciseWorkout); diff --git a/src/renderer/exercise.test.ts b/src/renderer/exercise.test.ts index 7459dba..215477d 100644 --- a/src/renderer/exercise.test.ts +++ b/src/renderer/exercise.test.ts @@ -960,7 +960,7 @@ describe('renderExercise & updateExerciseTimer', () => { params: [], sets: [ { state: 'completed', params: [] }, - { state: 'in-progress', params: [], restDuration: '45s' }, + { state: 'in-progress', params: [], targetRest: 45 }, { state: 'pending', params: [] } ], lineIndex: 0 @@ -1236,7 +1236,7 @@ describe('renderExercise & updateExerciseTimer', () => { { state: 'pending', params: [], - restDuration: '120s' + targetRest: 120 } ], lineIndex: 0 @@ -1288,7 +1288,7 @@ describe('renderExercise & updateExerciseTimer', () => { params: [], sets: [ { state: 'completed', params: [] }, - { state: 'in-progress', params: [], restDuration: '60s' } + { state: 'in-progress', params: [], targetRest: 60 } ], lineIndex: 0 }; @@ -1298,7 +1298,7 @@ describe('renderExercise & updateExerciseTimer', () => { 0, true, 1, - { setIndex: 1, phase: 'rest' }, + { workoutElapsed: 0, exerciseElapsed: 0, isOvertime: false, isRestActive: true, restElapsed: 10, restRemaining: 50 }, mockCallbacks, 'started' ); @@ -1403,7 +1403,7 @@ describe('renderExercise & updateExerciseTimer', () => { state: 'completed', params: [], recordedTime: '2m 10s', - restDuration: '120s' + recordedRest: '120s' } ], recordedTime: '2m 30s', @@ -1672,8 +1672,8 @@ describe('renderExercise & updateExerciseTimer', () => { state: 'started', params: [], sets: [ - { state: 'in-progress', params: [], restDuration: '90s' }, - { state: 'pending', params: [], restDuration: '90s' } + { state: 'in-progress', params: [], targetRest: 90 }, + { state: 'pending', params: [], targetRest: 90 } ], lineIndex: 0 }; @@ -1685,9 +1685,6 @@ describe('renderExercise & updateExerciseTimer', () => { 0, { workoutElapsed: 0, exerciseElapsed: 15, isOvertime: false, isRestActive: false, restElapsed: 0, restRemaining: 0 }, mockCallbacks, - 'started', - undefined, - mockCallbacks, 'started' ); expect(result.container).toBeDefined(); @@ -1996,10 +1993,7 @@ describe('updateExerciseTimer', () => { mockCallbacks, 'started', undefined, - mockCallbacks, - 'started', - 2, // totalExercises (not last) - undefined + 2 // totalExercises (not last) ); expect(mockCallbacks.onSetFinish).toBeDefined(); }); @@ -2025,10 +2019,7 @@ describe('updateExerciseTimer', () => { mockCallbacks, 'started', undefined, - mockCallbacks, - 'started', - 2, // totalExercises (there's another after this) - undefined + 2 // totalExercises (there's another after this) ); expect(mockCallbacks.onSetFinish).toBeDefined(); }); @@ -2054,10 +2045,7 @@ describe('updateExerciseTimer', () => { mockCallbacks, 'started', undefined, - mockCallbacks, - 'started', - 3, // totalExercises (3 total, on exercise 2 which is last) - undefined + 3 // totalExercises (3 total, on exercise 2 which is last) ); expect(mockCallbacks.onSetFinish).toBeDefined(); }); @@ -2080,10 +2068,7 @@ describe('updateExerciseTimer', () => { mockCallbacks, 'started', undefined, - mockCallbacks, - 'started', - 1, - undefined + 1 // totalExercises ); expect(mockCallbacks.onRestEnd).toBeDefined(); }); @@ -2352,8 +2337,11 @@ describe('updateExerciseTimer', () => { lineIndex: 0 }; const timerState: TimerState = { - elapsed: 30, + workoutElapsed: 30, + exerciseElapsed: 30, + isOvertime: false, isRestActive: true, + restElapsed: 50, restRemaining: 250 // 250/300 = 83% (green) }; const result = renderExercise( @@ -2385,8 +2373,11 @@ describe('updateExerciseTimer', () => { lineIndex: 0 }; const timerState: TimerState = { - elapsed: 100, + workoutElapsed: 100, + exerciseElapsed: 100, + isOvertime: false, isRestActive: true, + restElapsed: 150, restRemaining: 150 // 150/300 = 50% (yellow) }; const result = renderExercise( @@ -2418,8 +2409,11 @@ describe('updateExerciseTimer', () => { lineIndex: 0 }; const timerState: TimerState = { - elapsed: 250, + workoutElapsed: 250, + exerciseElapsed: 250, + isOvertime: false, isRestActive: true, + restElapsed: 250, restRemaining: 50 // 50/300 = 17% (red) }; const result = renderExercise( @@ -2451,8 +2445,11 @@ describe('updateExerciseTimer', () => { lineIndex: 0 }; const timerState: TimerState = { - elapsed: 75, + workoutElapsed: 75, + exerciseElapsed: 75, + isOvertime: false, isRestActive: true, + restElapsed: 75, restRemaining: -15 // Overtime by 15 seconds }; const result = renderExercise( @@ -2484,8 +2481,11 @@ describe('updateExerciseTimer', () => { lineIndex: 0 }; const timerState: TimerState = { - elapsed: 60, + workoutElapsed: 60, + exerciseElapsed: 60, + isOvertime: false, isRestActive: true, + restElapsed: 60, restRemaining: 0 // Exactly at end }; const result = renderExercise( @@ -2515,8 +2515,11 @@ describe('updateExerciseTimer', () => { lineIndex: 0 }; const timerState: TimerState = { - elapsed: 30, + workoutElapsed: 30, + exerciseElapsed: 30, + isOvertime: false, isRestActive: true, + restElapsed: 30, restRemaining: 30 // No rest duration param }; const result = renderExercise( @@ -2794,9 +2797,6 @@ describe('updateExerciseTimer', () => { 0, { workoutElapsed: 0, exerciseElapsed: 0, isOvertime: false, isRestActive: true, restElapsed: 50, restRemaining: 250 }, // 250/300 = 83% mockCallbacks, - 'started', - undefined, - mockCallbacks, 'started' ); expect(result.container.className).toContain('state-in-progress'); @@ -3357,7 +3357,7 @@ describe('updateExerciseTimer', () => { 0, true, 1, - { elapsed: 25, isRestActive: false }, + { workoutElapsed: 25, exerciseElapsed: 25, isOvertime: false, isRestActive: false }, mockCallbacks, 'started' ); @@ -3559,7 +3559,7 @@ describe('updateExerciseTimer', () => { 0, true, 2, - { elapsed: 15, isRestActive: false }, + { workoutElapsed: 15, exerciseElapsed: 15, isOvertime: false, isRestActive: false }, mockCallbacks, 'started' ); diff --git a/src/renderer/index.test.ts b/src/renderer/index.test.ts index d8ec6aa..641c657 100644 --- a/src/renderer/index.test.ts +++ b/src/renderer/index.test.ts @@ -53,6 +53,7 @@ class MockElement { _textContent: string = ''; styleMap: Map = new Map(); attributes: Map = new Map(); + isConnected: boolean = true; constructor(tag: string) { this.tag = tag; @@ -182,10 +183,12 @@ describe('renderWorkout', () => { name: 'Push Ups', state: 'pending', params: [], - sets: [{ state: 'pending', params: [] }], + sets: [{ state: 'pending', params: [], lineIndex: 1 }], lineIndex: 0 } - ] + ], + rawLines: [], + metadataEndIndex: -1 }; beforeEach(() => { @@ -306,7 +309,9 @@ describe('renderWorkout', () => { const { renderEmptyState } = require('./emptyState'); const emptyWorkout: ParsedWorkout = { metadata: { title: 'Empty', state: 'planned' }, - exercises: [] + exercises: [], + rawLines: [], + metadataEndIndex: -1 }; renderWorkout({ @@ -340,7 +345,9 @@ describe('renderWorkout', () => { const { renderEmptyState } = require('./emptyState'); const emptyCompletedWorkout: ParsedWorkout = { metadata: { title: 'Empty', state: 'completed' }, - exercises: [] + exercises: [], + rawLines: [], + metadataEndIndex: -1 }; renderWorkout({ @@ -391,17 +398,19 @@ describe('renderWorkout', () => { name: 'Exercise 1', state: 'pending', params: [], - sets: [{ state: 'pending', params: [] }], + sets: [{ state: 'pending', params: [], lineIndex: 1 }], lineIndex: 0 }, { name: 'Exercise 2', state: 'pending', params: [], - sets: [{ state: 'pending', params: [] }], + sets: [{ state: 'pending', params: [], lineIndex: 2 }], lineIndex: 1 } - ] + ], + rawLines: [], + metadataEndIndex: -1 }; renderWorkout({ @@ -667,6 +676,7 @@ describe('renderWorkout', () => { const timerState: TimerState = { workoutElapsed: 100, exerciseElapsed: 50, + isOvertime: false, isRestActive: false, restRemaining: 0 }; @@ -776,6 +786,7 @@ describe('renderWorkout', () => { const timerState: TimerState = { workoutElapsed: 100, exerciseElapsed: 50, + isOvertime: false, isRestActive: false, restRemaining: 0 }; @@ -812,7 +823,7 @@ describe('renderWorkout', () => { params: [], sets: [ { state: 'completed', params: [] }, - { state: 'in-progress', params: [], restDuration: '60s' } + { state: 'in-progress', params: [{ key: 'Rest', value: '60s', editable: true, unit: '' }] } ], lineIndex: 0 }, @@ -844,6 +855,7 @@ describe('renderWorkout', () => { const timerState: TimerState = { workoutElapsed: 200, exerciseElapsed: 100, + isOvertime: false, isRestActive: true, restRemaining: 0 }; @@ -860,7 +872,9 @@ describe('renderWorkout', () => { it('should handle empty exercise list', () => { const emptyWorkout: ParsedWorkout = { metadata: { title: 'Empty', state: 'planned' }, - exercises: [] + exercises: [], + rawLines: [], + metadataEndIndex: -1 }; renderWorkout({ @@ -892,7 +906,9 @@ describe('renderWorkout', () => { title: 'A'.repeat(500), state: 'planned' }, - exercises: mockWorkout.exercises + exercises: mockWorkout.exercises, + rawLines: [], + metadataEndIndex: -1 }; renderWorkout({ diff --git a/src/renderer/index.ts b/src/renderer/index.ts index 233ee7e..25f2d5d 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -148,6 +148,14 @@ export function renderWorkout(ctx: RendererContext): void { 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; + } + // Update the header timer display updateHeaderTimer(headerTimerEl, state); @@ -208,13 +216,19 @@ export function renderWorkout(ctx: RendererContext): void { } // Auto-advance to next exercise when rest completes - // Conditions: (1) on last set, (2) not last exercise, (3) - const isLastSet = Array.isArray(activeExercise.sets) && activeElements?.setTimerEl && + // 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 + typeof state.restRemaining === 'number' && state.restRemaining <= 0 && !hasAutoAdvanced && + isRestEditable ) { hasAutoAdvanced = true; // Trigger advance to next exercise diff --git a/src/timer/manager.test.ts b/src/timer/manager.test.ts index 96167b4..47d650a 100644 --- a/src/timer/manager.test.ts +++ b/src/timer/manager.test.ts @@ -259,8 +259,8 @@ describe('TimerManager', () => { manager.startWorkoutTimer('workout1'); dateNowSpy.mockReturnValue(6000); - const elapsed = manager.getElapsedSeconds('workout1'); - expect(elapsed).toBe(5); + const state = manager.getTimerState('workout1'); + expect(state?.workoutElapsed).toBe(5); }); it('should account for pause time', () => { @@ -269,8 +269,8 @@ describe('TimerManager', () => { manager.pauseExercise('workout1'); dateNowSpy.mockReturnValue(6000); - const elapsed = manager.getElapsedSeconds('workout1'); - expect(elapsed).toBe(1); + const state = manager.getTimerState('workout1'); + expect(state?.exerciseElapsed).toBe(1); }); }); @@ -280,8 +280,8 @@ describe('TimerManager', () => { manager.startRest('workout1', 60); dateNowSpy.mockReturnValue(36000); - const elapsed = manager.getRestElapsedSeconds('workout1'); - expect(elapsed).toBe(35); // 36000ms (now) - 1000ms (start) = 35000ms = 35s + const state = manager.getTimerState('workout1'); + expect(state?.restElapsed).toBe(35); // 36000ms (now) - 1000ms (start) = 35000ms = 35s }); }); @@ -291,8 +291,8 @@ describe('TimerManager', () => { manager.startRest('workout1', 60); dateNowSpy.mockReturnValue(21000); - const remaining = manager.getRestRemainingSeconds('workout1'); - expect(remaining).toBeCloseTo(40, 1); // 60s duration - (21000ms - 1000ms) = 40s + const state = manager.getTimerState('workout1'); + expect(state?.restRemaining).toBeCloseTo(40, 1); // 60s duration - (21000ms - 1000ms) = 40s }); }); diff --git a/src/timer/manager.ts b/src/timer/manager.ts index 16da6c0..79d6557 100644 --- a/src/timer/manager.ts +++ b/src/timer/manager.ts @@ -45,6 +45,8 @@ export class TimerManager { 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. @@ -84,9 +86,11 @@ export class TimerManager { * 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): void { + 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) { @@ -103,7 +107,7 @@ export class TimerManager { // Create new timer instance this.timers.set(workoutId, { workoutId, - workoutStartTime: now, // Total elapsed from workout start (never paused) + workoutStartTime, // Total elapsed from workout start (never paused) exerciseStartTime: now, // Current exercise/set start time exercisePausedTime: 0, // Accumulated time when paused isPaused: false, @@ -276,6 +280,12 @@ export class TimerManager { this.timers.delete(workoutId); this.lastCalledState.delete(workoutId); + const timeout = this.cleanupTimeouts.get(workoutId); + if (timeout) { + clearTimeout(timeout); + this.cleanupTimeouts.delete(workoutId); + } + // Cancel animation frame if no more active timers if (this.timers.size === 0 && this.frameId !== null) { cancelAnimationFrame(this.frameId); @@ -305,6 +315,13 @@ 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) { @@ -316,17 +333,22 @@ export class TimerManager { return () => { timer.callbacks.delete(callback); - // Auto-cleanup: delete timer if no more subscribers + // Graceful Auto-cleanup: wait 5 minutes before deleting to allow for UI re-renders if (timer.callbacks.size === 0) { - this.timers.delete(workoutId); - this.lastCalledState.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; - } + 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); } }; } @@ -594,5 +616,9 @@ export class TimerManager { } this.timers.clear(); this.lastCalledState.clear(); + for (const timeout of this.cleanupTimeouts.values()) { + clearTimeout(timeout); + } + this.cleanupTimeouts.clear(); } } diff --git a/styles.css b/styles.css index 0fd22dd..eb2d080 100644 --- a/styles.css +++ b/styles.css @@ -263,7 +263,7 @@ } .workout-set-timer.rest-overtime { - color: var(--color-blue); + color: var(--color-red); } .timer-indicator { @@ -292,7 +292,7 @@ } .timer-indicator.rest-overtime { - color: var(--color-blue); + color: var(--color-red); } /* Exercise Controls */