@holoscript/plugin-fitness-wellness 2.0.1

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.
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Fitness & wellness solvers — fitness-wellness-plugin
3
+ *
4
+ * Implements:
5
+ * - VO2max estimation (Fick equation, Cooper test, Åstrand-Rhyming cycle)
6
+ * - 1-Rep Max prediction (Epley, Brzycki, Lander, Lombardi formulas)
7
+ * - MET-based calorie expenditure
8
+ * - Heart rate zones (Karvonen method)
9
+ * - RPE-to-intensity mapping (Borg 6-20 scale)
10
+ * - Training load (ACWR — acute:chronic workload ratio)
11
+ * - Body composition (Jackson-Pollock 3-site skinfold)
12
+ *
13
+ * References:
14
+ * - Epley B (1985) Poundage Chart. Boyd Epley Workout
15
+ * - Karvonen M et al. (1957) Ann.Med.Exp.Biol.Fenn 35:307-315
16
+ * - Jackson AS, Pollock ML (1978) Br.J.Nutr 40:497-504
17
+ * - ACSM Guidelines for Exercise Testing and Prescription (10th ed.)
18
+ */
19
+
20
+ import { buildDomainSimulationReceipt, type DomainSimulationReceipt } from '@holoscript/core';
21
+
22
+ // ─── Types ────────────────────────────────────────────────────────────────────
23
+
24
+ export interface OneRepMaxResult {
25
+ /** Measured weight lifted */
26
+ weightKg: number;
27
+ /** Number of reps performed */
28
+ reps: number;
29
+ /** Epley: w × (1 + r/30) */
30
+ epley: number;
31
+ /** Brzycki: w × 36/(37 − r) */
32
+ brzycki: number;
33
+ /** Lander: 100w / (101.3 − 2.67123r) */
34
+ lander: number;
35
+ /** Lombardi: w × r^0.10 */
36
+ lombardi: number;
37
+ /** Average of all four formulas */
38
+ average: number;
39
+ }
40
+
41
+ export interface VO2MaxResult {
42
+ /** Estimated VO2max ml/kg/min */
43
+ vo2MaxMlKgMin: number;
44
+ /** Method used */
45
+ method: 'cooper-12min' | 'astrand-rhyming' | 'fick' | 'non-exercise';
46
+ /** Fitness classification (ACSM norms) */
47
+ fitnessClass: 'poor' | 'fair' | 'good' | 'excellent' | 'superior';
48
+ }
49
+
50
+ export interface HeartRateZoneResult {
51
+ /** Resting heart rate bpm */
52
+ hrRest: number;
53
+ /** Maximum heart rate bpm */
54
+ hrMax: number;
55
+ /** Heart rate reserve (Karvonen) bpm */
56
+ hrr: number;
57
+ zones: {
58
+ zone1: { lo: number; hi: number; label: string };
59
+ zone2: { lo: number; hi: number; label: string };
60
+ zone3: { lo: number; hi: number; label: string };
61
+ zone4: { lo: number; hi: number; label: string };
62
+ zone5: { lo: number; hi: number; label: string };
63
+ };
64
+ }
65
+
66
+ export interface CalorieBurnResult {
67
+ /** MET value for the activity */
68
+ met: number;
69
+ /** Body mass kg */
70
+ bodyMassKg: number;
71
+ /** Duration minutes */
72
+ durationMin: number;
73
+ /** Gross calorie expenditure kcal (MET × kg × hours) */
74
+ grossKcal: number;
75
+ /** Net calorie expenditure kcal (gross − BMR contribution) */
76
+ netKcal: number;
77
+ }
78
+
79
+ export interface BodyCompositionResult {
80
+ /** Sum of 3 skinfold measurements mm */
81
+ skinfoldSumMm: number;
82
+ /** Body density g/cc (Jackson-Pollock equation) */
83
+ bodyDensityGcc: number;
84
+ /** Body fat percentage (Siri equation) */
85
+ bodyFatPct: number;
86
+ /** Fat mass kg */
87
+ fatMassKg: number;
88
+ /** Lean mass kg */
89
+ leanMassKg: number;
90
+ }
91
+
92
+ export interface TrainingLoadResult {
93
+ /** Acute workload (7-day rolling average AU) */
94
+ acuteLoad: number;
95
+ /** Chronic workload (28-day rolling average AU) */
96
+ chronicLoad: number;
97
+ /** ACWR = acute / chronic */
98
+ acwr: number;
99
+ /**
100
+ * Injury-risk category per Gabbett (2016) ACWR bands:
101
+ * - 'high' → ACWR < 0.8 (under-prepared / detraining)
102
+ * - 'low' → 0.8 ≤ ACWR ≤ 1.3 (sweet spot)
103
+ * - 'moderate' → 1.3 < ACWR ≤ 1.5 (ramp-up)
104
+ * - 'very-high' → ACWR > 1.5 (danger zone)
105
+ */
106
+ riskCategory: 'low' | 'moderate' | 'high' | 'very-high';
107
+ }
108
+
109
+ export interface FitnessReceiptOptions {
110
+ runId?: string;
111
+ }
112
+
113
+ // ─── 1-Rep Max prediction ─────────────────────────────────────────────────────
114
+
115
+ /**
116
+ * Predict 1-rep maximum using four validated formulas.
117
+ * Accurate for reps ≤ 10; less reliable for > 15 reps.
118
+ */
119
+ export function oneRepMax(weightKg: number, reps: number): OneRepMaxResult {
120
+ if (weightKg <= 0) throw new Error('weightKg must be positive');
121
+ if (reps < 1 || !Number.isInteger(reps)) throw new Error('reps must be a positive integer');
122
+ if (reps === 1) {
123
+ return { weightKg, reps, epley: weightKg, brzycki: weightKg, lander: weightKg, lombardi: weightKg, average: weightKg };
124
+ }
125
+ if (reps > 30) throw new Error('reps > 30 is outside the validated range');
126
+
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;
132
+
133
+ return { weightKg, reps, epley, brzycki, lander, lombardi, average };
134
+ }
135
+
136
+ // ─── VO2max estimation ────────────────────────────────────────────────────────
137
+
138
+ /**
139
+ * Cooper 12-minute run test.
140
+ * VO2max (ml/kg/min) = (distance_m − 504.9) / 44.73
141
+ */
142
+ export function vo2MaxCooper(distanceMeters: number): VO2MaxResult {
143
+ if (distanceMeters <= 0) throw new Error('distanceMeters must be positive');
144
+ const vo2 = (distanceMeters - 504.9) / 44.73;
145
+ return { vo2MaxMlKgMin: Math.max(0, vo2), method: 'cooper-12min', fitnessClass: classifyVO2(vo2) };
146
+ }
147
+
148
+ /**
149
+ * Åstrand-Rhyming nomogram (cycle ergometer).
150
+ * VO2max ≈ 0.00212 × workloadW + 0.299 (simplified linear regression)
151
+ * More accurately: VO2max = (workload × 6) / (hrSteadyState - 37.182) × 3.5 for females
152
+ * We use the standard ACSM leg-cycling equation:
153
+ * VO2 (ml/min) = 1.8 × workRate(kgm/min) / bodyMassKg + 7 [in ml/kg/min]
154
+ * Then scale by correction factor for age-adjusted HRmax.
155
+ */
156
+ export function vo2MaxAstrand(
157
+ workRateWatts: number,
158
+ hrSteadyState: number,
159
+ bodyMassKg: number,
160
+ ageYears: number,
161
+ ): VO2MaxResult {
162
+ if (workRateWatts <= 0) throw new Error('workRateWatts must be positive');
163
+ if (hrSteadyState <= 0 || hrSteadyState > 250) throw new Error('hrSteadyState out of range');
164
+ if (bodyMassKg <= 0) throw new Error('bodyMassKg must be positive');
165
+
166
+ // Work rate in kgm/min (1 W = 6.12 kgm/min approximately)
167
+ const kgmMin = workRateWatts * 6.12;
168
+ // ACSM gross VO2 equation for leg cycling
169
+ const vo2Gross = (1.8 * kgmMin) / bodyMassKg + 7; // ml/kg/min
170
+ // Age-based HRmax correction (Astrand factor)
171
+ const hrMax = 220 - ageYears;
172
+ const correctionFactor = hrMax / hrSteadyState;
173
+ const vo2Max = vo2Gross * correctionFactor * 0.836; // empirical scaling
174
+
175
+ return { vo2MaxMlKgMin: Math.max(0, vo2Max), method: 'astrand-rhyming', fitnessClass: classifyVO2(vo2Max) };
176
+ }
177
+
178
+ /**
179
+ * Non-exercise VO2max estimation from demographic and fitness data.
180
+ * Jackson et al. (1990) equation:
181
+ * VO2max = 56.363 + 1.921×PA_R − 0.381×age − 0.754×BMI + 10.987×sex
182
+ */
183
+ export function vo2MaxNonExercise(
184
+ ageYears: number,
185
+ bmi: number,
186
+ physicalActivityRating: number, // PA-R: 0-10 Likert scale
187
+ isMale: boolean,
188
+ ): VO2MaxResult {
189
+ if (ageYears < 10 || ageYears > 100) throw new Error('ageYears out of range [10, 100]');
190
+ 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]');
192
+
193
+ 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) };
196
+ }
197
+
198
+ function classifyVO2(vo2: number): VO2MaxResult['fitnessClass'] {
199
+ if (vo2 < 25) return 'poor';
200
+ if (vo2 < 34) return 'fair';
201
+ if (vo2 < 42) return 'good';
202
+ if (vo2 < 52) return 'excellent';
203
+ return 'superior';
204
+ }
205
+
206
+ // ─── Heart rate zones (Karvonen) ─────────────────────────────────────────────
207
+
208
+ /**
209
+ * Compute 5-zone heart rate training plan using Karvonen (heart rate reserve) method.
210
+ * Zone boundaries: 50–60%, 60–70%, 70–80%, 80–90%, 90–100% of HRR.
211
+ */
212
+ export function heartRateZones(hrRest: number, hrMax: number): HeartRateZoneResult {
213
+ if (hrRest <= 0 || hrMax <= 0) throw new Error('Heart rates must be positive');
214
+ if (hrMax <= hrRest) throw new Error('hrMax must be > hrRest');
215
+
216
+ const hrr = hrMax - hrRest;
217
+ const zone = (lo: number, hi: number) => ({
218
+ lo: Math.round(hrRest + lo * hrr),
219
+ hi: Math.round(hrRest + hi * hrr),
220
+ });
221
+
222
+ return {
223
+ hrRest, hrMax, hrr,
224
+ 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' },
230
+ },
231
+ };
232
+ }
233
+
234
+ // ─── MET-based calorie burn ───────────────────────────────────────────────────
235
+
236
+ /** Common activity METs from the Compendium of Physical Activities (2011) */
237
+ 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,
253
+ };
254
+
255
+ /**
256
+ * Estimate gross and net calorie expenditure.
257
+ * Gross: MET × bodyMassKg × durationHours
258
+ * Net: gross − BMR_rate × duration (approximate BMR ≈ 1 MET contribution)
259
+ */
260
+ export function calorieBurn(met: number, bodyMassKg: number, durationMin: number): CalorieBurnResult {
261
+ if (met <= 0) throw new Error('MET must be positive');
262
+ if (bodyMassKg <= 0) throw new Error('bodyMassKg must be positive');
263
+ if (durationMin <= 0) throw new Error('durationMin must be positive');
264
+
265
+ const durationHours = durationMin / 60;
266
+ const grossKcal = met * bodyMassKg * durationHours;
267
+ // Net = activity kcal above resting (subtract 1 MET baseline)
268
+ const netKcal = (met - 1.0) * bodyMassKg * durationHours;
269
+
270
+ return { met, bodyMassKg, durationMin, grossKcal, netKcal: Math.max(0, netKcal) };
271
+ }
272
+
273
+ // ─── Body composition (Jackson-Pollock 3-site skinfold) ───────────────────────
274
+
275
+ /**
276
+ * Jackson-Pollock 3-site skinfold body density equations.
277
+ * Male sites: chest, abdomen, thigh (mm)
278
+ * Female sites: triceps, suprailiac, thigh (mm)
279
+ * Body fat % via Siri (1956): %BF = (4.95/D − 4.50) × 100
280
+ */
281
+ export function jacksonPollockSkinfold(
282
+ s1Mm: number,
283
+ s2Mm: number,
284
+ s3Mm: number,
285
+ ageYears: number,
286
+ isMale: boolean,
287
+ bodyMassKg: number,
288
+ ): BodyCompositionResult {
289
+ if (s1Mm <= 0 || s2Mm <= 0 || s3Mm <= 0) throw new Error('Skinfold measurements must be positive');
290
+ if (ageYears < 18 || ageYears > 80) throw new Error('Age must be in [18, 80]');
291
+ if (bodyMassKg <= 0) throw new Error('bodyMassKg must be positive');
292
+
293
+ const sum = s1Mm + s2Mm + s3Mm;
294
+ const sum2 = sum * sum;
295
+
296
+ // Jackson-Pollock (1978/1980) equations for body density
297
+ let density: number;
298
+ if (isMale) {
299
+ // 3-site (chest + abdomen + thigh)
300
+ density = 1.10938 - 0.0008267 * sum + 0.0000016 * sum2 - 0.0002574 * ageYears;
301
+ } else {
302
+ // 3-site (triceps + suprailiac + thigh)
303
+ density = 1.0994921 - 0.0009929 * sum + 0.0000023 * sum2 - 0.0001392 * ageYears;
304
+ }
305
+
306
+ // Siri equation
307
+ const bodyFatPct = (4.95 / density - 4.50) * 100;
308
+ const fatMassKg = bodyMassKg * bodyFatPct / 100;
309
+ const leanMassKg = bodyMassKg - fatMassKg;
310
+
311
+ return { skinfoldSumMm: sum, bodyDensityGcc: density, bodyFatPct, fatMassKg, leanMassKg };
312
+ }
313
+
314
+ // ─── Training load (ACWR) ─────────────────────────────────────────────────────
315
+
316
+ /**
317
+ * Compute acute:chronic workload ratio (Gabbett 2016).
318
+ * workloads: array of daily AU (arbitrary units), most recent last.
319
+ * Returns injury risk classification.
320
+ */
321
+ export function acuteChronicWorkloadRatio(workloads: number[]): TrainingLoadResult {
322
+ if (workloads.length < 7) throw new Error('At least 7 days of workload data required');
323
+
324
+ const recent = workloads.slice(-28);
325
+ const acuteWindow = recent.slice(-7);
326
+ const chronicWindow = recent;
327
+
328
+ const acuteLoad = acuteWindow.reduce((a, b) => a + b, 0) / 7;
329
+ const chronicLoad = chronicWindow.reduce((a, b) => a + b, 0) / chronicWindow.length;
330
+
331
+ const acwr = chronicLoad === 0 ? 0 : acuteLoad / chronicLoad;
332
+
333
+ // Injury-risk bands (Gabbett 2016, "training–injury prevention paradox").
334
+ // Ordered ascending by ACWR; the bands are contiguous and exhaustive, so
335
+ // each ACWR maps to exactly one category — no dead branch, no re-assignment.
336
+ let riskCategory: TrainingLoadResult['riskCategory'];
337
+ if (acwr < 0.8) {
338
+ // Under-prepared / detraining: chronically low load leaves the athlete
339
+ // unconditioned, which itself carries elevated injury risk.
340
+ riskCategory = 'high';
341
+ } else if (acwr <= 1.3) {
342
+ // "Sweet spot" (0.8–1.3): acute load matched to the chronic base — lowest risk.
343
+ riskCategory = 'low';
344
+ } else if (acwr <= 1.5) {
345
+ // Ramp-up zone (1.3–1.5): load climbing faster than the base supports.
346
+ riskCategory = 'moderate';
347
+ } else {
348
+ // Danger zone (> 1.5): acute spike well beyond the chronic base — highest risk.
349
+ riskCategory = 'very-high';
350
+ }
351
+
352
+ return { acuteLoad, chronicLoad, acwr, riskCategory };
353
+ }
354
+
355
+ // ─── Unified analysis entry-point ─────────────────────────────────────────────
356
+
357
+ export interface FitnessAnalysisInput {
358
+ 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 };
362
+ hrZones?: { hrRest: number; hrMax: number };
363
+ calories?: { met: number; bodyMassKg: number; durationMin: number };
364
+ bodyComp?: { s1: number; s2: number; s3: number; ageYears: number; isMale: boolean; bodyMassKg: number };
365
+ trainingLoad?: { workloads: number[] };
366
+ }
367
+
368
+ export interface FitnessAnalysisResult {
369
+ oneRM?: OneRepMaxResult;
370
+ vo2Max?: VO2MaxResult;
371
+ hrZones?: HeartRateZoneResult;
372
+ calories?: CalorieBurnResult;
373
+ bodyComp?: BodyCompositionResult;
374
+ trainingLoad?: TrainingLoadResult;
375
+ converged: true;
376
+ }
377
+
378
+ export function analyzeFitness(input: FitnessAnalysisInput): FitnessAnalysisResult {
379
+ const result: FitnessAnalysisResult = { converged: true };
380
+
381
+ if (input.oneRM) result.oneRM = oneRepMax(input.oneRM.weightKg, input.oneRM.reps);
382
+
383
+ if (input.vo2Max) {
384
+ const v = input.vo2Max;
385
+ 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);
387
+ else result.vo2Max = vo2MaxNonExercise(v.ageYears, v.bmi, v.paRating, v.isMale);
388
+ }
389
+
390
+ 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);
392
+ if (input.bodyComp) {
393
+ const b = input.bodyComp;
394
+ result.bodyComp = jacksonPollockSkinfold(b.s1, b.s2, b.s3, b.ageYears, b.isMale, b.bodyMassKg);
395
+ }
396
+ if (input.trainingLoad) result.trainingLoad = acuteChronicWorkloadRatio(input.trainingLoad.workloads);
397
+
398
+ return result;
399
+ }
400
+
401
+ // ─── Receipt ──────────────────────────────────────────────────────────────────
402
+
403
+ export function buildFitnessReceipt(
404
+ result: FitnessAnalysisResult,
405
+ options?: FitnessReceiptOptions,
406
+ ): DomainSimulationReceipt {
407
+ const violations: Array<{ criterion: string; message: string }> = [];
408
+
409
+ 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%]` });
411
+ }
412
+ 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` });
414
+ }
415
+
416
+ return buildDomainSimulationReceipt({
417
+ plugin: 'fitness-wellness',
418
+ pluginVersion: '1.0.0',
419
+ runId: options?.runId ?? `fit-${Date.now().toString(36)}`,
420
+ solverConfig: { solverType: 'fitness-analytics', scale: 'individual' },
421
+ resultSummary: {
422
+ vo2MaxMlKgMin: result.vo2Max?.vo2MaxMlKgMin ?? null,
423
+ fitnessClass: result.vo2Max?.fitnessClass ?? null,
424
+ oneRMAvgKg: result.oneRM?.average ?? null,
425
+ bodyFatPct: result.bodyComp?.bodyFatPct ?? null,
426
+ acwr: result.trainingLoad?.acwr ?? null,
427
+ },
428
+ cael: { version: 'cael.v1', event: 'fitness_wellness.fitness_analysis', solverType: 'fitness-wellness.analytics' },
429
+ acceptance: { accepted: violations.length === 0, violations },
430
+ });
431
+ }
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ export { createWorkoutHandler, type WorkoutConfig, type WorkoutType, type ExerciseSet } from './traits/WorkoutTrait';
2
+ 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';
5
+ export * from './traits/types';
6
+
7
+ import { createWorkoutHandler } from './traits/WorkoutTrait';
8
+ import { createRepCounterHandler } from './traits/RepCounterTrait';
9
+ import { createExerciseLibraryHandler } from './traits/ExerciseLibraryTrait';
10
+ import { createProgressTrackerHandler } from './traits/ProgressTrackerTrait';
11
+
12
+ export * from './fitnesssolver';
13
+
14
+ // Runtime integration — behavioral trait handler + registrar that wire the
15
+ // deterministic 1-rep-max prediction solver into HoloScriptRuntime's dispatch.
16
+ // Closes the built-but-dead-wired gap for `one_rep_max`, mirroring
17
+ // government-civic's `civic_decision` reference integration.
18
+ export {
19
+ FITNESS_WELLNESS_PLUGIN_ID,
20
+ oneRepMaxHandler,
21
+ registerFitnessWellnessTraitHandlers,
22
+ type OneRepMaxTraitConfig,
23
+ type OneRepMaxSolvedEvent,
24
+ type RuntimeTraitHandler,
25
+ type TraitRegistrar,
26
+ } from './runtime';
27
+
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()];
package/src/runtime.ts ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Runtime integration for @holoscript/plugin-fitness-wellness.
3
+ *
4
+ * Bridges the previously dead-wired `one_rep_max` trait into a behavioral
5
+ * TraitHandler that the HoloScript runtime actually dispatches
6
+ * (HoloScriptRuntime.registerTrait -> applyDirectives / updateTraits).
7
+ *
8
+ * Before this module the plugin declared trait NAMES only (pluginMeta.traits)
9
+ * and exported the fitness solvers (oneRepMax, heartRateZones, calorieBurn, …),
10
+ * but nothing invoked a solver THROUGH the runtime — the whole domain-plugin
11
+ * tier was built-but-dead-wired. This mirrors government-civic-plugin's
12
+ * reference integration (civic_decision): it wires the deterministic
13
+ * 1-rep-max prediction solver (`oneRepMax`) behind the `one_rep_max` trait so
14
+ * the runtime's directive dispatch can run it. The remaining fitness traits
15
+ * follow the same registrar shape.
16
+ */
17
+ import { registerPluginTraits } from '@holoscript/core/runtime';
18
+ import { oneRepMax, type OneRepMaxResult } from './fitnesssolver';
19
+
20
+ /** Stable id for this plugin's trait ownership tagging. */
21
+ export const FITNESS_WELLNESS_PLUGIN_ID = 'fitness-wellness' as const;
22
+
23
+ /** Config carried by an orb's `@one_rep_max` trait directive. */
24
+ export interface OneRepMaxTraitConfig {
25
+ /** Measured weight lifted (kg). Required; absence/invalid emits `one_rep_max_error`. */
26
+ weightKg?: number;
27
+ /** Reps performed at that weight. Required positive integer; absence/invalid emits error. */
28
+ reps?: number;
29
+ }
30
+
31
+ /** Summary payload emitted on `one_rep_max_solved`. */
32
+ export interface OneRepMaxSolvedEvent {
33
+ nodeId: string;
34
+ /** Measured weight echoed back. */
35
+ weightKg: number;
36
+ /** Reps echoed back. */
37
+ reps: number;
38
+ /** Epley-formula predicted 1RM (kg). */
39
+ epley: number;
40
+ /** Brzycki-formula predicted 1RM (kg). */
41
+ brzycki: number;
42
+ /** Lander-formula predicted 1RM (kg). */
43
+ lander: number;
44
+ /** Lombardi-formula predicted 1RM (kg). */
45
+ lombardi: number;
46
+ /** Average of all four formula predictions (kg). */
47
+ average: number;
48
+ }
49
+
50
+ /**
51
+ * Structural view of the runtime trait-handler contract. Matches
52
+ * `@holoscript/core` TraitTypes.TraitHandler at the call sites the runtime
53
+ * actually uses (onAttach / onUpdate receive the node, the directive config,
54
+ * and a context exposing `emit`). Declared locally so the plugin stays
55
+ * decoupled from core's full trait surface.
56
+ */
57
+ export interface TraitDispatchContext {
58
+ emit: (event: string, payload?: unknown) => void;
59
+ setState?: (updates: Record<string, unknown>) => void;
60
+ }
61
+
62
+ export interface RuntimeTraitHandler {
63
+ name: string;
64
+ onAttach?: (node: unknown, config: OneRepMaxTraitConfig, context: TraitDispatchContext) => void;
65
+ onUpdate?: (
66
+ node: unknown,
67
+ config: OneRepMaxTraitConfig,
68
+ context: TraitDispatchContext,
69
+ delta: number,
70
+ ) => void;
71
+ }
72
+
73
+ interface OneRepMaxNode {
74
+ id?: string;
75
+ name?: string;
76
+ properties?: Record<string, unknown>;
77
+ __oneRepMaxResult?: OneRepMaxResult;
78
+ }
79
+
80
+ /** Run the 1RM solver on the directive config, write the result onto the node, and emit. */
81
+ function solveOntoNode(
82
+ node: unknown,
83
+ config: OneRepMaxTraitConfig | undefined,
84
+ context: TraitDispatchContext,
85
+ ): void {
86
+ const carrier = node as OneRepMaxNode;
87
+ const nodeId = carrier.id ?? carrier.name ?? 'unknown';
88
+ const weightKg = config?.weightKg;
89
+ const reps = config?.reps;
90
+
91
+ if (typeof weightKg !== 'number' || typeof reps !== 'number') {
92
+ context.emit('one_rep_max_error', {
93
+ nodeId,
94
+ error:
95
+ 'one_rep_max trait requires config.weightKg (number) and config.reps (number)',
96
+ });
97
+ return;
98
+ }
99
+
100
+ try {
101
+ const result = oneRepMax(weightKg, reps);
102
+ carrier.__oneRepMaxResult = result;
103
+ carrier.properties = {
104
+ ...(carrier.properties ?? {}),
105
+ oneRepMaxEpley: result.epley,
106
+ oneRepMaxAverage: result.average,
107
+ };
108
+ const summary: OneRepMaxSolvedEvent = {
109
+ nodeId,
110
+ weightKg: result.weightKg,
111
+ reps: result.reps,
112
+ epley: result.epley,
113
+ brzycki: result.brzycki,
114
+ lander: result.lander,
115
+ lombardi: result.lombardi,
116
+ average: result.average,
117
+ };
118
+ context.setState?.({ [`one_rep_max:${nodeId}`]: summary });
119
+ context.emit('one_rep_max_solved', summary);
120
+ } catch (error) {
121
+ context.emit('one_rep_max_error', {
122
+ nodeId,
123
+ error: error instanceof Error ? error.message : String(error),
124
+ });
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Behavioral handler for the fitness-wellness `one_rep_max` trait. Runs the
130
+ * deterministic 1-rep-max prediction solver whenever an orb carrying the trait
131
+ * is attached (and on each per-frame update), writing the result onto the node
132
+ * and emitting `one_rep_max_solved` / `one_rep_max_error`.
133
+ */
134
+ export const oneRepMaxHandler: RuntimeTraitHandler = {
135
+ name: 'one_rep_max',
136
+ onAttach: (node, config, context) => solveOntoNode(node, config, context),
137
+ onUpdate: (node, config, context) => solveOntoNode(node, config, context),
138
+ };
139
+
140
+ /** A runtime that can register behavioral trait handlers. */
141
+ export interface TraitRegistrar {
142
+ registerTrait(name: string, handler: unknown): void;
143
+ }
144
+
145
+ /**
146
+ * Register fitness-wellness behavioral trait handlers into a runtime that
147
+ * exposes `registerTrait(name, handler)` — e.g. `@holoscript/core`
148
+ * HoloScriptRuntime. This is the consumption path the dead-wired tier was
149
+ * missing: after this call the runtime's directive dispatch (applyDirectives /
150
+ * updateTraits) will invoke the 1RM solver for `@one_rep_max` orbs.
151
+ */
152
+ export function registerFitnessWellnessTraitHandlers(registrar: TraitRegistrar): void {
153
+ registerPluginTraits(registrar, FITNESS_WELLNESS_PLUGIN_ID, [oneRepMaxHandler]);
154
+ }
@@ -0,0 +1,20 @@
1
+ /** @exercise_library Trait — Exercise database. @trait exercise_library */
2
+ import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
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[]; }
7
+
8
+ const defaultConfig: ExerciseLibraryConfig = { exercises: [], categories: [] };
9
+
10
+ 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'); },
14
+ onUpdate() {},
15
+ 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 }); }
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,30 @@
1
+ /** @progress_tracker Trait — Fitness progress over time. @trait progress_tracker */
2
+ import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
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[]; }
7
+
8
+ const defaultConfig: ProgressTrackerConfig = { userId: '', metrics: ['weight', 'body_fat', 'bench_press_1rm'], goalValues: {}, trackingPeriodDays: 90 };
9
+
10
+ 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'); },
14
+ onUpdate() {},
15
+ onEvent(n: HSPlusNode, c: ProgressTrackerConfig, ctx: TraitContext, e: TraitEvent) {
16
+ const s = n.__progressState as ProgressTrackerState | undefined; if (!s) return;
17
+ 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) ?? '' };
19
+ s.entries.push(entry);
20
+ 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;
23
+ s.lastLogDate = today;
24
+ 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 }); }
26
+ ctx.emit?.('progress:logged', { metric: entry.metric, streak: s.streakDays });
27
+ }
28
+ },
29
+ };
30
+ }