@isparling/engram-coach 0.1.0 → 0.2.0
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/README.md +85 -18
- package/SETUP.md +559 -0
- package/SKILL_PACK.md +75 -0
- package/analyses/catalog.md +257 -0
- package/analysis-tools/hrv-trend.ts +592 -0
- package/analysis-tools/migrate-structured-capture.ts +234 -0
- package/analysis-tools/race-context.ts +96 -0
- package/analysis-tools/stream-analyze.ts +1008 -0
- package/analysis-tools/tsb-predict.ts +117 -0
- package/capture-handler.ts +301 -0
- package/config.json.example +21 -0
- package/engram-coach-ambient-capture.ts +336 -0
- package/engram-coach-capture-types.ts +185 -0
- package/engram-coach-config.ts +268 -0
- package/engram-coach-domain.ts +7 -2
- package/engram-coach-keys.ts +189 -0
- package/engram-coach-materialization.ts +638 -0
- package/engram-coach-migration.ts +1078 -0
- package/engram-coach-pack.ts +17 -12
- package/engram-coach-presentation.ts +10 -1
- package/engram-coach-reconciliation.ts +305 -2
- package/engram-coach-structured-capture.ts +622 -0
- package/package.json +39 -6
- package/personas/aggressive-monitoring.md +121 -0
- package/personas/aggressive.json +85 -0
- package/personas/conservative-monitoring.md +133 -0
- package/personas/conservative.json +93 -0
- package/personas/polarized-monitoring.md +112 -0
- package/personas/polarized.json +72 -0
- package/personas/volume-monitoring.md +85 -0
- package/personas/volume.json +108 -0
- package/shared/retrieval.md +71 -0
- package/shared/setup.md +207 -0
- package/skills/.gitkeep +0 -0
- package/skills/adapt-plan/SKILL.md +263 -0
- package/skills/block-review/SKILL.md +275 -0
- package/skills/consult/SKILL.md +176 -0
- package/skills/intake/SKILL.md +315 -0
- package/skills/lactate-analyze/SKILL.md +230 -0
- package/skills/lessons-rollup/SKILL.md +196 -0
- package/skills/monitoring-rollup/SKILL.md +208 -0
- package/skills/race-analysis/SKILL.md +219 -0
- package/skills/season-retrospective/SKILL.md +200 -0
- package/skills/set-goal/SKILL.md +297 -0
- package/templates/base.md +55 -0
- package/templates/build-1.md +57 -0
- package/templates/build-2.md +62 -0
- package/templates/race-report.md +51 -0
- package/templates/race-specificity.md +62 -0
- package/templates/season-review.md +40 -0
- package/engram-coach-extractor.ts +0 -295
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
import { fileURLToPath } from 'url';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { loadConfig, IntervalsIcuConfig } from './stream-analyze.js';
|
|
4
|
+
|
|
5
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export type MetricKey = 'hrv_rmssd' | 'hrv_sdnn';
|
|
8
|
+
|
|
9
|
+
export interface WellnessRecord {
|
|
10
|
+
date: string; // YYYY-MM-DD
|
|
11
|
+
hrv_rmssd: number | null;
|
|
12
|
+
hrv_sdnn: number | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface HrvTrendThresholds {
|
|
16
|
+
green: number; // z-score at/above this → green (default -0.5)
|
|
17
|
+
green_watch: number; // z-score at/above this → green-watch (default -1.0)
|
|
18
|
+
amber: number; // z-score at/above this → amber band (default -1.5)
|
|
19
|
+
red: number; // z-score below this → red hard floor (default -2.0)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface HrvTrendInput {
|
|
23
|
+
targetDate: string;
|
|
24
|
+
wellness: WellnessRecord[]; // sorted ascending by date
|
|
25
|
+
metric: MetricKey;
|
|
26
|
+
shortWindowDays: number;
|
|
27
|
+
longWindowDays: number;
|
|
28
|
+
trendWindowDays: number;
|
|
29
|
+
analogTolerance: number;
|
|
30
|
+
analogDedupDays: number;
|
|
31
|
+
thresholds?: Partial<HrvTrendThresholds>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface BaselinesResult {
|
|
35
|
+
short_window_days: number;
|
|
36
|
+
short_mean: number | null;
|
|
37
|
+
short_sd: number | null;
|
|
38
|
+
long_window_days: number;
|
|
39
|
+
long_mean: number | null;
|
|
40
|
+
long_sd: number | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PositionResult {
|
|
44
|
+
z_short: number | null;
|
|
45
|
+
z_long: number | null;
|
|
46
|
+
percentile_long: number | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TrendResult {
|
|
50
|
+
window_days: number;
|
|
51
|
+
slope_per_day: number | null;
|
|
52
|
+
direction: 'improving' | 'stable' | 'declining' | null;
|
|
53
|
+
consecutive_days_below_long_mean: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AnalogMatch {
|
|
57
|
+
date: string;
|
|
58
|
+
value: number;
|
|
59
|
+
rebound_days_to_long_mean: number | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface AnalogsResult {
|
|
63
|
+
tolerance: number;
|
|
64
|
+
dedup_days: number;
|
|
65
|
+
matches: AnalogMatch[];
|
|
66
|
+
match_count: number;
|
|
67
|
+
median_rebound_days: number | null;
|
|
68
|
+
any_sustained_suppression: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type ClassificationLabel =
|
|
72
|
+
| 'green'
|
|
73
|
+
| 'green-watch'
|
|
74
|
+
| 'amber'
|
|
75
|
+
| 'amber-red'
|
|
76
|
+
| 'red'
|
|
77
|
+
| 'insufficient_data';
|
|
78
|
+
|
|
79
|
+
export interface ClassificationResult {
|
|
80
|
+
label: ClassificationLabel;
|
|
81
|
+
reasoning: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface HrvTrendOutput {
|
|
85
|
+
date: string;
|
|
86
|
+
metric: MetricKey;
|
|
87
|
+
current: number | null;
|
|
88
|
+
baselines: BaselinesResult;
|
|
89
|
+
position: PositionResult;
|
|
90
|
+
trend: TrendResult;
|
|
91
|
+
analogs: AnalogsResult;
|
|
92
|
+
classification: ClassificationResult;
|
|
93
|
+
errors: Record<string, string>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Math helpers ─────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export function sampleMean(values: number[]): number {
|
|
99
|
+
if (values.length === 0) return 0;
|
|
100
|
+
return values.reduce((sum, v) => sum + v, 0) / values.length;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function sampleStddev(values: number[]): number {
|
|
104
|
+
if (values.length < 2) return 0;
|
|
105
|
+
const avg = sampleMean(values);
|
|
106
|
+
const variance = values.reduce((sum, v) => sum + (v - avg) ** 2, 0) / (values.length - 1);
|
|
107
|
+
return Math.sqrt(variance);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function percentileRank(sortedValues: number[], target: number): number {
|
|
111
|
+
if (sortedValues.length === 0) return 0;
|
|
112
|
+
const count = sortedValues.filter(v => v <= target).length;
|
|
113
|
+
return (count / sortedValues.length) * 100;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function linearRegressionSlope(values: number[]): number {
|
|
117
|
+
const n = values.length;
|
|
118
|
+
if (n < 2) return 0;
|
|
119
|
+
const xMean = (n - 1) / 2;
|
|
120
|
+
const yMean = sampleMean(values);
|
|
121
|
+
let numerator = 0;
|
|
122
|
+
let denominator = 0;
|
|
123
|
+
for (let i = 0; i < n; i++) {
|
|
124
|
+
numerator += (i - xMean) * (values[i] - yMean);
|
|
125
|
+
denominator += (i - xMean) ** 2;
|
|
126
|
+
}
|
|
127
|
+
return denominator === 0 ? 0 : numerator / denominator;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Internal helpers ─────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
function round2(n: number): number {
|
|
133
|
+
return Math.round(n * 100) / 100;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── computeBaselines ─────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
export function computeBaselines(
|
|
139
|
+
history: number[],
|
|
140
|
+
shortWindowDays: number,
|
|
141
|
+
longWindowDays: number,
|
|
142
|
+
): BaselinesResult {
|
|
143
|
+
const result: BaselinesResult = {
|
|
144
|
+
short_window_days: shortWindowDays,
|
|
145
|
+
short_mean: null,
|
|
146
|
+
short_sd: null,
|
|
147
|
+
long_window_days: longWindowDays,
|
|
148
|
+
long_mean: null,
|
|
149
|
+
long_sd: null,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const longSlice = history.slice(-longWindowDays);
|
|
153
|
+
if (longSlice.length >= longWindowDays) {
|
|
154
|
+
result.long_mean = round2(sampleMean(longSlice));
|
|
155
|
+
result.long_sd = round2(sampleStddev(longSlice));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const shortSlice = history.slice(-shortWindowDays);
|
|
159
|
+
if (shortSlice.length >= shortWindowDays) {
|
|
160
|
+
result.short_mean = round2(sampleMean(shortSlice));
|
|
161
|
+
result.short_sd = round2(sampleStddev(shortSlice));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── computePosition ──────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
export function computePosition(
|
|
170
|
+
current: number,
|
|
171
|
+
baselines: BaselinesResult,
|
|
172
|
+
sortedLongWindow?: number[],
|
|
173
|
+
): PositionResult {
|
|
174
|
+
const zShort =
|
|
175
|
+
baselines.short_mean !== null && baselines.short_sd !== null && baselines.short_sd > 0
|
|
176
|
+
? round2((current - baselines.short_mean) / baselines.short_sd)
|
|
177
|
+
: null;
|
|
178
|
+
|
|
179
|
+
const zLong =
|
|
180
|
+
baselines.long_mean !== null && baselines.long_sd !== null && baselines.long_sd > 0
|
|
181
|
+
? round2((current - baselines.long_mean) / baselines.long_sd)
|
|
182
|
+
: null;
|
|
183
|
+
|
|
184
|
+
const percentileLong =
|
|
185
|
+
sortedLongWindow && sortedLongWindow.length > 0
|
|
186
|
+
? round2(percentileRank(sortedLongWindow, current))
|
|
187
|
+
: null;
|
|
188
|
+
|
|
189
|
+
return { z_short: zShort, z_long: zLong, percentile_long: percentileLong };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── computeTrend ─────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
export function computeTrend(
|
|
195
|
+
history: WellnessRecord[],
|
|
196
|
+
targetDate: string,
|
|
197
|
+
longMean: number | null,
|
|
198
|
+
trendWindowDays: number,
|
|
199
|
+
metric: MetricKey,
|
|
200
|
+
): TrendResult {
|
|
201
|
+
const getValue = (r: WellnessRecord): number | null =>
|
|
202
|
+
metric === 'hrv_rmssd' ? r.hrv_rmssd : r.hrv_sdnn;
|
|
203
|
+
|
|
204
|
+
const relevant = history.filter(r => r.date <= targetDate);
|
|
205
|
+
|
|
206
|
+
// Consecutive days below long_mean: walk backwards from target date
|
|
207
|
+
let consecutive = 0;
|
|
208
|
+
if (longMean !== null) {
|
|
209
|
+
for (let i = relevant.length - 1; i >= 0; i--) {
|
|
210
|
+
const val = getValue(relevant[i]);
|
|
211
|
+
if (val === null) break;
|
|
212
|
+
if (val < longMean) {
|
|
213
|
+
consecutive++;
|
|
214
|
+
} else {
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Trend window: last trendWindowDays records (including target date)
|
|
221
|
+
const trendRecords = relevant.slice(-trendWindowDays);
|
|
222
|
+
const trendValues = trendRecords.map(getValue).filter((v): v is number => v !== null);
|
|
223
|
+
|
|
224
|
+
let slopePerDay: number | null = null;
|
|
225
|
+
let direction: TrendResult['direction'] = null;
|
|
226
|
+
|
|
227
|
+
if (trendValues.length >= 2) {
|
|
228
|
+
slopePerDay = round2(linearRegressionSlope(trendValues));
|
|
229
|
+
if (slopePerDay > 0.5) direction = 'improving';
|
|
230
|
+
else if (slopePerDay < -0.5) direction = 'declining';
|
|
231
|
+
else direction = 'stable';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
window_days: trendWindowDays,
|
|
236
|
+
slope_per_day: slopePerDay,
|
|
237
|
+
direction,
|
|
238
|
+
consecutive_days_below_long_mean: consecutive,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── computeAnalogs ────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
function deduplicateAnalogs(matches: AnalogMatch[], dedupDays: number): AnalogMatch[] {
|
|
245
|
+
if (matches.length === 0) return [];
|
|
246
|
+
const sorted = [...matches].sort((a, b) => a.date.localeCompare(b.date));
|
|
247
|
+
const clusters: AnalogMatch[][] = [[sorted[0]]];
|
|
248
|
+
|
|
249
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
250
|
+
const current = sorted[i];
|
|
251
|
+
const lastCluster = clusters[clusters.length - 1];
|
|
252
|
+
const lastDate = lastCluster[lastCluster.length - 1].date;
|
|
253
|
+
const msPerDay = 86_400_000;
|
|
254
|
+
const daysDiff = Math.round(
|
|
255
|
+
(new Date(current.date).getTime() - new Date(lastDate).getTime()) / msPerDay,
|
|
256
|
+
);
|
|
257
|
+
if (daysDiff <= dedupDays) {
|
|
258
|
+
lastCluster.push(current);
|
|
259
|
+
} else {
|
|
260
|
+
clusters.push([current]);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return clusters.map(cluster =>
|
|
265
|
+
cluster.reduce((min, m) => (m.value < min.value ? m : min)),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function computeAnalogs(
|
|
270
|
+
current: number,
|
|
271
|
+
longMean: number,
|
|
272
|
+
history: WellnessRecord[],
|
|
273
|
+
tolerance: number,
|
|
274
|
+
dedupDays: number,
|
|
275
|
+
metric: MetricKey,
|
|
276
|
+
): AnalogsResult {
|
|
277
|
+
const getValue = (r: WellnessRecord): number | null =>
|
|
278
|
+
metric === 'hrv_rmssd' ? r.hrv_rmssd : r.hrv_sdnn;
|
|
279
|
+
|
|
280
|
+
const REBOUND_MAX_DAYS = 7;
|
|
281
|
+
const OUTPUT_CAP = 5;
|
|
282
|
+
|
|
283
|
+
const rawMatches: AnalogMatch[] = [];
|
|
284
|
+
for (let i = 0; i < history.length; i++) {
|
|
285
|
+
const val = getValue(history[i]);
|
|
286
|
+
if (val === null) continue;
|
|
287
|
+
if (Math.abs(val - current) > tolerance) continue;
|
|
288
|
+
|
|
289
|
+
let reboundDays: number | null = null;
|
|
290
|
+
for (let d = 1; d <= REBOUND_MAX_DAYS; d++) {
|
|
291
|
+
const next = history[i + d];
|
|
292
|
+
if (!next) break;
|
|
293
|
+
const nextVal = getValue(next);
|
|
294
|
+
if (nextVal !== null && nextVal >= longMean) {
|
|
295
|
+
reboundDays = d;
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
rawMatches.push({ date: history[i].date, value: val, rebound_days_to_long_mean: reboundDays });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const deduplicated = deduplicateAnalogs(rawMatches, dedupDays);
|
|
304
|
+
deduplicated.sort((a, b) => b.date.localeCompare(a.date));
|
|
305
|
+
const matches = deduplicated.slice(0, OUTPUT_CAP);
|
|
306
|
+
|
|
307
|
+
const anySupp = rawMatches.some(m => m.rebound_days_to_long_mean === null);
|
|
308
|
+
const reboundTimes = matches
|
|
309
|
+
.map(m => m.rebound_days_to_long_mean)
|
|
310
|
+
.filter((v): v is number => v !== null);
|
|
311
|
+
const medianRebound =
|
|
312
|
+
reboundTimes.length === 0
|
|
313
|
+
? null
|
|
314
|
+
: reboundTimes.slice().sort((a, b) => a - b)[Math.floor(reboundTimes.length / 2)];
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
tolerance,
|
|
318
|
+
dedup_days: dedupDays,
|
|
319
|
+
matches,
|
|
320
|
+
match_count: matches.length,
|
|
321
|
+
median_rebound_days: medianRebound,
|
|
322
|
+
any_sustained_suppression: anySupp,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── classify ──────────────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
const DEFAULT_THRESHOLDS: HrvTrendThresholds = {
|
|
329
|
+
green: -0.5,
|
|
330
|
+
green_watch: -1.0,
|
|
331
|
+
amber: -1.5,
|
|
332
|
+
red: -2.0,
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
export function classify(
|
|
336
|
+
zLong: number | null,
|
|
337
|
+
consecutive: number,
|
|
338
|
+
analogs: AnalogsResult | null,
|
|
339
|
+
thresholds: Partial<HrvTrendThresholds>,
|
|
340
|
+
): ClassificationResult {
|
|
341
|
+
if (zLong === null) {
|
|
342
|
+
return {
|
|
343
|
+
label: 'insufficient_data',
|
|
344
|
+
reasoning: 'Insufficient wellness history to compute z-score baseline.',
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const t: HrvTrendThresholds = { ...DEFAULT_THRESHOLDS, ...thresholds };
|
|
349
|
+
const anySustained = analogs?.any_sustained_suppression ?? false;
|
|
350
|
+
const zStr = round2(zLong).toFixed(2);
|
|
351
|
+
|
|
352
|
+
if (zLong < t.red) {
|
|
353
|
+
return {
|
|
354
|
+
label: 'red',
|
|
355
|
+
reasoning: `z_long ${zStr} is below hard floor (${t.red}); severe HRV suppression regardless of analog history.`,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (zLong < t.amber) {
|
|
360
|
+
if (consecutive >= 3 || anySustained) {
|
|
361
|
+
return {
|
|
362
|
+
label: 'red',
|
|
363
|
+
reasoning:
|
|
364
|
+
`z_long ${zStr} in severe band (${t.amber}..${t.red}); ` +
|
|
365
|
+
(consecutive >= 3 ? `${consecutive} consecutive days below long-mean; ` : '') +
|
|
366
|
+
(anySustained ? 'prior analogs showed sustained suppression.' : ''),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
label: 'amber-red',
|
|
371
|
+
reasoning:
|
|
372
|
+
`z_long ${zStr} in severe band (${t.amber}..${t.red}); ` +
|
|
373
|
+
`${consecutive} consecutive days below long-mean; ` +
|
|
374
|
+
`prior analogs did not show sustained suppression.`,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (zLong < t.green_watch) {
|
|
379
|
+
if (consecutive >= 3 && anySustained) {
|
|
380
|
+
return {
|
|
381
|
+
label: 'amber-red',
|
|
382
|
+
reasoning:
|
|
383
|
+
`z_long ${zStr} in moderate-suppression band; ` +
|
|
384
|
+
`${consecutive} consecutive days below long-mean; ` +
|
|
385
|
+
`prior analogs showed sustained suppression.`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
label: 'amber',
|
|
390
|
+
reasoning:
|
|
391
|
+
`z_long ${zStr} in moderate-suppression band (${t.green_watch}..${t.amber}); ` +
|
|
392
|
+
`${consecutive} consecutive days below long-mean; ` +
|
|
393
|
+
(anySustained ? 'some analogs showed sustained suppression.' : 'analogs generally rebounded.'),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (zLong < t.green) {
|
|
398
|
+
return {
|
|
399
|
+
label: 'green-watch',
|
|
400
|
+
reasoning:
|
|
401
|
+
`z_long ${zStr} is near baseline (${t.green_watch}..${t.green}); mild suppression worth noting.`,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
label: 'green',
|
|
407
|
+
reasoning: `z_long ${zStr} is at or above baseline (≥ ${t.green}); readiness unimpaired.`,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── analyzeHrvTrend ───────────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
export function analyzeHrvTrend(input: HrvTrendInput): HrvTrendOutput {
|
|
414
|
+
const {
|
|
415
|
+
targetDate, wellness, metric, shortWindowDays, longWindowDays,
|
|
416
|
+
trendWindowDays, analogTolerance, analogDedupDays, thresholds = {},
|
|
417
|
+
} = input;
|
|
418
|
+
|
|
419
|
+
const errors: Record<string, string> = {};
|
|
420
|
+
|
|
421
|
+
const getValue = (r: WellnessRecord): number | null =>
|
|
422
|
+
metric === 'hrv_rmssd' ? r.hrv_rmssd : r.hrv_sdnn;
|
|
423
|
+
|
|
424
|
+
const allSorted = [...wellness].sort((a, b) => a.date.localeCompare(b.date));
|
|
425
|
+
const history = allSorted.filter(r => r.date < targetDate);
|
|
426
|
+
const todayRecord = wellness.find(r => r.date === targetDate);
|
|
427
|
+
const current = todayRecord ? getValue(todayRecord) : null;
|
|
428
|
+
|
|
429
|
+
const numericHistory = history.map(getValue).filter((v): v is number => v !== null);
|
|
430
|
+
|
|
431
|
+
const baselines = computeBaselines(numericHistory, shortWindowDays, longWindowDays);
|
|
432
|
+
|
|
433
|
+
const longSlice = numericHistory.slice(-longWindowDays).sort((a, b) => a - b);
|
|
434
|
+
const position =
|
|
435
|
+
current !== null
|
|
436
|
+
? computePosition(current, baselines, baselines.long_mean !== null ? longSlice : undefined)
|
|
437
|
+
: { z_short: null, z_long: null, percentile_long: null };
|
|
438
|
+
|
|
439
|
+
const trend = computeTrend(allSorted, targetDate, baselines.long_mean, trendWindowDays, metric);
|
|
440
|
+
|
|
441
|
+
let analogs: AnalogsResult = {
|
|
442
|
+
tolerance: analogTolerance, dedup_days: analogDedupDays,
|
|
443
|
+
matches: [], match_count: 0, median_rebound_days: null,
|
|
444
|
+
any_sustained_suppression: false,
|
|
445
|
+
};
|
|
446
|
+
if (current !== null && baselines.long_mean !== null) {
|
|
447
|
+
analogs = computeAnalogs(current, baselines.long_mean, allSorted, analogTolerance, analogDedupDays, metric);
|
|
448
|
+
analogs.matches = analogs.matches.filter(m => m.date !== targetDate);
|
|
449
|
+
analogs.match_count = analogs.matches.length;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const classification = classify(
|
|
453
|
+
position.z_long,
|
|
454
|
+
trend.consecutive_days_below_long_mean,
|
|
455
|
+
analogs,
|
|
456
|
+
thresholds,
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
date: targetDate,
|
|
461
|
+
metric,
|
|
462
|
+
current,
|
|
463
|
+
baselines,
|
|
464
|
+
position,
|
|
465
|
+
trend,
|
|
466
|
+
analogs,
|
|
467
|
+
classification,
|
|
468
|
+
errors,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── fetchWellnessHistory ──────────────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
interface WellnessApiRecord {
|
|
475
|
+
id: string;
|
|
476
|
+
hrv?: number | null;
|
|
477
|
+
hrvSdnn?: number | null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function authHeader(apiKey: string): string {
|
|
481
|
+
return 'Basic ' + Buffer.from(`API_KEY:${apiKey}`).toString('base64');
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export async function fetchWellnessHistory(
|
|
485
|
+
config: IntervalsIcuConfig,
|
|
486
|
+
oldest: string,
|
|
487
|
+
newest: string,
|
|
488
|
+
): Promise<WellnessRecord[]> {
|
|
489
|
+
const url =
|
|
490
|
+
`https://intervals.icu/api/v1/athlete/${config.athlete_id}/wellness` +
|
|
491
|
+
`?oldest=${oldest}&newest=${newest}`;
|
|
492
|
+
const resp = await fetch(url, {
|
|
493
|
+
headers: {
|
|
494
|
+
Authorization: authHeader(config.api_key),
|
|
495
|
+
Accept: 'application/json',
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
if (!resp.ok) {
|
|
499
|
+
throw new Error(`Intervals.icu wellness API returned ${resp.status}: ${resp.statusText}`);
|
|
500
|
+
}
|
|
501
|
+
const data: WellnessApiRecord[] = await resp.json();
|
|
502
|
+
return data.map(r => ({
|
|
503
|
+
date: r.id,
|
|
504
|
+
hrv_rmssd: r.hrv ?? null,
|
|
505
|
+
hrv_sdnn: r.hrvSdnn ?? null,
|
|
506
|
+
}));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
function addDays(date: string, n: number): string {
|
|
512
|
+
const d = new Date(date);
|
|
513
|
+
d.setDate(d.getDate() + n);
|
|
514
|
+
return d.toISOString().split('T')[0];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function parseArgs(args: string[]): {
|
|
518
|
+
configPath: string;
|
|
519
|
+
targetDate: string;
|
|
520
|
+
metric: MetricKey;
|
|
521
|
+
shortWindowDays: number;
|
|
522
|
+
longWindowDays: number;
|
|
523
|
+
trendWindowDays: number;
|
|
524
|
+
analogTolerance: number;
|
|
525
|
+
analogDedupDays: number;
|
|
526
|
+
} {
|
|
527
|
+
const flagValue = (name: string): string | undefined => {
|
|
528
|
+
const i = args.indexOf(name);
|
|
529
|
+
return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined;
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const configPath = flagValue('--config');
|
|
533
|
+
if (!configPath) {
|
|
534
|
+
process.stderr.write(
|
|
535
|
+
'Usage: hrv-trend --config <path> [--date YYYY-MM-DD] [--metric hrv_rmssd|hrv_sdnn]\n' +
|
|
536
|
+
' [--short-window N] [--long-window N] [--trend-window N]\n' +
|
|
537
|
+
' [--analog-tolerance N] [--analog-dedup-days N]\n',
|
|
538
|
+
);
|
|
539
|
+
process.exit(1);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const todayStr = new Date().toISOString().split('T')[0];
|
|
543
|
+
const targetDate = flagValue('--date') ?? todayStr;
|
|
544
|
+
|
|
545
|
+
const metricRaw = flagValue('--metric') ?? 'hrv_rmssd';
|
|
546
|
+
if (metricRaw !== 'hrv_rmssd' && metricRaw !== 'hrv_sdnn') {
|
|
547
|
+
process.stderr.write(`Error: --metric must be hrv_rmssd or hrv_sdnn (got: ${metricRaw})\n`);
|
|
548
|
+
process.exit(1);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return {
|
|
552
|
+
configPath,
|
|
553
|
+
targetDate,
|
|
554
|
+
metric: metricRaw as MetricKey,
|
|
555
|
+
shortWindowDays: Number(flagValue('--short-window') ?? 14),
|
|
556
|
+
longWindowDays: Number(flagValue('--long-window') ?? 60),
|
|
557
|
+
trendWindowDays: Number(flagValue('--trend-window') ?? 7),
|
|
558
|
+
analogTolerance: Number(flagValue('--analog-tolerance') ?? 2),
|
|
559
|
+
analogDedupDays: Number(flagValue('--analog-dedup-days') ?? 3),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const isDirectExecution =
|
|
564
|
+
process.argv[1] !== undefined &&
|
|
565
|
+
fileURLToPath(import.meta.url) === resolve(process.argv[1]);
|
|
566
|
+
|
|
567
|
+
if (isDirectExecution) {
|
|
568
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
569
|
+
(async () => {
|
|
570
|
+
try {
|
|
571
|
+
const config = await loadConfig(parsed.configPath);
|
|
572
|
+
const fetchDays = parsed.longWindowDays + 30;
|
|
573
|
+
const oldest = addDays(parsed.targetDate, -fetchDays);
|
|
574
|
+
const newest = parsed.targetDate;
|
|
575
|
+
const wellness = await fetchWellnessHistory(config, oldest, newest);
|
|
576
|
+
const result = analyzeHrvTrend({
|
|
577
|
+
targetDate: parsed.targetDate,
|
|
578
|
+
wellness,
|
|
579
|
+
metric: parsed.metric,
|
|
580
|
+
shortWindowDays: parsed.shortWindowDays,
|
|
581
|
+
longWindowDays: parsed.longWindowDays,
|
|
582
|
+
trendWindowDays: parsed.trendWindowDays,
|
|
583
|
+
analogTolerance: parsed.analogTolerance,
|
|
584
|
+
analogDedupDays: parsed.analogDedupDays,
|
|
585
|
+
});
|
|
586
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
587
|
+
} catch (err) {
|
|
588
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
589
|
+
process.exit(1);
|
|
590
|
+
}
|
|
591
|
+
})();
|
|
592
|
+
}
|