whoop-up 1.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.
- package/.env.example +3 -0
- package/CLAUDE.md +277 -0
- package/README.md +278 -0
- package/SKILL.md +235 -0
- package/dist/api/client.d.ts +24 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +149 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/endpoints.d.ts +18 -0
- package/dist/api/endpoints.d.ts.map +1 -0
- package/dist/api/endpoints.js +19 -0
- package/dist/api/endpoints.js.map +1 -0
- package/dist/auth/oauth.d.ts +5 -0
- package/dist/auth/oauth.d.ts.map +1 -0
- package/dist/auth/oauth.js +140 -0
- package/dist/auth/oauth.js.map +1 -0
- package/dist/auth/tokens.d.ts +12 -0
- package/dist/auth/tokens.d.ts.map +1 -0
- package/dist/auth/tokens.js +102 -0
- package/dist/auth/tokens.js.map +1 -0
- package/dist/charts/generator.d.ts +8 -0
- package/dist/charts/generator.d.ts.map +1 -0
- package/dist/charts/generator.js +445 -0
- package/dist/charts/generator.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +370 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/types/whoop.d.ts +156 -0
- package/dist/types/whoop.d.ts.map +1 -0
- package/dist/types/whoop.js +3 -0
- package/dist/types/whoop.js.map +1 -0
- package/dist/utils/analysis.d.ts +30 -0
- package/dist/utils/analysis.d.ts.map +1 -0
- package/dist/utils/analysis.js +246 -0
- package/dist/utils/analysis.js.map +1 -0
- package/dist/utils/constants.d.ts +16 -0
- package/dist/utils/constants.d.ts.map +1 -0
- package/dist/utils/constants.js +25 -0
- package/dist/utils/constants.js.map +1 -0
- package/dist/utils/date.d.ts +14 -0
- package/dist/utils/date.d.ts.map +1 -0
- package/dist/utils/date.js +48 -0
- package/dist/utils/date.js.map +1 -0
- package/dist/utils/errors.d.ts +14 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +36 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/format.d.ts +25 -0
- package/dist/utils/format.d.ts.map +1 -0
- package/dist/utils/format.js +262 -0
- package/dist/utils/format.js.map +1 -0
- package/docs/COMMANDS.md +435 -0
- package/package.json +54 -0
- package/references/health_analysis.md +212 -0
- package/src/api/client.ts +207 -0
- package/src/api/endpoints.ts +20 -0
- package/src/auth/oauth.ts +171 -0
- package/src/auth/tokens.ts +120 -0
- package/src/charts/generator.ts +493 -0
- package/src/cli.ts +433 -0
- package/src/index.ts +8 -0
- package/src/types/whoop.ts +192 -0
- package/src/utils/analysis.ts +321 -0
- package/src/utils/constants.ts +32 -0
- package/src/utils/date.ts +58 -0
- package/src/utils/errors.ts +38 -0
- package/src/utils/format.ts +323 -0
- package/tests/cli/cli.test.ts +49 -0
- package/tests/utils/analysis.test.ts +152 -0
- package/tests/utils/date.test.ts +69 -0
- package/tests/utils/errors.test.ts +33 -0
- package/tests/utils/format.test.ts +229 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import type { WhoopData, WhoopSleep, WhoopRecovery, WhoopCycle } from '../types/whoop.js';
|
|
2
|
+
import type { TrendData } from './analysis.js';
|
|
3
|
+
import {
|
|
4
|
+
RECOVERY_GREEN, RECOVERY_YELLOW,
|
|
5
|
+
SLEEP_PERF_GREEN, SLEEP_PERF_YELLOW,
|
|
6
|
+
STRAIN_OPTIMAL_GREEN, STRAIN_OPTIMAL_YELLOW, STRAIN_OPTIMAL_RED,
|
|
7
|
+
STRAIN_TOLERANCE, STRAIN_COLOR_TOLERANCE,
|
|
8
|
+
} from './constants.js';
|
|
9
|
+
|
|
10
|
+
export function formatPretty(data: WhoopData): string {
|
|
11
|
+
const lines: string[] = [];
|
|
12
|
+
lines.push(`📅 ${data.date}`);
|
|
13
|
+
lines.push('');
|
|
14
|
+
|
|
15
|
+
if (data.profile) {
|
|
16
|
+
lines.push(`👤 ${data.profile.first_name} ${data.profile.last_name}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (data.body) {
|
|
20
|
+
const b = data.body;
|
|
21
|
+
lines.push(`📏 ${b.height_meter}m | ${b.weight_kilogram}kg | Max HR: ${b.max_heart_rate}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const scoredRecovery = data.recovery?.filter(r => r.score_state === 'SCORED' && r.score != null);
|
|
25
|
+
if (scoredRecovery?.length) {
|
|
26
|
+
const r = scoredRecovery[0].score!;
|
|
27
|
+
lines.push(`💚 Recovery: ${r.recovery_score}% | HRV: ${r.hrv_rmssd_milli.toFixed(1)}ms | RHR: ${r.resting_heart_rate}bpm`);
|
|
28
|
+
if (r.spo2_percentage != null) lines.push(` SpO2: ${r.spo2_percentage}% | Skin temp: ${r.skin_temp_celsius?.toFixed(1)}°C`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const scoredSleep = data.sleep?.filter(s => s.score_state === 'SCORED' && s.score != null && !s.nap);
|
|
32
|
+
if (scoredSleep?.length) {
|
|
33
|
+
const s = scoredSleep[0].score!;
|
|
34
|
+
const hours = (s.stage_summary.total_in_bed_time_milli / 3600000).toFixed(1);
|
|
35
|
+
lines.push(`😴 Sleep: ${s.sleep_performance_percentage?.toFixed(0) ?? 'N/A'}% | ${hours}h | Efficiency: ${s.sleep_efficiency_percentage?.toFixed(0) ?? 'N/A'}%`);
|
|
36
|
+
lines.push(` REM: ${(s.stage_summary.total_rem_sleep_time_milli / 60000).toFixed(0)}min | Deep: ${(s.stage_summary.total_slow_wave_sleep_time_milli / 60000).toFixed(0)}min`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const scoredWorkout = data.workout?.filter(w => w.score_state === 'SCORED' && w.score != null);
|
|
40
|
+
if (scoredWorkout?.length) {
|
|
41
|
+
lines.push(`🏋️ Workouts:`);
|
|
42
|
+
for (const w of scoredWorkout) {
|
|
43
|
+
const sc = w.score!;
|
|
44
|
+
lines.push(` ${w.sport_name}: Strain ${sc.strain.toFixed(1)} | Avg HR: ${sc.average_heart_rate} | ${(sc.kilojoule / 4.184).toFixed(0)} cal`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const scoredCycle = data.cycle?.filter(c => c.score_state === 'SCORED' && c.score != null);
|
|
49
|
+
if (scoredCycle?.length) {
|
|
50
|
+
const c = scoredCycle[0].score!;
|
|
51
|
+
lines.push(`🔄 Day strain: ${c.strain.toFixed(1)} | ${(c.kilojoule / 4.184).toFixed(0)} cal | Avg HR: ${c.average_heart_rate}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return lines.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SummaryStats {
|
|
58
|
+
days: number;
|
|
59
|
+
avgSleepPerf: number | null;
|
|
60
|
+
avgSleepHours: number | null;
|
|
61
|
+
avgHrv: number | null;
|
|
62
|
+
avgRhr: number | null;
|
|
63
|
+
avgRecovery: number | null;
|
|
64
|
+
avgStrain: number | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function computeSummaryStats(
|
|
68
|
+
sleep: WhoopSleep[],
|
|
69
|
+
recovery: WhoopRecovery[],
|
|
70
|
+
cycle: WhoopCycle[],
|
|
71
|
+
days: number
|
|
72
|
+
): SummaryStats {
|
|
73
|
+
const mainSleep = sleep.filter(s => s.score_state === 'SCORED' && s.score != null && !s.nap);
|
|
74
|
+
const scoredRecovery = recovery.filter(r => r.score_state === 'SCORED' && r.score != null);
|
|
75
|
+
const scoredCycle = cycle.filter(c => c.score_state === 'SCORED' && c.score != null);
|
|
76
|
+
|
|
77
|
+
const sleepPerfs = mainSleep
|
|
78
|
+
.filter(s => s.score!.sleep_performance_percentage != null)
|
|
79
|
+
.map(s => s.score!.sleep_performance_percentage!);
|
|
80
|
+
|
|
81
|
+
const sleepHours = mainSleep
|
|
82
|
+
.filter(s => s.score!.stage_summary != null)
|
|
83
|
+
.map(s => {
|
|
84
|
+
const { total_in_bed_time_milli, total_awake_time_milli } = s.score!.stage_summary;
|
|
85
|
+
return (total_in_bed_time_milli - total_awake_time_milli) / 3600000;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const hrvValues = scoredRecovery.map(r => r.score!.hrv_rmssd_milli);
|
|
89
|
+
const rhrValues = scoredRecovery.map(r => r.score!.resting_heart_rate);
|
|
90
|
+
const recoveryScores = scoredRecovery.map(r => r.score!.recovery_score);
|
|
91
|
+
const strainValues = scoredCycle.map(c => c.score!.strain);
|
|
92
|
+
|
|
93
|
+
const avg = (arr: number[]) => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : null;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
days,
|
|
97
|
+
avgSleepPerf: avg(sleepPerfs),
|
|
98
|
+
avgSleepHours: avg(sleepHours),
|
|
99
|
+
avgHrv: avg(hrvValues),
|
|
100
|
+
avgRhr: avg(rhrValues),
|
|
101
|
+
avgRecovery: avg(recoveryScores),
|
|
102
|
+
avgStrain: avg(strainValues),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function formatSummary(data: WhoopData): string {
|
|
107
|
+
const parts: string[] = [];
|
|
108
|
+
|
|
109
|
+
const scoredRecovery = data.recovery?.filter(r => r.score_state === 'SCORED' && r.score != null);
|
|
110
|
+
if (scoredRecovery?.length) {
|
|
111
|
+
const r = scoredRecovery[0].score!;
|
|
112
|
+
parts.push(`Recovery: ${r.recovery_score}%`);
|
|
113
|
+
parts.push(`HRV: ${r.hrv_rmssd_milli.toFixed(0)}ms`);
|
|
114
|
+
parts.push(`RHR: ${r.resting_heart_rate}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const scoredSleep = data.sleep?.filter(s => s.score_state === 'SCORED' && s.score != null && !s.nap);
|
|
118
|
+
if (scoredSleep?.length) {
|
|
119
|
+
const perf = scoredSleep[0].score!.sleep_performance_percentage;
|
|
120
|
+
if (perf != null) parts.push(`Sleep: ${perf}%`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const scoredCycle = data.cycle?.filter(c => c.score_state === 'SCORED' && c.score != null);
|
|
124
|
+
if (scoredCycle?.length) {
|
|
125
|
+
parts.push(`Strain: ${scoredCycle[0].score!.strain.toFixed(1)}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const scoredWorkout = data.workout?.filter(w => w.score_state === 'SCORED');
|
|
129
|
+
if (scoredWorkout?.length) {
|
|
130
|
+
parts.push(`Workouts: ${scoredWorkout.length}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return parts.length ? `${data.date} | ${parts.join(' | ')}` : `${data.date} | No data`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function statusIcon(value: number, green: number, yellow: number, invert = false): string {
|
|
137
|
+
if (invert) {
|
|
138
|
+
return value <= green ? '🟢' : value <= yellow ? '🟡' : '🔴';
|
|
139
|
+
}
|
|
140
|
+
return value >= green ? '🟢' : value >= yellow ? '🟡' : '🔴';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function formatSummaryColor(data: WhoopData): string {
|
|
144
|
+
const lines: string[] = [`📅 ${data.date}`];
|
|
145
|
+
|
|
146
|
+
const scoredRecovery = data.recovery?.filter(r => r.score_state === 'SCORED' && r.score != null);
|
|
147
|
+
if (scoredRecovery?.length) {
|
|
148
|
+
const r = scoredRecovery[0].score!;
|
|
149
|
+
const icon = statusIcon(r.recovery_score, RECOVERY_GREEN, RECOVERY_YELLOW);
|
|
150
|
+
lines.push(`${icon} Recovery: ${r.recovery_score}% | HRV: ${r.hrv_rmssd_milli.toFixed(0)}ms | RHR: ${r.resting_heart_rate}bpm`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const scoredSleep = data.sleep?.filter(s => s.score_state === 'SCORED' && s.score != null && !s.nap);
|
|
154
|
+
if (scoredSleep?.length) {
|
|
155
|
+
const s = scoredSleep[0].score!;
|
|
156
|
+
const perf = s.sleep_performance_percentage ?? 0;
|
|
157
|
+
const icon = statusIcon(perf, SLEEP_PERF_GREEN, SLEEP_PERF_YELLOW);
|
|
158
|
+
const hours = (s.stage_summary.total_in_bed_time_milli / 3600000).toFixed(1);
|
|
159
|
+
lines.push(`${icon} Sleep: ${perf.toFixed(0)}% | ${hours}h | Efficiency: ${s.sleep_efficiency_percentage?.toFixed(0) ?? 'N/A'}%`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const scoredCycle = data.cycle?.filter(c => c.score_state === 'SCORED' && c.score != null);
|
|
163
|
+
if (scoredCycle?.length) {
|
|
164
|
+
const c = scoredCycle[0].score!;
|
|
165
|
+
const recoveryScore = scoredRecovery?.[0]?.score?.recovery_score ?? 50;
|
|
166
|
+
const optimal = recoveryScore >= RECOVERY_GREEN ? STRAIN_OPTIMAL_GREEN : recoveryScore >= RECOVERY_YELLOW ? STRAIN_OPTIMAL_YELLOW : STRAIN_OPTIMAL_RED;
|
|
167
|
+
const diff = Math.abs(c.strain - optimal);
|
|
168
|
+
const icon = diff <= STRAIN_TOLERANCE ? '🟢' : diff <= STRAIN_COLOR_TOLERANCE ? '🟡' : '🔴';
|
|
169
|
+
lines.push(`${icon} Strain: ${c.strain.toFixed(1)} (optimal: ~${optimal}) | ${(c.kilojoule / 4.184).toFixed(0)} cal`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const scoredWorkout = data.workout?.filter(w => w.score_state === 'SCORED');
|
|
173
|
+
if (scoredWorkout?.length) {
|
|
174
|
+
lines.push(`🏋️ Workouts: ${scoredWorkout.length} | ${scoredWorkout.map(w => w.sport_name).join(', ')}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return lines.join('\n');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function formatSummaryStats(stats: SummaryStats, color = false): string {
|
|
181
|
+
const lines: string[] = [`📊 ${stats.days}-Day Summary`];
|
|
182
|
+
lines.push('');
|
|
183
|
+
|
|
184
|
+
const fmt = (v: number | null, decimals = 0, unit = '') =>
|
|
185
|
+
v != null ? `${v.toFixed(decimals)}${unit}` : 'N/A';
|
|
186
|
+
|
|
187
|
+
if (stats.avgRecovery != null) {
|
|
188
|
+
const icon = color ? (stats.avgRecovery >= RECOVERY_GREEN ? '🟢' : stats.avgRecovery >= RECOVERY_YELLOW ? '🟡' : '🔴') : '💚';
|
|
189
|
+
lines.push(`${icon} Avg Recovery: ${fmt(stats.avgRecovery, 1, '%')}`);
|
|
190
|
+
}
|
|
191
|
+
if (stats.avgHrv != null) {
|
|
192
|
+
lines.push(`💓 Avg HRV: ${fmt(stats.avgHrv, 1, 'ms')}`);
|
|
193
|
+
}
|
|
194
|
+
if (stats.avgRhr != null) {
|
|
195
|
+
lines.push(`❤️ Avg RHR: ${fmt(stats.avgRhr, 1, 'bpm')}`);
|
|
196
|
+
}
|
|
197
|
+
if (stats.avgSleepPerf != null) {
|
|
198
|
+
const icon = color ? (stats.avgSleepPerf >= SLEEP_PERF_GREEN ? '🟢' : stats.avgSleepPerf >= SLEEP_PERF_YELLOW ? '🟡' : '🔴') : '😴';
|
|
199
|
+
lines.push(`${icon} Avg Sleep: ${fmt(stats.avgSleepPerf, 1, '%')} | ${fmt(stats.avgSleepHours, 1, 'h')}`);
|
|
200
|
+
}
|
|
201
|
+
if (stats.avgStrain != null) {
|
|
202
|
+
lines.push(`🔥 Avg Strain: ${fmt(stats.avgStrain, 1)}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return lines.join('\n');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface DashboardData {
|
|
209
|
+
today: WhoopData;
|
|
210
|
+
recoveryHistory: WhoopRecovery[];
|
|
211
|
+
sleepHistory: WhoopSleep[];
|
|
212
|
+
cycleHistory: WhoopCycle[];
|
|
213
|
+
trends: TrendData;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function formatDashboard(d: DashboardData): string {
|
|
217
|
+
const lines: string[] = [];
|
|
218
|
+
const { today, trends } = d;
|
|
219
|
+
|
|
220
|
+
// Pre-compute scored records
|
|
221
|
+
const scoredRecovery = today.recovery?.filter(r => r.score_state === 'SCORED' && r.score != null);
|
|
222
|
+
const scoredSleep = today.sleep?.filter(s => s.score_state === 'SCORED' && s.score != null && !s.nap);
|
|
223
|
+
const scoredCycle = today.cycle?.filter(c => c.score_state === 'SCORED' && c.score != null);
|
|
224
|
+
const scoredWorkout = today.workout?.filter(w => w.score_state === 'SCORED' && w.score != null);
|
|
225
|
+
|
|
226
|
+
// Header
|
|
227
|
+
const name = today.profile ? `${today.profile.first_name} ${today.profile.last_name}` : '';
|
|
228
|
+
lines.push(`📅 ${today.date}${name ? ` | ${name}` : ''}`);
|
|
229
|
+
lines.push('');
|
|
230
|
+
|
|
231
|
+
// Recovery
|
|
232
|
+
lines.push('── Recovery ──────────────────────────');
|
|
233
|
+
if (scoredRecovery?.length) {
|
|
234
|
+
const r = scoredRecovery[0].score!;
|
|
235
|
+
const icon = r.recovery_score >= RECOVERY_GREEN ? '🟢' : r.recovery_score >= RECOVERY_YELLOW ? '🟡' : '🔴';
|
|
236
|
+
const hrvAvg = trends.hrv?.avg ?? 0;
|
|
237
|
+
const rhrAvg = trends.rhr?.avg ?? 0;
|
|
238
|
+
const hrvDelta = hrvAvg > 0 ? ` (${r.hrv_rmssd_milli > hrvAvg ? '↑' : r.hrv_rmssd_milli < hrvAvg ? '↓' : '→'} vs ${hrvAvg.toFixed(0)} avg)` : '';
|
|
239
|
+
const rhrDelta = rhrAvg > 0 ? ` (${r.resting_heart_rate < rhrAvg ? '↓' : r.resting_heart_rate > rhrAvg ? '↑' : '→'} vs ${rhrAvg.toFixed(0)} avg)` : '';
|
|
240
|
+
lines.push(`${icon} ${r.recovery_score}% | HRV: ${r.hrv_rmssd_milli.toFixed(0)}ms${hrvDelta} | RHR: ${r.resting_heart_rate}bpm${rhrDelta}`);
|
|
241
|
+
const extras: string[] = [];
|
|
242
|
+
if (r.spo2_percentage != null) extras.push(`SpO2: ${r.spo2_percentage}%`);
|
|
243
|
+
if (r.skin_temp_celsius != null) extras.push(`Skin: ${r.skin_temp_celsius.toFixed(1)}°C`);
|
|
244
|
+
if (scoredSleep?.length && scoredSleep[0].score!.respiratory_rate != null) {
|
|
245
|
+
extras.push(`Resp: ${scoredSleep[0].score!.respiratory_rate!.toFixed(1)}/min`);
|
|
246
|
+
}
|
|
247
|
+
if (extras.length) lines.push(` ${extras.join(' | ')}`);
|
|
248
|
+
} else {
|
|
249
|
+
lines.push(' No recovery data');
|
|
250
|
+
}
|
|
251
|
+
lines.push('');
|
|
252
|
+
|
|
253
|
+
// Sleep
|
|
254
|
+
lines.push('── Sleep ─────────────────────────────');
|
|
255
|
+
if (scoredSleep?.length) {
|
|
256
|
+
const s = scoredSleep[0].score!;
|
|
257
|
+
const ss = s.stage_summary;
|
|
258
|
+
const totalSleepMs = ss.total_in_bed_time_milli - ss.total_awake_time_milli;
|
|
259
|
+
const totalH = (ss.total_in_bed_time_milli / 3600000).toFixed(1);
|
|
260
|
+
const deepH = (ss.total_slow_wave_sleep_time_milli / 3600000).toFixed(1);
|
|
261
|
+
const remH = (ss.total_rem_sleep_time_milli / 3600000).toFixed(1);
|
|
262
|
+
const lightH = (ss.total_light_sleep_time_milli / 3600000).toFixed(1);
|
|
263
|
+
const deepPct = totalSleepMs > 0 ? Math.round((ss.total_slow_wave_sleep_time_milli / totalSleepMs) * 100) : 0;
|
|
264
|
+
const remPct = totalSleepMs > 0 ? Math.round((ss.total_rem_sleep_time_milli / totalSleepMs) * 100) : 0;
|
|
265
|
+
lines.push(`😴 ${s.sleep_performance_percentage?.toFixed(0) ?? 'N/A'}% | ${totalH}h total | Efficiency: ${s.sleep_efficiency_percentage?.toFixed(0) ?? 'N/A'}%`);
|
|
266
|
+
lines.push(` Deep: ${deepH}h (${deepPct}%) | REM: ${remH}h (${remPct}%) | Light: ${lightH}h`);
|
|
267
|
+
lines.push(` Disturbances: ${ss.disturbance_count} | Consistency: ${s.sleep_consistency_percentage?.toFixed(0) ?? 'N/A'}%`);
|
|
268
|
+
const sn = s.sleep_needed;
|
|
269
|
+
const debtH = (sn.need_from_sleep_debt_milli / 3600000).toFixed(1);
|
|
270
|
+
const needTonightMs = sn.baseline_milli + sn.need_from_sleep_debt_milli + sn.need_from_recent_strain_milli + sn.need_from_recent_nap_milli;
|
|
271
|
+
const needH = (needTonightMs / 3600000).toFixed(1);
|
|
272
|
+
lines.push(` 💤 Sleep debt: ${debtH}h | Need tonight: ${needH}h`);
|
|
273
|
+
} else {
|
|
274
|
+
lines.push(' No sleep data');
|
|
275
|
+
}
|
|
276
|
+
lines.push('');
|
|
277
|
+
|
|
278
|
+
// Strain
|
|
279
|
+
lines.push('── Strain ────────────────────────────');
|
|
280
|
+
if (scoredCycle?.length) {
|
|
281
|
+
const c = scoredCycle[0].score!;
|
|
282
|
+
const recoveryScore = scoredRecovery?.[0]?.score?.recovery_score ?? 50;
|
|
283
|
+
const optimal = recoveryScore >= RECOVERY_GREEN ? STRAIN_OPTIMAL_GREEN : recoveryScore >= RECOVERY_YELLOW ? STRAIN_OPTIMAL_YELLOW : STRAIN_OPTIMAL_RED;
|
|
284
|
+
lines.push(`🔥 ${c.strain.toFixed(1)} / ${optimal} optimal | ${(c.kilojoule / 4.184).toFixed(0)} cal`);
|
|
285
|
+
}
|
|
286
|
+
if (scoredWorkout?.length) {
|
|
287
|
+
for (const w of scoredWorkout) {
|
|
288
|
+
const sc = w.score!;
|
|
289
|
+
const ms = new Date(w.end).getTime() - new Date(w.start).getTime();
|
|
290
|
+
const min = Math.round(ms / 60000);
|
|
291
|
+
const dur = min >= 60 ? `${Math.floor(min / 60)}h${min % 60}m` : `${min}min`;
|
|
292
|
+
lines.push(` ${w.sport_name} (strain ${sc.strain.toFixed(1)}, ${dur})`);
|
|
293
|
+
}
|
|
294
|
+
} else if (!scoredCycle?.length) {
|
|
295
|
+
lines.push(' No strain data');
|
|
296
|
+
}
|
|
297
|
+
lines.push('');
|
|
298
|
+
|
|
299
|
+
// 7-Day Trends
|
|
300
|
+
lines.push('── 7-Day Trends ──────────────────────');
|
|
301
|
+
const arrow = (t: 'up' | 'down' | 'stable') => t === 'up' ? '↑' : t === 'down' ? '↓' : '→';
|
|
302
|
+
if (trends.hrv) {
|
|
303
|
+
const oldest = trends.hrv.values[trends.hrv.values.length - 1];
|
|
304
|
+
lines.push(` HRV: ${oldest?.toFixed(0) ?? '?'} → ${trends.hrv.current.toFixed(0)}ms ${arrow(trends.hrv.trend)} (range ${trends.hrv.min.toFixed(0)}-${trends.hrv.max.toFixed(0)})`);
|
|
305
|
+
}
|
|
306
|
+
if (trends.rhr) {
|
|
307
|
+
const oldest = trends.rhr.values[trends.rhr.values.length - 1];
|
|
308
|
+
lines.push(` RHR: ${oldest ?? '?'} → ${trends.rhr.current}bpm ${arrow(trends.rhr.trend)} (range ${trends.rhr.min}-${trends.rhr.max})`);
|
|
309
|
+
}
|
|
310
|
+
if (trends.recovery) {
|
|
311
|
+
const oldest = trends.recovery.values[trends.recovery.values.length - 1];
|
|
312
|
+
lines.push(` Recovery: ${oldest ?? '?'} → ${trends.recovery.current}% ${arrow(trends.recovery.trend)}`);
|
|
313
|
+
}
|
|
314
|
+
if (trends.sleepHours) {
|
|
315
|
+
const oldest = trends.sleepHours.values[trends.sleepHours.values.length - 1];
|
|
316
|
+
lines.push(` Sleep: ${oldest?.toFixed(1) ?? '?'} → ${trends.sleepHours.current.toFixed(1)}h ${arrow(trends.sleepHours.trend)}`);
|
|
317
|
+
}
|
|
318
|
+
if (trends.strain) {
|
|
319
|
+
lines.push(` Strain: ${trends.strain.avg.toFixed(1)} avg (range ${trends.strain.min.toFixed(1)}-${trends.strain.max.toFixed(1)})`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return lines.join('\n');
|
|
323
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
|
|
7
|
+
const exec = promisify(execFile);
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const pkg = require('../../package.json') as { version: string };
|
|
10
|
+
|
|
11
|
+
const CLI = 'tsx';
|
|
12
|
+
const ENTRY = 'src/index.ts';
|
|
13
|
+
|
|
14
|
+
async function run(...args: string[]) {
|
|
15
|
+
try {
|
|
16
|
+
const { stdout, stderr } = await exec(CLI, [ENTRY, ...args], {
|
|
17
|
+
cwd: process.cwd(),
|
|
18
|
+
timeout: 15000,
|
|
19
|
+
});
|
|
20
|
+
return { stdout, stderr, exitCode: 0 };
|
|
21
|
+
} catch (err: any) {
|
|
22
|
+
return { stdout: err.stdout || '', stderr: err.stderr || '', exitCode: err.code ?? 1 };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('CLI', () => {
|
|
27
|
+
it('--version outputs package.json version', async () => {
|
|
28
|
+
const { stdout } = await run('--version');
|
|
29
|
+
assert.ok(stdout.trim().includes(pkg.version), `Expected version ${pkg.version}, got: ${stdout.trim()}`);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('--help exits with 0 and shows key commands', async () => {
|
|
33
|
+
const { stdout, exitCode } = await run('--help');
|
|
34
|
+
assert.equal(exitCode, 0);
|
|
35
|
+
assert.ok(stdout.includes('sleep'));
|
|
36
|
+
assert.ok(stdout.includes('recovery'));
|
|
37
|
+
assert.ok(stdout.includes('dashboard'));
|
|
38
|
+
assert.ok(stdout.includes('chart'));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('trends --help shows valid days options in description', async () => {
|
|
42
|
+
// Note: '--days 5' cannot be tested directly because Commander.js intercepts
|
|
43
|
+
// the root-level '--days' option before subcommand option parsing.
|
|
44
|
+
// The days validation is covered at unit level; here we verify the help text.
|
|
45
|
+
const { stdout, exitCode } = await run('trends', '--help');
|
|
46
|
+
assert.equal(exitCode, 0);
|
|
47
|
+
assert.ok(stdout.includes('7, 14, or 30'));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { analyzeTrends, generateInsights } from '../../src/utils/analysis.js';
|
|
4
|
+
import type { WhoopRecovery, WhoopSleep, WhoopCycle } from '../../src/types/whoop.js';
|
|
5
|
+
|
|
6
|
+
function makeRecovery(score: number, hrv: number, rhr: number, date: string): WhoopRecovery {
|
|
7
|
+
return {
|
|
8
|
+
cycle_id: 1,
|
|
9
|
+
sleep_id: 'sleep-uuid-1',
|
|
10
|
+
user_id: 1,
|
|
11
|
+
created_at: date,
|
|
12
|
+
updated_at: date,
|
|
13
|
+
score_state: 'SCORED',
|
|
14
|
+
score: {
|
|
15
|
+
recovery_score: score,
|
|
16
|
+
resting_heart_rate: rhr,
|
|
17
|
+
hrv_rmssd_milli: hrv,
|
|
18
|
+
spo2_percentage: 98,
|
|
19
|
+
skin_temp_celsius: 33,
|
|
20
|
+
user_calibrating: false,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeSleep(perf: number, totalMs: number, date: string): WhoopSleep {
|
|
26
|
+
return {
|
|
27
|
+
id: 'sleep-uuid-1',
|
|
28
|
+
user_id: 1,
|
|
29
|
+
created_at: date,
|
|
30
|
+
updated_at: date,
|
|
31
|
+
start: date,
|
|
32
|
+
end: date,
|
|
33
|
+
timezone_offset: '+00:00',
|
|
34
|
+
nap: false,
|
|
35
|
+
score_state: 'SCORED',
|
|
36
|
+
score: {
|
|
37
|
+
sleep_performance_percentage: perf,
|
|
38
|
+
sleep_efficiency_percentage: 90,
|
|
39
|
+
sleep_consistency_percentage: 80,
|
|
40
|
+
respiratory_rate: 15,
|
|
41
|
+
sleep_needed: {
|
|
42
|
+
baseline_milli: 28800000,
|
|
43
|
+
need_from_sleep_debt_milli: 0,
|
|
44
|
+
need_from_recent_strain_milli: 0,
|
|
45
|
+
need_from_recent_nap_milli: 0,
|
|
46
|
+
},
|
|
47
|
+
stage_summary: {
|
|
48
|
+
total_in_bed_time_milli: totalMs,
|
|
49
|
+
total_awake_time_milli: 3600000,
|
|
50
|
+
total_no_data_time_milli: 0,
|
|
51
|
+
total_slow_wave_sleep_time_milli: totalMs * 0.2,
|
|
52
|
+
total_rem_sleep_time_milli: totalMs * 0.2,
|
|
53
|
+
total_light_sleep_time_milli: totalMs * 0.4,
|
|
54
|
+
disturbance_count: 2,
|
|
55
|
+
sleep_cycle_count: 4,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeCycle(strain: number, date: string): WhoopCycle {
|
|
62
|
+
return {
|
|
63
|
+
id: 1,
|
|
64
|
+
user_id: 1,
|
|
65
|
+
created_at: date,
|
|
66
|
+
updated_at: date,
|
|
67
|
+
start: date,
|
|
68
|
+
end: date,
|
|
69
|
+
timezone_offset: '+00:00',
|
|
70
|
+
score_state: 'SCORED',
|
|
71
|
+
score: { strain, kilojoule: 5000, average_heart_rate: 70, max_heart_rate: 150 },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('analyzeTrends', () => {
|
|
76
|
+
it('calculates avg/min/max for recovery', () => {
|
|
77
|
+
const recoveries = [
|
|
78
|
+
makeRecovery(80, 90, 50, '2024-03-15'),
|
|
79
|
+
makeRecovery(60, 70, 55, '2024-03-14'),
|
|
80
|
+
makeRecovery(70, 80, 52, '2024-03-13'),
|
|
81
|
+
];
|
|
82
|
+
const result = analyzeTrends(recoveries, [], [], 7);
|
|
83
|
+
assert.ok(result.recovery);
|
|
84
|
+
assert.equal(result.recovery.avg, 70);
|
|
85
|
+
assert.equal(result.recovery.min, 60);
|
|
86
|
+
assert.equal(result.recovery.max, 80);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns null stats for empty arrays', () => {
|
|
90
|
+
const result = analyzeTrends([], [], [], 7);
|
|
91
|
+
assert.equal(result.recovery, null);
|
|
92
|
+
assert.equal(result.hrv, null);
|
|
93
|
+
assert.equal(result.strain, null);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('handles single record (trend is stable)', () => {
|
|
97
|
+
const result = analyzeTrends(
|
|
98
|
+
[makeRecovery(75, 85, 52, '2024-03-15')],
|
|
99
|
+
[],
|
|
100
|
+
[],
|
|
101
|
+
7,
|
|
102
|
+
);
|
|
103
|
+
assert.ok(result.recovery);
|
|
104
|
+
assert.equal(result.recovery.avg, 75);
|
|
105
|
+
assert.equal(result.recovery.trend, 'stable');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('generateInsights', () => {
|
|
110
|
+
it('produces green recovery insight for high score', () => {
|
|
111
|
+
const insights = generateInsights(
|
|
112
|
+
[makeRecovery(80, 90, 50, '2024-03-15')],
|
|
113
|
+
[],
|
|
114
|
+
[],
|
|
115
|
+
[],
|
|
116
|
+
);
|
|
117
|
+
const rec = insights.find(i => i.category === 'recovery');
|
|
118
|
+
assert.ok(rec);
|
|
119
|
+
assert.equal(rec.level, 'good');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('produces red recovery insight for low score', () => {
|
|
123
|
+
const insights = generateInsights(
|
|
124
|
+
[makeRecovery(20, 40, 65, '2024-03-15')],
|
|
125
|
+
[],
|
|
126
|
+
[],
|
|
127
|
+
[],
|
|
128
|
+
);
|
|
129
|
+
const rec = insights.find(i => i.category === 'recovery');
|
|
130
|
+
assert.ok(rec);
|
|
131
|
+
assert.equal(rec.level, 'critical');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('returns empty array for no data', () => {
|
|
135
|
+
const insights = generateInsights([], [], [], []);
|
|
136
|
+
assert.equal(insights.length, 0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('detects HRV below baseline', () => {
|
|
140
|
+
const recoveries = [
|
|
141
|
+
makeRecovery(50, 30, 55, '2024-03-15'),
|
|
142
|
+
makeRecovery(50, 80, 55, '2024-03-14'),
|
|
143
|
+
makeRecovery(50, 80, 55, '2024-03-13'),
|
|
144
|
+
makeRecovery(50, 80, 55, '2024-03-12'),
|
|
145
|
+
makeRecovery(50, 80, 55, '2024-03-11'),
|
|
146
|
+
];
|
|
147
|
+
const insights = generateInsights(recoveries, [], [], []);
|
|
148
|
+
const hrv = insights.find(i => i.category === 'hrv');
|
|
149
|
+
assert.ok(hrv);
|
|
150
|
+
assert.equal(hrv.level, 'warning');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { getWhoopDay, validateISODate, getDaysAgo, getDateRange, formatDate, nowISO } from '../../src/utils/date.js';
|
|
4
|
+
|
|
5
|
+
describe('getWhoopDay', () => {
|
|
6
|
+
it('returns a date string in YYYY-MM-DD format', () => {
|
|
7
|
+
const result = getWhoopDay();
|
|
8
|
+
assert.match(result, /^\d{4}-\d{2}-\d{2}$/);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('returns previous day for local time before 4am', () => {
|
|
12
|
+
const d = new Date();
|
|
13
|
+
d.setHours(2, 0, 0, 0);
|
|
14
|
+
const result = getWhoopDay(d.toISOString());
|
|
15
|
+
const expected = new Date(d);
|
|
16
|
+
expected.setDate(expected.getDate() - 1);
|
|
17
|
+
assert.equal(result, formatDate(expected));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns same day for local time at or after 4am', () => {
|
|
21
|
+
const d = new Date();
|
|
22
|
+
d.setHours(10, 0, 0, 0);
|
|
23
|
+
const result = getWhoopDay(d.toISOString());
|
|
24
|
+
assert.equal(result, formatDate(d));
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('validateISODate', () => {
|
|
29
|
+
it('accepts valid YYYY-MM-DD', () => {
|
|
30
|
+
assert.equal(validateISODate('2024-03-15'), true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('rejects invalid format', () => {
|
|
34
|
+
assert.equal(validateISODate('03-15-2024'), false);
|
|
35
|
+
assert.equal(validateISODate('2024/03/15'), false);
|
|
36
|
+
assert.equal(validateISODate('not-a-date'), false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('rejects month 13', () => {
|
|
40
|
+
assert.equal(validateISODate('2024-13-01'), false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('getDaysAgo', () => {
|
|
45
|
+
it('returns a date N days in the past', () => {
|
|
46
|
+
const result = getDaysAgo(7);
|
|
47
|
+
const expected = new Date();
|
|
48
|
+
expected.setDate(expected.getDate() - 7);
|
|
49
|
+
assert.equal(result, formatDate(expected));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('getDateRange', () => {
|
|
54
|
+
it('returns start/end ISO strings', () => {
|
|
55
|
+
const range = getDateRange('2024-03-15');
|
|
56
|
+
assert.ok(range.start.includes('T'));
|
|
57
|
+
assert.ok(range.end.includes('T'));
|
|
58
|
+
const start = new Date(range.start);
|
|
59
|
+
const end = new Date(range.end);
|
|
60
|
+
assert.ok(end.getTime() > start.getTime());
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('nowISO', () => {
|
|
65
|
+
it('returns a valid ISO string', () => {
|
|
66
|
+
const result = nowISO();
|
|
67
|
+
assert.ok(!isNaN(new Date(result).getTime()));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { WhoopError, ExitCode } from '../../src/utils/errors.js';
|
|
4
|
+
|
|
5
|
+
describe('ExitCode', () => {
|
|
6
|
+
it('has correct values', () => {
|
|
7
|
+
assert.equal(ExitCode.SUCCESS, 0);
|
|
8
|
+
assert.equal(ExitCode.GENERAL_ERROR, 1);
|
|
9
|
+
assert.equal(ExitCode.AUTH_ERROR, 2);
|
|
10
|
+
assert.equal(ExitCode.RATE_LIMIT, 3);
|
|
11
|
+
assert.equal(ExitCode.NETWORK_ERROR, 4);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('WhoopError', () => {
|
|
16
|
+
it('stores message, code, and statusCode', () => {
|
|
17
|
+
const err = new WhoopError('test', ExitCode.AUTH_ERROR, 401);
|
|
18
|
+
assert.equal(err.message, 'test');
|
|
19
|
+
assert.equal(err.code, ExitCode.AUTH_ERROR);
|
|
20
|
+
assert.equal(err.statusCode, 401);
|
|
21
|
+
assert.equal(err.name, 'WhoopError');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('works without statusCode', () => {
|
|
25
|
+
const err = new WhoopError('msg', ExitCode.GENERAL_ERROR);
|
|
26
|
+
assert.equal(err.statusCode, undefined);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('is instanceof Error', () => {
|
|
30
|
+
const err = new WhoopError('msg', ExitCode.GENERAL_ERROR);
|
|
31
|
+
assert.ok(err instanceof Error);
|
|
32
|
+
});
|
|
33
|
+
});
|