@holoscript/plugin-fitness-wellness 2.0.1 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@holoscript/plugin-fitness-wellness",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "main": "src/index.ts",
5
5
  "peerDependencies": {
6
- "@holoscript/core": "8.0.6"
6
+ "@holoscript/core": ">=8.0.0"
7
7
  },
8
8
  "license": "MIT",
9
9
  "scripts": {
10
10
  "test": "vitest run --passWithNoTests",
11
11
  "test:coverage": "vitest run --coverage --passWithNoTests"
12
12
  }
13
- }
13
+ }
@@ -45,11 +45,11 @@ describe('oneRepMax', () => {
45
45
 
46
46
  it('Brzycki formula: 100 kg × 5 reps ≈ 112.5 kg', () => {
47
47
  const r = oneRepMax(100, 5);
48
- expect(r.brzycki).toBeCloseTo(100 * 36 / 32, 3);
48
+ expect(r.brzycki).toBeCloseTo((100 * 36) / 32, 3);
49
49
  });
50
50
 
51
51
  it('higher reps → higher 1RM estimate', () => {
52
- const r5 = oneRepMax(100, 5);
52
+ const r5 = oneRepMax(100, 5);
53
53
  const r10 = oneRepMax(100, 10);
54
54
  expect(r10.average).toBeGreaterThan(r5.average);
55
55
  });
@@ -125,7 +125,7 @@ describe('vo2MaxNonExercise', () => {
125
125
 
126
126
  it('older age → lower VO2max (all else equal)', () => {
127
127
  const rYoung = vo2MaxNonExercise(25, 25, 5, true);
128
- const rOld = vo2MaxNonExercise(55, 25, 5, true);
128
+ const rOld = vo2MaxNonExercise(55, 25, 5, true);
129
129
  expect(rOld.vo2MaxMlKgMin).toBeLessThan(rYoung.vo2MaxMlKgMin);
130
130
  });
131
131
 
@@ -241,7 +241,7 @@ describe('jacksonPollockSkinfold', () => {
241
241
 
242
242
  it('body density ~1.07 (plausible for lean male)', () => {
243
243
  expect(male.bodyDensityGcc).toBeGreaterThan(1.04);
244
- expect(male.bodyDensityGcc).toBeLessThan(1.10);
244
+ expect(male.bodyDensityGcc).toBeLessThan(1.1);
245
245
  });
246
246
 
247
247
  it('higher skinfolds → higher body fat %', () => {
@@ -357,7 +357,7 @@ describe('acuteChronicWorkloadRatio — risk-band classification', () => {
357
357
  describe('analyzeFitness', () => {
358
358
  it('returns all sub-results when all inputs provided', () => {
359
359
  const r = analyzeFitness({
360
- oneRM: { weightKg: 100, reps: 5 },
360
+ oneRM: { weightKg: 100, reps: 5 },
361
361
  vo2Max: { method: 'cooper', distanceM: 2400 },
362
362
  hrZones: { hrRest: 60, hrMax: 190 },
363
363
  calories: { met: 9.8, bodyMassKg: 70, durationMin: 30 },
@@ -57,9 +57,7 @@ describe('fitness-wellness -> HoloScript runtime integration (one_rep_max)', ()
57
57
  solved.push(e as Record<string, unknown>);
58
58
  });
59
59
 
60
- await runtime.executeNode(
61
- oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: TEST_REPS }) as never,
62
- );
60
+ await runtime.executeNode(oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: TEST_REPS }) as never);
63
61
  await flush();
64
62
 
65
63
  expect(solved).toHaveLength(1);
@@ -79,9 +77,7 @@ describe('fitness-wellness -> HoloScript runtime integration (one_rep_max)', ()
79
77
  const solved: unknown[] = [];
80
78
  runtime.on('one_rep_max_solved', (e: unknown) => solved.push(e));
81
79
 
82
- await runtime.executeNode(
83
- oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: TEST_REPS }) as never,
84
- );
80
+ await runtime.executeNode(oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: TEST_REPS }) as never);
85
81
  await flush();
86
82
 
87
83
  expect(solved).toHaveLength(0);
@@ -91,9 +87,7 @@ describe('fitness-wellness -> HoloScript runtime integration (one_rep_max)', ()
91
87
  const runtime = new HoloScriptRuntime();
92
88
  registerFitnessWellnessTraitHandlers(runtime);
93
89
 
94
- await runtime.executeNode(
95
- oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: TEST_REPS }) as never,
96
- );
90
+ await runtime.executeNode(oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: TEST_REPS }) as never);
97
91
  await flush();
98
92
 
99
93
  const state = runtime.getState() as Record<string, unknown>;
@@ -119,9 +113,7 @@ describe('fitness-wellness -> HoloScript runtime integration (one_rep_max)', ()
119
113
  // reps = 0 is invalid — the real solver throws "reps must be a positive
120
114
  // integer", which the handler's try/catch turns into a one_rep_max_error
121
115
  // rather than a throw through the runtime.
122
- await runtime.executeNode(
123
- oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: 0 }) as never,
124
- );
116
+ await runtime.executeNode(oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: 0 }) as never);
125
117
  await flush();
126
118
 
127
119
  expect(errors).toHaveLength(1);
@@ -120,15 +120,23 @@ export function oneRepMax(weightKg: number, reps: number): OneRepMaxResult {
120
120
  if (weightKg <= 0) throw new Error('weightKg must be positive');
121
121
  if (reps < 1 || !Number.isInteger(reps)) throw new Error('reps must be a positive integer');
122
122
  if (reps === 1) {
123
- return { weightKg, reps, epley: weightKg, brzycki: weightKg, lander: weightKg, lombardi: weightKg, average: weightKg };
123
+ return {
124
+ weightKg,
125
+ reps,
126
+ epley: weightKg,
127
+ brzycki: weightKg,
128
+ lander: weightKg,
129
+ lombardi: weightKg,
130
+ average: weightKg,
131
+ };
124
132
  }
125
133
  if (reps > 30) throw new Error('reps > 30 is outside the validated range');
126
134
 
127
- const epley = weightKg * (1 + reps / 30);
128
- const brzycki = weightKg * 36 / (37 - reps);
129
- const lander = 100 * weightKg / (101.3 - 2.67123 * reps);
130
- const lombardi = weightKg * Math.pow(reps, 0.10);
131
- const average = (epley + brzycki + lander + lombardi) / 4;
135
+ const epley = weightKg * (1 + reps / 30);
136
+ const brzycki = (weightKg * 36) / (37 - reps);
137
+ const lander = (100 * weightKg) / (101.3 - 2.67123 * reps);
138
+ const lombardi = weightKg * Math.pow(reps, 0.1);
139
+ const average = (epley + brzycki + lander + lombardi) / 4;
132
140
 
133
141
  return { weightKg, reps, epley, brzycki, lander, lombardi, average };
134
142
  }
@@ -142,7 +150,11 @@ export function oneRepMax(weightKg: number, reps: number): OneRepMaxResult {
142
150
  export function vo2MaxCooper(distanceMeters: number): VO2MaxResult {
143
151
  if (distanceMeters <= 0) throw new Error('distanceMeters must be positive');
144
152
  const vo2 = (distanceMeters - 504.9) / 44.73;
145
- return { vo2MaxMlKgMin: Math.max(0, vo2), method: 'cooper-12min', fitnessClass: classifyVO2(vo2) };
153
+ return {
154
+ vo2MaxMlKgMin: Math.max(0, vo2),
155
+ method: 'cooper-12min',
156
+ fitnessClass: classifyVO2(vo2),
157
+ };
146
158
  }
147
159
 
148
160
  /**
@@ -157,7 +169,7 @@ export function vo2MaxAstrand(
157
169
  workRateWatts: number,
158
170
  hrSteadyState: number,
159
171
  bodyMassKg: number,
160
- ageYears: number,
172
+ ageYears: number
161
173
  ): VO2MaxResult {
162
174
  if (workRateWatts <= 0) throw new Error('workRateWatts must be positive');
163
175
  if (hrSteadyState <= 0 || hrSteadyState > 250) throw new Error('hrSteadyState out of range');
@@ -172,7 +184,11 @@ export function vo2MaxAstrand(
172
184
  const correctionFactor = hrMax / hrSteadyState;
173
185
  const vo2Max = vo2Gross * correctionFactor * 0.836; // empirical scaling
174
186
 
175
- return { vo2MaxMlKgMin: Math.max(0, vo2Max), method: 'astrand-rhyming', fitnessClass: classifyVO2(vo2Max) };
187
+ return {
188
+ vo2MaxMlKgMin: Math.max(0, vo2Max),
189
+ method: 'astrand-rhyming',
190
+ fitnessClass: classifyVO2(vo2Max),
191
+ };
176
192
  }
177
193
 
178
194
  /**
@@ -184,15 +200,21 @@ export function vo2MaxNonExercise(
184
200
  ageYears: number,
185
201
  bmi: number,
186
202
  physicalActivityRating: number, // PA-R: 0-10 Likert scale
187
- isMale: boolean,
203
+ isMale: boolean
188
204
  ): VO2MaxResult {
189
205
  if (ageYears < 10 || ageYears > 100) throw new Error('ageYears out of range [10, 100]');
190
206
  if (bmi <= 10 || bmi > 70) throw new Error('BMI out of plausible range');
191
- if (physicalActivityRating < 0 || physicalActivityRating > 10) throw new Error('PA_R must be in [0, 10]');
207
+ if (physicalActivityRating < 0 || physicalActivityRating > 10)
208
+ throw new Error('PA_R must be in [0, 10]');
192
209
 
193
210
  const sex = isMale ? 1 : 0;
194
- const vo2 = 56.363 + 1.921 * physicalActivityRating - 0.381 * ageYears - 0.754 * bmi + 10.987 * sex;
195
- return { vo2MaxMlKgMin: Math.max(10, vo2), method: 'non-exercise', fitnessClass: classifyVO2(vo2) };
211
+ const vo2 =
212
+ 56.363 + 1.921 * physicalActivityRating - 0.381 * ageYears - 0.754 * bmi + 10.987 * sex;
213
+ return {
214
+ vo2MaxMlKgMin: Math.max(10, vo2),
215
+ method: 'non-exercise',
216
+ fitnessClass: classifyVO2(vo2),
217
+ };
196
218
  }
197
219
 
198
220
  function classifyVO2(vo2: number): VO2MaxResult['fitnessClass'] {
@@ -220,13 +242,15 @@ export function heartRateZones(hrRest: number, hrMax: number): HeartRateZoneResu
220
242
  });
221
243
 
222
244
  return {
223
- hrRest, hrMax, hrr,
245
+ hrRest,
246
+ hrMax,
247
+ hrr,
224
248
  zones: {
225
- zone1: { ...zone(0.50, 0.60), label: 'Recovery / Active Rest' },
226
- zone2: { ...zone(0.60, 0.70), label: 'Aerobic Base / Fat Burn' },
227
- zone3: { ...zone(0.70, 0.80), label: 'Aerobic / Tempo' },
228
- zone4: { ...zone(0.80, 0.90), label: 'Lactate Threshold' },
229
- zone5: { ...zone(0.90, 1.00), label: 'VO2max / Neuromuscular' },
249
+ zone1: { ...zone(0.5, 0.6), label: 'Recovery / Active Rest' },
250
+ zone2: { ...zone(0.6, 0.7), label: 'Aerobic Base / Fat Burn' },
251
+ zone3: { ...zone(0.7, 0.8), label: 'Aerobic / Tempo' },
252
+ zone4: { ...zone(0.8, 0.9), label: 'Lactate Threshold' },
253
+ zone5: { ...zone(0.9, 1.0), label: 'VO2max / Neuromuscular' },
230
254
  },
231
255
  };
232
256
  }
@@ -235,21 +259,21 @@ export function heartRateZones(hrRest: number, hrMax: number): HeartRateZoneResu
235
259
 
236
260
  /** Common activity METs from the Compendium of Physical Activities (2011) */
237
261
  export const ACTIVITY_METS: Record<string, number> = {
238
- running_6mph: 9.8,
239
- running_8mph: 11.8,
240
- running_10mph: 14.5,
241
- cycling_moderate: 8.0,
242
- cycling_vigorous: 12.0,
243
- swimming_moderate: 6.0,
244
- swimming_vigorous: 9.8,
245
- walking_3mph: 3.5,
246
- walking_4mph: 5.0,
247
- strength_training: 3.5,
248
- yoga: 2.5,
249
- rowing_moderate: 7.0,
250
- rowing_vigorous: 12.0,
251
- basketball: 8.0,
252
- soccer: 7.0,
262
+ running_6mph: 9.8,
263
+ running_8mph: 11.8,
264
+ running_10mph: 14.5,
265
+ cycling_moderate: 8.0,
266
+ cycling_vigorous: 12.0,
267
+ swimming_moderate: 6.0,
268
+ swimming_vigorous: 9.8,
269
+ walking_3mph: 3.5,
270
+ walking_4mph: 5.0,
271
+ strength_training: 3.5,
272
+ yoga: 2.5,
273
+ rowing_moderate: 7.0,
274
+ rowing_vigorous: 12.0,
275
+ basketball: 8.0,
276
+ soccer: 7.0,
253
277
  };
254
278
 
255
279
  /**
@@ -257,7 +281,11 @@ export const ACTIVITY_METS: Record<string, number> = {
257
281
  * Gross: MET × bodyMassKg × durationHours
258
282
  * Net: gross − BMR_rate × duration (approximate BMR ≈ 1 MET contribution)
259
283
  */
260
- export function calorieBurn(met: number, bodyMassKg: number, durationMin: number): CalorieBurnResult {
284
+ export function calorieBurn(
285
+ met: number,
286
+ bodyMassKg: number,
287
+ durationMin: number
288
+ ): CalorieBurnResult {
261
289
  if (met <= 0) throw new Error('MET must be positive');
262
290
  if (bodyMassKg <= 0) throw new Error('bodyMassKg must be positive');
263
291
  if (durationMin <= 0) throw new Error('durationMin must be positive');
@@ -284,9 +312,10 @@ export function jacksonPollockSkinfold(
284
312
  s3Mm: number,
285
313
  ageYears: number,
286
314
  isMale: boolean,
287
- bodyMassKg: number,
315
+ bodyMassKg: number
288
316
  ): BodyCompositionResult {
289
- if (s1Mm <= 0 || s2Mm <= 0 || s3Mm <= 0) throw new Error('Skinfold measurements must be positive');
317
+ if (s1Mm <= 0 || s2Mm <= 0 || s3Mm <= 0)
318
+ throw new Error('Skinfold measurements must be positive');
290
319
  if (ageYears < 18 || ageYears > 80) throw new Error('Age must be in [18, 80]');
291
320
  if (bodyMassKg <= 0) throw new Error('bodyMassKg must be positive');
292
321
 
@@ -304,8 +333,8 @@ export function jacksonPollockSkinfold(
304
333
  }
305
334
 
306
335
  // Siri equation
307
- const bodyFatPct = (4.95 / density - 4.50) * 100;
308
- const fatMassKg = bodyMassKg * bodyFatPct / 100;
336
+ const bodyFatPct = (4.95 / density - 4.5) * 100;
337
+ const fatMassKg = (bodyMassKg * bodyFatPct) / 100;
309
338
  const leanMassKg = bodyMassKg - fatMassKg;
310
339
 
311
340
  return { skinfoldSumMm: sum, bodyDensityGcc: density, bodyFatPct, fatMassKg, leanMassKg };
@@ -356,12 +385,26 @@ export function acuteChronicWorkloadRatio(workloads: number[]): TrainingLoadResu
356
385
 
357
386
  export interface FitnessAnalysisInput {
358
387
  oneRM?: { weightKg: number; reps: number };
359
- vo2Max?: { method: 'cooper'; distanceM: number } |
360
- { method: 'astrand'; workRateW: number; hrSteady: number; bodyMassKg: number; ageYears: number } |
361
- { method: 'non-exercise'; ageYears: number; bmi: number; paRating: number; isMale: boolean };
388
+ vo2Max?:
389
+ | { method: 'cooper'; distanceM: number }
390
+ | {
391
+ method: 'astrand';
392
+ workRateW: number;
393
+ hrSteady: number;
394
+ bodyMassKg: number;
395
+ ageYears: number;
396
+ }
397
+ | { method: 'non-exercise'; ageYears: number; bmi: number; paRating: number; isMale: boolean };
362
398
  hrZones?: { hrRest: number; hrMax: number };
363
399
  calories?: { met: number; bodyMassKg: number; durationMin: number };
364
- bodyComp?: { s1: number; s2: number; s3: number; ageYears: number; isMale: boolean; bodyMassKg: number };
400
+ bodyComp?: {
401
+ s1: number;
402
+ s2: number;
403
+ s3: number;
404
+ ageYears: number;
405
+ isMale: boolean;
406
+ bodyMassKg: number;
407
+ };
365
408
  trainingLoad?: { workloads: number[] };
366
409
  }
367
410
 
@@ -383,17 +426,24 @@ export function analyzeFitness(input: FitnessAnalysisInput): FitnessAnalysisResu
383
426
  if (input.vo2Max) {
384
427
  const v = input.vo2Max;
385
428
  if (v.method === 'cooper') result.vo2Max = vo2MaxCooper(v.distanceM);
386
- else if (v.method === 'astrand') result.vo2Max = vo2MaxAstrand(v.workRateW, v.hrSteady, v.bodyMassKg, v.ageYears);
429
+ else if (v.method === 'astrand')
430
+ result.vo2Max = vo2MaxAstrand(v.workRateW, v.hrSteady, v.bodyMassKg, v.ageYears);
387
431
  else result.vo2Max = vo2MaxNonExercise(v.ageYears, v.bmi, v.paRating, v.isMale);
388
432
  }
389
433
 
390
434
  if (input.hrZones) result.hrZones = heartRateZones(input.hrZones.hrRest, input.hrZones.hrMax);
391
- if (input.calories) result.calories = calorieBurn(input.calories.met, input.calories.bodyMassKg, input.calories.durationMin);
435
+ if (input.calories)
436
+ result.calories = calorieBurn(
437
+ input.calories.met,
438
+ input.calories.bodyMassKg,
439
+ input.calories.durationMin
440
+ );
392
441
  if (input.bodyComp) {
393
442
  const b = input.bodyComp;
394
443
  result.bodyComp = jacksonPollockSkinfold(b.s1, b.s2, b.s3, b.ageYears, b.isMale, b.bodyMassKg);
395
444
  }
396
- if (input.trainingLoad) result.trainingLoad = acuteChronicWorkloadRatio(input.trainingLoad.workloads);
445
+ if (input.trainingLoad)
446
+ result.trainingLoad = acuteChronicWorkloadRatio(input.trainingLoad.workloads);
397
447
 
398
448
  return result;
399
449
  }
@@ -402,15 +452,21 @@ export function analyzeFitness(input: FitnessAnalysisInput): FitnessAnalysisResu
402
452
 
403
453
  export function buildFitnessReceipt(
404
454
  result: FitnessAnalysisResult,
405
- options?: FitnessReceiptOptions,
455
+ options?: FitnessReceiptOptions
406
456
  ): DomainSimulationReceipt {
407
457
  const violations: Array<{ criterion: string; message: string }> = [];
408
458
 
409
459
  if (result.bodyComp && (result.bodyComp.bodyFatPct < 3 || result.bodyComp.bodyFatPct > 60)) {
410
- violations.push({ criterion: 'body_fat', message: `body fat ${result.bodyComp.bodyFatPct.toFixed(1)}% outside plausible range [3%, 60%]` });
460
+ violations.push({
461
+ criterion: 'body_fat',
462
+ message: `body fat ${result.bodyComp.bodyFatPct.toFixed(1)}% outside plausible range [3%, 60%]`,
463
+ });
411
464
  }
412
465
  if (result.trainingLoad && result.trainingLoad.riskCategory === 'very-high') {
413
- violations.push({ criterion: 'acwr', message: `ACWR ${result.trainingLoad.acwr.toFixed(2)} is very high (>1.5) — injury risk elevated` });
466
+ violations.push({
467
+ criterion: 'acwr',
468
+ message: `ACWR ${result.trainingLoad.acwr.toFixed(2)} is very high (>1.5) — injury risk elevated`,
469
+ });
414
470
  }
415
471
 
416
472
  return buildDomainSimulationReceipt({
@@ -425,7 +481,11 @@ export function buildFitnessReceipt(
425
481
  bodyFatPct: result.bodyComp?.bodyFatPct ?? null,
426
482
  acwr: result.trainingLoad?.acwr ?? null,
427
483
  },
428
- cael: { version: 'cael.v1', event: 'fitness_wellness.fitness_analysis', solverType: 'fitness-wellness.analytics' },
484
+ cael: {
485
+ version: 'cael.v1',
486
+ event: 'fitness_wellness.fitness_analysis',
487
+ solverType: 'fitness-wellness.analytics',
488
+ },
429
489
  acceptance: { accepted: violations.length === 0, violations },
430
490
  });
431
491
  }
package/src/index.ts CHANGED
@@ -1,7 +1,21 @@
1
- export { createWorkoutHandler, type WorkoutConfig, type WorkoutType, type ExerciseSet } from './traits/WorkoutTrait';
1
+ export {
2
+ createWorkoutHandler,
3
+ type WorkoutConfig,
4
+ type WorkoutType,
5
+ type ExerciseSet,
6
+ } from './traits/WorkoutTrait';
2
7
  export { createRepCounterHandler, type RepCounterConfig } from './traits/RepCounterTrait';
3
- export { createExerciseLibraryHandler, type ExerciseLibraryConfig, type Exercise, type MuscleGroup } from './traits/ExerciseLibraryTrait';
4
- export { createProgressTrackerHandler, type ProgressTrackerConfig, type ProgressEntry } from './traits/ProgressTrackerTrait';
8
+ export {
9
+ createExerciseLibraryHandler,
10
+ type ExerciseLibraryConfig,
11
+ type Exercise,
12
+ type MuscleGroup,
13
+ } from './traits/ExerciseLibraryTrait';
14
+ export {
15
+ createProgressTrackerHandler,
16
+ type ProgressTrackerConfig,
17
+ type ProgressEntry,
18
+ } from './traits/ProgressTrackerTrait';
5
19
  export * from './traits/types';
6
20
 
7
21
  import { createWorkoutHandler } from './traits/WorkoutTrait';
@@ -25,5 +39,25 @@ export {
25
39
  type TraitRegistrar,
26
40
  } from './runtime';
27
41
 
28
- export const pluginMeta = { name: '@holoscript/plugin-fitness-wellness', version: '1.0.0', traits: ['workout', 'rep_counter', 'exercise_library', 'progress_tracker', 'one_rep_max', 'vo2max', 'hr_zones', 'calorie_burn', 'body_composition', 'training_load_acwr'] };
29
- export const traitHandlers = [createWorkoutHandler(), createRepCounterHandler(), createExerciseLibraryHandler(), createProgressTrackerHandler()];
42
+ export const pluginMeta = {
43
+ name: '@holoscript/plugin-fitness-wellness',
44
+ version: '1.0.0',
45
+ traits: [
46
+ 'workout',
47
+ 'rep_counter',
48
+ 'exercise_library',
49
+ 'progress_tracker',
50
+ 'one_rep_max',
51
+ 'vo2max',
52
+ 'hr_zones',
53
+ 'calorie_burn',
54
+ 'body_composition',
55
+ 'training_load_acwr',
56
+ ],
57
+ };
58
+ export const traitHandlers = [
59
+ createWorkoutHandler(),
60
+ createRepCounterHandler(),
61
+ createExerciseLibraryHandler(),
62
+ createProgressTrackerHandler(),
63
+ ];
package/src/runtime.ts CHANGED
@@ -66,7 +66,7 @@ export interface RuntimeTraitHandler {
66
66
  node: unknown,
67
67
  config: OneRepMaxTraitConfig,
68
68
  context: TraitDispatchContext,
69
- delta: number,
69
+ delta: number
70
70
  ) => void;
71
71
  }
72
72
 
@@ -81,7 +81,7 @@ interface OneRepMaxNode {
81
81
  function solveOntoNode(
82
82
  node: unknown,
83
83
  config: OneRepMaxTraitConfig | undefined,
84
- context: TraitDispatchContext,
84
+ context: TraitDispatchContext
85
85
  ): void {
86
86
  const carrier = node as OneRepMaxNode;
87
87
  const nodeId = carrier.id ?? carrier.name ?? 'unknown';
@@ -91,8 +91,7 @@ function solveOntoNode(
91
91
  if (typeof weightKg !== 'number' || typeof reps !== 'number') {
92
92
  context.emit('one_rep_max_error', {
93
93
  nodeId,
94
- error:
95
- 'one_rep_max trait requires config.weightKg (number) and config.reps (number)',
94
+ error: 'one_rep_max trait requires config.weightKg (number) and config.reps (number)',
96
95
  });
97
96
  return;
98
97
  }
@@ -1,20 +1,62 @@
1
1
  /** @exercise_library Trait — Exercise database. @trait exercise_library */
2
2
  import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
3
 
4
- export type MuscleGroup = 'chest' | 'back' | 'shoulders' | 'biceps' | 'triceps' | 'quadriceps' | 'hamstrings' | 'glutes' | 'calves' | 'core' | 'full_body';
5
- export interface Exercise { id: string; name: string; primaryMuscle: MuscleGroup; secondaryMuscles: MuscleGroup[]; equipment: string[]; instructions: string; videoUrl?: string; }
6
- export interface ExerciseLibraryConfig { exercises: Exercise[]; categories: string[]; }
4
+ export type MuscleGroup =
5
+ | 'chest'
6
+ | 'back'
7
+ | 'shoulders'
8
+ | 'biceps'
9
+ | 'triceps'
10
+ | 'quadriceps'
11
+ | 'hamstrings'
12
+ | 'glutes'
13
+ | 'calves'
14
+ | 'core'
15
+ | 'full_body';
16
+ export interface Exercise {
17
+ id: string;
18
+ name: string;
19
+ primaryMuscle: MuscleGroup;
20
+ secondaryMuscles: MuscleGroup[];
21
+ equipment: string[];
22
+ instructions: string;
23
+ videoUrl?: string;
24
+ }
25
+ export interface ExerciseLibraryConfig {
26
+ exercises: Exercise[];
27
+ categories: string[];
28
+ }
7
29
 
8
30
  const defaultConfig: ExerciseLibraryConfig = { exercises: [], categories: [] };
9
31
 
10
32
  export function createExerciseLibraryHandler(): TraitHandler<ExerciseLibraryConfig> {
11
- return { name: 'exercise_library', defaultConfig,
12
- onAttach(n: HSPlusNode, c: ExerciseLibraryConfig, ctx: TraitContext) { n.__libState = { loaded: c.exercises.length }; ctx.emit?.('library:loaded', { exercises: c.exercises.length }); },
13
- onDetach(n: HSPlusNode, _c: ExerciseLibraryConfig, ctx: TraitContext) { delete n.__libState; ctx.emit?.('library:unloaded'); },
33
+ return {
34
+ name: 'exercise_library',
35
+ defaultConfig,
36
+ onAttach(n: HSPlusNode, c: ExerciseLibraryConfig, ctx: TraitContext) {
37
+ n.__libState = { loaded: c.exercises.length };
38
+ ctx.emit?.('library:loaded', { exercises: c.exercises.length });
39
+ },
40
+ onDetach(n: HSPlusNode, _c: ExerciseLibraryConfig, ctx: TraitContext) {
41
+ delete n.__libState;
42
+ ctx.emit?.('library:unloaded');
43
+ },
14
44
  onUpdate() {},
15
45
  onEvent(_n: HSPlusNode, c: ExerciseLibraryConfig, ctx: TraitContext, e: TraitEvent) {
16
- if (e.type === 'library:search') { const muscle = e.payload?.muscle as MuscleGroup; const results = c.exercises.filter(ex => ex.primaryMuscle === muscle || ex.secondaryMuscles.includes(muscle)); ctx.emit?.('library:results', { count: results.length, exercises: results.map(r => r.name) }); }
17
- if (e.type === 'library:random') { const ex = c.exercises[Math.floor(Math.random() * c.exercises.length)]; if (ex) ctx.emit?.('library:suggestion', { exercise: ex.name, muscle: ex.primaryMuscle }); }
46
+ if (e.type === 'library:search') {
47
+ const muscle = e.payload?.muscle as MuscleGroup;
48
+ const results = c.exercises.filter(
49
+ (ex) => ex.primaryMuscle === muscle || ex.secondaryMuscles.includes(muscle)
50
+ );
51
+ ctx.emit?.('library:results', {
52
+ count: results.length,
53
+ exercises: results.map((r) => r.name),
54
+ });
55
+ }
56
+ if (e.type === 'library:random') {
57
+ const ex = c.exercises[Math.floor(Math.random() * c.exercises.length)];
58
+ if (ex) ctx.emit?.('library:suggestion', { exercise: ex.name, muscle: ex.primaryMuscle });
59
+ }
18
60
  },
19
61
  };
20
62
  }
@@ -1,28 +1,67 @@
1
1
  /** @progress_tracker Trait — Fitness progress over time. @trait progress_tracker */
2
2
  import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
3
 
4
- export interface ProgressEntry { date: string; metric: string; value: number; unit: string; }
5
- export interface ProgressTrackerConfig { userId: string; metrics: string[]; goalValues: Record<string, number>; trackingPeriodDays: number; }
6
- export interface ProgressTrackerState { entries: ProgressEntry[]; streakDays: number; lastLogDate: string | null; goalsReached: string[]; }
4
+ export interface ProgressEntry {
5
+ date: string;
6
+ metric: string;
7
+ value: number;
8
+ unit: string;
9
+ }
10
+ export interface ProgressTrackerConfig {
11
+ userId: string;
12
+ metrics: string[];
13
+ goalValues: Record<string, number>;
14
+ trackingPeriodDays: number;
15
+ }
16
+ export interface ProgressTrackerState {
17
+ entries: ProgressEntry[];
18
+ streakDays: number;
19
+ lastLogDate: string | null;
20
+ goalsReached: string[];
21
+ }
7
22
 
8
- const defaultConfig: ProgressTrackerConfig = { userId: '', metrics: ['weight', 'body_fat', 'bench_press_1rm'], goalValues: {}, trackingPeriodDays: 90 };
23
+ const defaultConfig: ProgressTrackerConfig = {
24
+ userId: '',
25
+ metrics: ['weight', 'body_fat', 'bench_press_1rm'],
26
+ goalValues: {},
27
+ trackingPeriodDays: 90,
28
+ };
9
29
 
10
30
  export function createProgressTrackerHandler(): TraitHandler<ProgressTrackerConfig> {
11
- return { name: 'progress_tracker', defaultConfig,
12
- onAttach(n: HSPlusNode, _c: ProgressTrackerConfig, ctx: TraitContext) { n.__progressState = { entries: [], streakDays: 0, lastLogDate: null, goalsReached: [] }; ctx.emit?.('progress:initialized'); },
13
- onDetach(n: HSPlusNode, _c: ProgressTrackerConfig, ctx: TraitContext) { delete n.__progressState; ctx.emit?.('progress:removed'); },
31
+ return {
32
+ name: 'progress_tracker',
33
+ defaultConfig,
34
+ onAttach(n: HSPlusNode, _c: ProgressTrackerConfig, ctx: TraitContext) {
35
+ n.__progressState = { entries: [], streakDays: 0, lastLogDate: null, goalsReached: [] };
36
+ ctx.emit?.('progress:initialized');
37
+ },
38
+ onDetach(n: HSPlusNode, _c: ProgressTrackerConfig, ctx: TraitContext) {
39
+ delete n.__progressState;
40
+ ctx.emit?.('progress:removed');
41
+ },
14
42
  onUpdate() {},
15
43
  onEvent(n: HSPlusNode, c: ProgressTrackerConfig, ctx: TraitContext, e: TraitEvent) {
16
- const s = n.__progressState as ProgressTrackerState | undefined; if (!s) return;
44
+ const s = n.__progressState as ProgressTrackerState | undefined;
45
+ if (!s) return;
17
46
  if (e.type === 'progress:log') {
18
- const entry: ProgressEntry = { date: new Date().toISOString().split('T')[0], metric: (e.payload?.metric as string) ?? '', value: (e.payload?.value as number) ?? 0, unit: (e.payload?.unit as string) ?? '' };
47
+ const entry: ProgressEntry = {
48
+ date: new Date().toISOString().split('T')[0],
49
+ metric: (e.payload?.metric as string) ?? '',
50
+ value: (e.payload?.value as number) ?? 0,
51
+ unit: (e.payload?.unit as string) ?? '',
52
+ };
19
53
  s.entries.push(entry);
20
54
  const today = entry.date;
21
- if (s.lastLogDate && s.lastLogDate !== today) { const diff = (new Date(today).getTime() - new Date(s.lastLogDate).getTime()) / 86400000; s.streakDays = diff <= 1 ? s.streakDays + 1 : 1; }
22
- else if (!s.lastLogDate) s.streakDays = 1;
55
+ if (s.lastLogDate && s.lastLogDate !== today) {
56
+ const diff = (new Date(today).getTime() - new Date(s.lastLogDate).getTime()) / 86400000;
57
+ s.streakDays = diff <= 1 ? s.streakDays + 1 : 1;
58
+ } else if (!s.lastLogDate) s.streakDays = 1;
23
59
  s.lastLogDate = today;
24
60
  const goal = c.goalValues[entry.metric];
25
- if (goal !== undefined && entry.value >= goal && !s.goalsReached.includes(entry.metric)) { s.goalsReached.push(entry.metric); ctx.emit?.('progress:goal_reached', { metric: entry.metric, value: entry.value }); }
61
+ if (goal !== undefined && entry.value >= goal && !s.goalsReached.includes(entry.metric)) {
62
+ s.goalsReached.push(entry.metric);
63
+ ctx.emit?.('progress:goal_reached', { metric: entry.metric, value: entry.value });
64
+ }
26
65
  ctx.emit?.('progress:logged', { metric: entry.metric, streak: s.streakDays });
27
66
  }
28
67
  },
@@ -1,25 +1,65 @@
1
1
  /** @rep_counter Trait — Repetition counting and form tracking. @trait rep_counter */
2
2
  import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
3
 
4
- export interface RepCounterConfig { targetReps: number; exerciseName: string; formCheckEnabled: boolean; tempoUp: number; tempoDown: number; }
5
- export interface RepCounterState { currentReps: number; goodFormReps: number; badFormReps: number; isTracking: boolean; }
4
+ export interface RepCounterConfig {
5
+ targetReps: number;
6
+ exerciseName: string;
7
+ formCheckEnabled: boolean;
8
+ tempoUp: number;
9
+ tempoDown: number;
10
+ }
11
+ export interface RepCounterState {
12
+ currentReps: number;
13
+ goodFormReps: number;
14
+ badFormReps: number;
15
+ isTracking: boolean;
16
+ }
6
17
 
7
- const defaultConfig: RepCounterConfig = { targetReps: 10, exerciseName: '', formCheckEnabled: true, tempoUp: 2, tempoDown: 3 };
18
+ const defaultConfig: RepCounterConfig = {
19
+ targetReps: 10,
20
+ exerciseName: '',
21
+ formCheckEnabled: true,
22
+ tempoUp: 2,
23
+ tempoDown: 3,
24
+ };
8
25
 
9
26
  export function createRepCounterHandler(): TraitHandler<RepCounterConfig> {
10
- return { name: 'rep_counter', defaultConfig,
11
- onAttach(n: HSPlusNode, _c: RepCounterConfig, ctx: TraitContext) { n.__repState = { currentReps: 0, goodFormReps: 0, badFormReps: 0, isTracking: false }; ctx.emit?.('rep:ready'); },
12
- onDetach(n: HSPlusNode, _c: RepCounterConfig, ctx: TraitContext) { delete n.__repState; ctx.emit?.('rep:stopped'); },
27
+ return {
28
+ name: 'rep_counter',
29
+ defaultConfig,
30
+ onAttach(n: HSPlusNode, _c: RepCounterConfig, ctx: TraitContext) {
31
+ n.__repState = { currentReps: 0, goodFormReps: 0, badFormReps: 0, isTracking: false };
32
+ ctx.emit?.('rep:ready');
33
+ },
34
+ onDetach(n: HSPlusNode, _c: RepCounterConfig, ctx: TraitContext) {
35
+ delete n.__repState;
36
+ ctx.emit?.('rep:stopped');
37
+ },
13
38
  onUpdate() {},
14
39
  onEvent(n: HSPlusNode, c: RepCounterConfig, ctx: TraitContext, e: TraitEvent) {
15
- const s = n.__repState as RepCounterState | undefined; if (!s) return;
16
- if (e.type === 'rep:start') { s.isTracking = true; s.currentReps = 0; s.goodFormReps = 0; s.badFormReps = 0; ctx.emit?.('rep:tracking'); }
40
+ const s = n.__repState as RepCounterState | undefined;
41
+ if (!s) return;
42
+ if (e.type === 'rep:start') {
43
+ s.isTracking = true;
44
+ s.currentReps = 0;
45
+ s.goodFormReps = 0;
46
+ s.badFormReps = 0;
47
+ ctx.emit?.('rep:tracking');
48
+ }
17
49
  if (e.type === 'rep:count' && s.isTracking) {
18
50
  s.currentReps++;
19
51
  const goodForm = (e.payload?.goodForm as boolean) ?? true;
20
- if (goodForm) s.goodFormReps++; else s.badFormReps++;
21
- ctx.emit?.('rep:counted', { reps: s.currentReps, target: c.targetReps, formRate: s.goodFormReps / s.currentReps });
22
- if (s.currentReps >= c.targetReps) { s.isTracking = false; ctx.emit?.('rep:target_reached', { total: s.currentReps, goodForm: s.goodFormReps }); }
52
+ if (goodForm) s.goodFormReps++;
53
+ else s.badFormReps++;
54
+ ctx.emit?.('rep:counted', {
55
+ reps: s.currentReps,
56
+ target: c.targetReps,
57
+ formRate: s.goodFormReps / s.currentReps,
58
+ });
59
+ if (s.currentReps >= c.targetReps) {
60
+ s.isTracking = false;
61
+ ctx.emit?.('rep:target_reached', { total: s.currentReps, goodForm: s.goodFormReps });
62
+ }
23
63
  }
24
64
  },
25
65
  };
@@ -1,23 +1,96 @@
1
1
  /** @workout Trait — Workout session definition. @trait workout */
2
2
  import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
3
 
4
- export type WorkoutType = 'strength' | 'cardio' | 'hiit' | 'yoga' | 'pilates' | 'crossfit' | 'swimming' | 'cycling' | 'running';
5
- export interface ExerciseSet { exerciseId: string; reps: number; weightKg?: number; durationS?: number; restS: number; }
6
- export interface WorkoutConfig { name: string; type: WorkoutType; exercises: ExerciseSet[]; estimatedDurationMin: number; difficulty: 'beginner' | 'intermediate' | 'advanced'; calorieTarget?: number; }
7
- export interface WorkoutState { isActive: boolean; currentExercise: number; elapsedS: number; caloriesBurned: number; completedSets: number; }
4
+ export type WorkoutType =
5
+ | 'strength'
6
+ | 'cardio'
7
+ | 'hiit'
8
+ | 'yoga'
9
+ | 'pilates'
10
+ | 'crossfit'
11
+ | 'swimming'
12
+ | 'cycling'
13
+ | 'running';
14
+ export interface ExerciseSet {
15
+ exerciseId: string;
16
+ reps: number;
17
+ weightKg?: number;
18
+ durationS?: number;
19
+ restS: number;
20
+ }
21
+ export interface WorkoutConfig {
22
+ name: string;
23
+ type: WorkoutType;
24
+ exercises: ExerciseSet[];
25
+ estimatedDurationMin: number;
26
+ difficulty: 'beginner' | 'intermediate' | 'advanced';
27
+ calorieTarget?: number;
28
+ }
29
+ export interface WorkoutState {
30
+ isActive: boolean;
31
+ currentExercise: number;
32
+ elapsedS: number;
33
+ caloriesBurned: number;
34
+ completedSets: number;
35
+ }
8
36
 
9
- const defaultConfig: WorkoutConfig = { name: '', type: 'strength', exercises: [], estimatedDurationMin: 30, difficulty: 'intermediate' };
37
+ const defaultConfig: WorkoutConfig = {
38
+ name: '',
39
+ type: 'strength',
40
+ exercises: [],
41
+ estimatedDurationMin: 30,
42
+ difficulty: 'intermediate',
43
+ };
10
44
 
11
45
  export function createWorkoutHandler(): TraitHandler<WorkoutConfig> {
12
- return { name: 'workout', defaultConfig,
13
- onAttach(n: HSPlusNode, _c: WorkoutConfig, ctx: TraitContext) { n.__workoutState = { isActive: false, currentExercise: 0, elapsedS: 0, caloriesBurned: 0, completedSets: 0 }; ctx.emit?.('workout:ready'); },
14
- onDetach(n: HSPlusNode, _c: WorkoutConfig, ctx: TraitContext) { delete n.__workoutState; ctx.emit?.('workout:ended'); },
15
- onUpdate(n: HSPlusNode, _c: WorkoutConfig, _ctx: TraitContext, delta: number) { const s = n.__workoutState as WorkoutState | undefined; if (s?.isActive) { s.elapsedS += delta / 1000; s.caloriesBurned += (delta / 1000) * 0.15; } },
46
+ return {
47
+ name: 'workout',
48
+ defaultConfig,
49
+ onAttach(n: HSPlusNode, _c: WorkoutConfig, ctx: TraitContext) {
50
+ n.__workoutState = {
51
+ isActive: false,
52
+ currentExercise: 0,
53
+ elapsedS: 0,
54
+ caloriesBurned: 0,
55
+ completedSets: 0,
56
+ };
57
+ ctx.emit?.('workout:ready');
58
+ },
59
+ onDetach(n: HSPlusNode, _c: WorkoutConfig, ctx: TraitContext) {
60
+ delete n.__workoutState;
61
+ ctx.emit?.('workout:ended');
62
+ },
63
+ onUpdate(n: HSPlusNode, _c: WorkoutConfig, _ctx: TraitContext, delta: number) {
64
+ const s = n.__workoutState as WorkoutState | undefined;
65
+ if (s?.isActive) {
66
+ s.elapsedS += delta / 1000;
67
+ s.caloriesBurned += (delta / 1000) * 0.15;
68
+ }
69
+ },
16
70
  onEvent(n: HSPlusNode, c: WorkoutConfig, ctx: TraitContext, e: TraitEvent) {
17
- const s = n.__workoutState as WorkoutState | undefined; if (!s) return;
18
- if (e.type === 'workout:start') { s.isActive = true; ctx.emit?.('workout:started', { type: c.type }); }
19
- if (e.type === 'workout:complete_set') { s.completedSets++; if (s.completedSets >= c.exercises.length) { s.isActive = false; ctx.emit?.('workout:completed', { calories: Math.round(s.caloriesBurned), duration: Math.round(s.elapsedS) }); } else { s.currentExercise++; ctx.emit?.('workout:next_exercise', { index: s.currentExercise }); } }
20
- if (e.type === 'workout:pause') { s.isActive = false; ctx.emit?.('workout:paused'); }
71
+ const s = n.__workoutState as WorkoutState | undefined;
72
+ if (!s) return;
73
+ if (e.type === 'workout:start') {
74
+ s.isActive = true;
75
+ ctx.emit?.('workout:started', { type: c.type });
76
+ }
77
+ if (e.type === 'workout:complete_set') {
78
+ s.completedSets++;
79
+ if (s.completedSets >= c.exercises.length) {
80
+ s.isActive = false;
81
+ ctx.emit?.('workout:completed', {
82
+ calories: Math.round(s.caloriesBurned),
83
+ duration: Math.round(s.elapsedS),
84
+ });
85
+ } else {
86
+ s.currentExercise++;
87
+ ctx.emit?.('workout:next_exercise', { index: s.currentExercise });
88
+ }
89
+ }
90
+ if (e.type === 'workout:pause') {
91
+ s.isActive = false;
92
+ ctx.emit?.('workout:paused');
93
+ }
21
94
  },
22
95
  };
23
96
  }
@@ -1,4 +1,22 @@
1
- export interface HSPlusNode { id?: string; properties?: Record<string, unknown>; [key: string]: unknown; }
2
- export interface TraitContext { emit?: (event: string, payload?: unknown) => void; [key: string]: unknown; }
3
- export interface TraitEvent { type: string; payload?: Record<string, unknown>; [key: string]: unknown; }
4
- export interface TraitHandler<T = unknown> { name: string; defaultConfig: T; onAttach(n: HSPlusNode, c: T, ctx: TraitContext): void; onDetach(n: HSPlusNode, c: T, ctx: TraitContext): void; onUpdate(n: HSPlusNode, c: T, ctx: TraitContext, d: number): void; onEvent(n: HSPlusNode, c: T, ctx: TraitContext, e: TraitEvent): void; }
1
+ export interface HSPlusNode {
2
+ id?: string;
3
+ properties?: Record<string, unknown>;
4
+ [key: string]: unknown;
5
+ }
6
+ export interface TraitContext {
7
+ emit?: (event: string, payload?: unknown) => void;
8
+ [key: string]: unknown;
9
+ }
10
+ export interface TraitEvent {
11
+ type: string;
12
+ payload?: Record<string, unknown>;
13
+ [key: string]: unknown;
14
+ }
15
+ export interface TraitHandler<T = unknown> {
16
+ name: string;
17
+ defaultConfig: T;
18
+ onAttach(n: HSPlusNode, c: T, ctx: TraitContext): void;
19
+ onDetach(n: HSPlusNode, c: T, ctx: TraitContext): void;
20
+ onUpdate(n: HSPlusNode, c: T, ctx: TraitContext, d: number): void;
21
+ onEvent(n: HSPlusNode, c: T, ctx: TraitContext, e: TraitEvent): void;
22
+ }
package/tsconfig.json CHANGED
@@ -1 +1,5 @@
1
- { "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "declaration": true }, "include": ["src"] }
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": { "outDir": "dist", "rootDir": "src", "declaration": true },
4
+ "include": ["src"]
5
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2025-2026 HoloScript Contributors
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.