@drakulavich/oura-cli 0.2.1 → 0.3.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/CHANGELOG.md +25 -0
- package/dist/index.js +199 -158
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,30 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0] - 2026-05-13
|
|
10
|
+
|
|
11
|
+
### Changed (CLI surface)
|
|
12
|
+
- `oura-cli report weekly` is now `oura-cli report --period week` (default).
|
|
13
|
+
`--period month` adds a 30-day report with weekly-bucket display.
|
|
14
|
+
(#1, item 9). The old `weekly` subcommand is removed.
|
|
15
|
+
- `oura-cli db import` is now an alias of `oura-cli sync` — both run the same
|
|
16
|
+
handler. (#1, item 8)
|
|
17
|
+
- Empty `200` response from the Oura API now throws `CliError('API_ERROR',
|
|
18
|
+
'Empty response body from Oura API.')` instead of a raw JSON parse error.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- AJV-based test coverage for every `docs/schemas/*.json` file: validates
|
|
22
|
+
JSON syntax, compiles against JSON Schema 2020-12, and asserts the
|
|
23
|
+
describe-manifest shape against `describe.json`. (#1, item 10)
|
|
24
|
+
- Fixture-based tests for `OuraClient.fetch` error paths (401, 403, 429, 500,
|
|
25
|
+
empty body, Bearer redaction). (#1, item 11)
|
|
26
|
+
|
|
27
|
+
### Renamed (internal API)
|
|
28
|
+
- `getWeeklyReport(db)` → `getReport(db, days)` in `src/db/report.ts`.
|
|
29
|
+
- `formatWeeklyReport(data, format)` → `formatReport(data, format, period)` in
|
|
30
|
+
`src/format-report.ts`.
|
|
31
|
+
- Type `WeeklyReportData` → `ReportData`.
|
|
32
|
+
|
|
9
33
|
## [0.2.1] - 2026-05-13
|
|
10
34
|
|
|
11
35
|
### Added
|
|
@@ -97,6 +121,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
97
121
|
- Local SQLite cache at `~/.oura-cli/oura.db`.
|
|
98
122
|
- Auth via `oura-cli login`, `OURA_TOKEN`, `OURA_TOKEN_PATH`, or `~/.oura-token`.
|
|
99
123
|
|
|
124
|
+
[0.3.0]: https://github.com/drakulavich/oura-cli/releases/tag/v0.3.0
|
|
100
125
|
[0.2.1]: https://github.com/drakulavich/oura-cli/releases/tag/v0.2.1
|
|
101
126
|
[0.2.0]: https://github.com/drakulavich/oura-cli/releases/tag/v0.2.0
|
|
102
127
|
[0.1.3]: https://github.com/drakulavich/oura-cli/releases/tag/v0.1.3
|
package/dist/index.js
CHANGED
|
@@ -2722,7 +2722,12 @@ class OuraClient {
|
|
|
2722
2722
|
}
|
|
2723
2723
|
throw new CliError("API_ERROR", `Oura API ${response.status}: ${body}`);
|
|
2724
2724
|
}
|
|
2725
|
-
|
|
2725
|
+
let json;
|
|
2726
|
+
try {
|
|
2727
|
+
json = await response.json();
|
|
2728
|
+
} catch {
|
|
2729
|
+
throw new CliError("API_ERROR", "Empty response body from Oura API.");
|
|
2730
|
+
}
|
|
2726
2731
|
return json.data ?? [];
|
|
2727
2732
|
}
|
|
2728
2733
|
}
|
|
@@ -2809,8 +2814,8 @@ function createApiCommand(name, description, endpoint) {
|
|
|
2809
2814
|
}
|
|
2810
2815
|
|
|
2811
2816
|
// src/commands/db.ts
|
|
2812
|
-
import { mkdirSync as
|
|
2813
|
-
import { dirname } from "path";
|
|
2817
|
+
import { mkdirSync as mkdirSync3, unlinkSync } from "fs";
|
|
2818
|
+
import { dirname as dirname2 } from "path";
|
|
2814
2819
|
|
|
2815
2820
|
// src/lib/db.ts
|
|
2816
2821
|
import { Database } from "bun:sqlite";
|
|
@@ -2999,89 +3004,6 @@ function ensureSchema2(db) {
|
|
|
2999
3004
|
ensureSchema(db, MIGRATIONS);
|
|
3000
3005
|
}
|
|
3001
3006
|
|
|
3002
|
-
// src/db/import.ts
|
|
3003
|
-
async function importDaily(db, client, log) {
|
|
3004
|
-
const _log = log ?? (() => {});
|
|
3005
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
3006
|
-
const lastDates = [];
|
|
3007
|
-
for (const tbl of ["daily_sleep", "daily_readiness", "daily_activity"]) {
|
|
3008
|
-
const row = db.query(`SELECT MAX(day) as d FROM ${tbl}`).get();
|
|
3009
|
-
if (row?.d)
|
|
3010
|
-
lastDates.push(row.d);
|
|
3011
|
-
}
|
|
3012
|
-
const startDate = lastDates.length > 0 ? lastDates.sort()[0] : new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10);
|
|
3013
|
-
_log(`Syncing from ${startDate} to ${today}`);
|
|
3014
|
-
const counts = {};
|
|
3015
|
-
const sleepData = await client.fetch("daily_sleep", startDate, today);
|
|
3016
|
-
const insertSleep = db.query("INSERT OR REPLACE INTO daily_sleep VALUES (?,?,?,?,?)");
|
|
3017
|
-
for (const s of sleepData) {
|
|
3018
|
-
insertSleep.run(s.id, s.day, s.score, JSON.stringify(s.contributors), s.timestamp);
|
|
3019
|
-
_log(` + sleep ${s.day}`);
|
|
3020
|
-
}
|
|
3021
|
-
counts.daily_sleep = sleepData.length;
|
|
3022
|
-
const readinessData = await client.fetch("daily_readiness", startDate, today);
|
|
3023
|
-
const insertReadiness = db.query("INSERT OR REPLACE INTO daily_readiness VALUES (?,?,?,?,?,?,?)");
|
|
3024
|
-
for (const r of readinessData) {
|
|
3025
|
-
insertReadiness.run(r.id, r.day, r.score, JSON.stringify(r.contributors), r.temperature_deviation, r.temperature_trend_deviation, r.timestamp);
|
|
3026
|
-
_log(` + readiness ${r.day}`);
|
|
3027
|
-
}
|
|
3028
|
-
counts.daily_readiness = readinessData.length;
|
|
3029
|
-
const activityData = await client.fetch("daily_activity", startDate, today);
|
|
3030
|
-
const insertActivity = db.query("INSERT OR REPLACE INTO daily_activity VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
|
|
3031
|
-
for (const a of activityData) {
|
|
3032
|
-
insertActivity.run(a.id, a.day, a.score, a.active_calories, a.steps, a.equivalent_walking_distance, a.high_activity_time, a.medium_activity_time, a.low_activity_time, a.sedentary_time, a.total_calories, a.target_calories, JSON.stringify(a.contributors), a.timestamp);
|
|
3033
|
-
_log(` + activity ${a.day}`);
|
|
3034
|
-
}
|
|
3035
|
-
counts.daily_activity = activityData.length;
|
|
3036
|
-
const hrData = await client.fetch("heartrate", today, today);
|
|
3037
|
-
const insertHr = db.query("INSERT OR IGNORE INTO heartrate VALUES (?,?,?,?)");
|
|
3038
|
-
for (const h of hrData) {
|
|
3039
|
-
const day = h.timestamp.slice(0, 10);
|
|
3040
|
-
insertHr.run(h.timestamp, h.bpm, h.source, day);
|
|
3041
|
-
}
|
|
3042
|
-
counts.heartrate = hrData.length;
|
|
3043
|
-
if (hrData.length > 0)
|
|
3044
|
-
_log(` + heartrate ${hrData.length} records`);
|
|
3045
|
-
const spo2Data = await client.fetch("daily_spo2", startDate, today);
|
|
3046
|
-
const insertSpo2 = db.query("INSERT OR REPLACE INTO daily_spo2 VALUES (?,?,?,?)");
|
|
3047
|
-
for (const s of spo2Data) {
|
|
3048
|
-
const avg = s.spo2_percentage?.average ?? null;
|
|
3049
|
-
insertSpo2.run(s.id, s.day, avg, s.breathing_disturbance_index);
|
|
3050
|
-
_log(` + spo2 ${s.day}`);
|
|
3051
|
-
}
|
|
3052
|
-
counts.daily_spo2 = spo2Data.length;
|
|
3053
|
-
const stressData = await client.fetch("daily_stress", startDate, today);
|
|
3054
|
-
const insertStress = db.query("INSERT OR REPLACE INTO daily_stress VALUES (?,?,?,?,?)");
|
|
3055
|
-
for (const s of stressData) {
|
|
3056
|
-
insertStress.run(s.id, s.day, s.day_summary, s.recovery_high, s.stress_high);
|
|
3057
|
-
_log(` + stress ${s.day}`);
|
|
3058
|
-
}
|
|
3059
|
-
counts.daily_stress = stressData.length;
|
|
3060
|
-
const workoutData = await client.fetch("workout", startDate, today);
|
|
3061
|
-
const insertWorkout = db.query("INSERT OR REPLACE INTO workouts VALUES (?,?,?,?,?,?,?,?,?,?)");
|
|
3062
|
-
for (const w of workoutData) {
|
|
3063
|
-
insertWorkout.run(w.id, w.day, w.activity, w.calories, w.distance, w.start_datetime, w.end_datetime, w.intensity, w.label ?? "", w.source);
|
|
3064
|
-
_log(` + workout ${w.day} ${w.activity}`);
|
|
3065
|
-
}
|
|
3066
|
-
counts.workouts = workoutData.length;
|
|
3067
|
-
const sleepPeriods = await client.fetch("sleep", startDate, today);
|
|
3068
|
-
const insertSleepModel = db.query(`INSERT OR REPLACE INTO sleep_model VALUES (${Array(19).fill("?").join(",")})`);
|
|
3069
|
-
for (const sp of sleepPeriods) {
|
|
3070
|
-
insertSleepModel.run(sp.id, sp.day, sp.average_breath, sp.average_heart_rate, sp.average_hrv, sp.awake_time, sp.bedtime_end, sp.bedtime_start, sp.deep_sleep_duration, sp.efficiency, sp.latency, sp.light_sleep_duration, sp.lowest_heart_rate, sp.period, sp.rem_sleep_duration, sp.restless_periods, sp.time_in_bed, sp.total_sleep_duration, sp.type);
|
|
3071
|
-
_log(` + sleep_period ${sp.day} (${sp.type})`);
|
|
3072
|
-
}
|
|
3073
|
-
counts.sleep_model = sleepPeriods.length;
|
|
3074
|
-
const cvData = await client.fetch("daily_cardiovascular_age", startDate, today);
|
|
3075
|
-
const insertCv = db.query("INSERT OR REPLACE INTO cardiovascular_age VALUES (?,?,?)");
|
|
3076
|
-
for (const c of cvData) {
|
|
3077
|
-
insertCv.run(c.id, c.day, c.vascular_age);
|
|
3078
|
-
_log(` + cardiovascular_age ${c.day}`);
|
|
3079
|
-
}
|
|
3080
|
-
counts.cardiovascular_age = cvData.length;
|
|
3081
|
-
_log("Import complete.");
|
|
3082
|
-
return { startDate, endDate: today, counts };
|
|
3083
|
-
}
|
|
3084
|
-
|
|
3085
3007
|
// src/db/csv-import.ts
|
|
3086
3008
|
import { readFileSync as readFileSync2, existsSync } from "fs";
|
|
3087
3009
|
import { join } from "path";
|
|
@@ -3392,23 +3314,124 @@ function resolveFormat({ explicit, isTty }) {
|
|
|
3392
3314
|
throw new CliError("BAD_ARGS", `Unknown --format value: "${explicit}". Use "table" or "json".`);
|
|
3393
3315
|
}
|
|
3394
3316
|
|
|
3317
|
+
// src/commands/sync.ts
|
|
3318
|
+
import { mkdirSync as mkdirSync2 } from "fs";
|
|
3319
|
+
import { dirname } from "path";
|
|
3320
|
+
|
|
3321
|
+
// src/db/import.ts
|
|
3322
|
+
async function importDaily(db, client, log) {
|
|
3323
|
+
const _log = log ?? (() => {});
|
|
3324
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
3325
|
+
const lastDates = [];
|
|
3326
|
+
for (const tbl of ["daily_sleep", "daily_readiness", "daily_activity"]) {
|
|
3327
|
+
const row = db.query(`SELECT MAX(day) as d FROM ${tbl}`).get();
|
|
3328
|
+
if (row?.d)
|
|
3329
|
+
lastDates.push(row.d);
|
|
3330
|
+
}
|
|
3331
|
+
const startDate = lastDates.length > 0 ? lastDates.sort()[0] : new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10);
|
|
3332
|
+
_log(`Syncing from ${startDate} to ${today}`);
|
|
3333
|
+
const counts = {};
|
|
3334
|
+
const sleepData = await client.fetch("daily_sleep", startDate, today);
|
|
3335
|
+
const insertSleep = db.query("INSERT OR REPLACE INTO daily_sleep VALUES (?,?,?,?,?)");
|
|
3336
|
+
for (const s of sleepData) {
|
|
3337
|
+
insertSleep.run(s.id, s.day, s.score, JSON.stringify(s.contributors), s.timestamp);
|
|
3338
|
+
_log(` + sleep ${s.day}`);
|
|
3339
|
+
}
|
|
3340
|
+
counts.daily_sleep = sleepData.length;
|
|
3341
|
+
const readinessData = await client.fetch("daily_readiness", startDate, today);
|
|
3342
|
+
const insertReadiness = db.query("INSERT OR REPLACE INTO daily_readiness VALUES (?,?,?,?,?,?,?)");
|
|
3343
|
+
for (const r of readinessData) {
|
|
3344
|
+
insertReadiness.run(r.id, r.day, r.score, JSON.stringify(r.contributors), r.temperature_deviation, r.temperature_trend_deviation, r.timestamp);
|
|
3345
|
+
_log(` + readiness ${r.day}`);
|
|
3346
|
+
}
|
|
3347
|
+
counts.daily_readiness = readinessData.length;
|
|
3348
|
+
const activityData = await client.fetch("daily_activity", startDate, today);
|
|
3349
|
+
const insertActivity = db.query("INSERT OR REPLACE INTO daily_activity VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
|
|
3350
|
+
for (const a of activityData) {
|
|
3351
|
+
insertActivity.run(a.id, a.day, a.score, a.active_calories, a.steps, a.equivalent_walking_distance, a.high_activity_time, a.medium_activity_time, a.low_activity_time, a.sedentary_time, a.total_calories, a.target_calories, JSON.stringify(a.contributors), a.timestamp);
|
|
3352
|
+
_log(` + activity ${a.day}`);
|
|
3353
|
+
}
|
|
3354
|
+
counts.daily_activity = activityData.length;
|
|
3355
|
+
const hrData = await client.fetch("heartrate", today, today);
|
|
3356
|
+
const insertHr = db.query("INSERT OR IGNORE INTO heartrate VALUES (?,?,?,?)");
|
|
3357
|
+
for (const h of hrData) {
|
|
3358
|
+
const day = h.timestamp.slice(0, 10);
|
|
3359
|
+
insertHr.run(h.timestamp, h.bpm, h.source, day);
|
|
3360
|
+
}
|
|
3361
|
+
counts.heartrate = hrData.length;
|
|
3362
|
+
if (hrData.length > 0)
|
|
3363
|
+
_log(` + heartrate ${hrData.length} records`);
|
|
3364
|
+
const spo2Data = await client.fetch("daily_spo2", startDate, today);
|
|
3365
|
+
const insertSpo2 = db.query("INSERT OR REPLACE INTO daily_spo2 VALUES (?,?,?,?)");
|
|
3366
|
+
for (const s of spo2Data) {
|
|
3367
|
+
const avg = s.spo2_percentage?.average ?? null;
|
|
3368
|
+
insertSpo2.run(s.id, s.day, avg, s.breathing_disturbance_index);
|
|
3369
|
+
_log(` + spo2 ${s.day}`);
|
|
3370
|
+
}
|
|
3371
|
+
counts.daily_spo2 = spo2Data.length;
|
|
3372
|
+
const stressData = await client.fetch("daily_stress", startDate, today);
|
|
3373
|
+
const insertStress = db.query("INSERT OR REPLACE INTO daily_stress VALUES (?,?,?,?,?)");
|
|
3374
|
+
for (const s of stressData) {
|
|
3375
|
+
insertStress.run(s.id, s.day, s.day_summary, s.recovery_high, s.stress_high);
|
|
3376
|
+
_log(` + stress ${s.day}`);
|
|
3377
|
+
}
|
|
3378
|
+
counts.daily_stress = stressData.length;
|
|
3379
|
+
const workoutData = await client.fetch("workout", startDate, today);
|
|
3380
|
+
const insertWorkout = db.query("INSERT OR REPLACE INTO workouts VALUES (?,?,?,?,?,?,?,?,?,?)");
|
|
3381
|
+
for (const w of workoutData) {
|
|
3382
|
+
insertWorkout.run(w.id, w.day, w.activity, w.calories, w.distance, w.start_datetime, w.end_datetime, w.intensity, w.label ?? "", w.source);
|
|
3383
|
+
_log(` + workout ${w.day} ${w.activity}`);
|
|
3384
|
+
}
|
|
3385
|
+
counts.workouts = workoutData.length;
|
|
3386
|
+
const sleepPeriods = await client.fetch("sleep", startDate, today);
|
|
3387
|
+
const insertSleepModel = db.query(`INSERT OR REPLACE INTO sleep_model VALUES (${Array(19).fill("?").join(",")})`);
|
|
3388
|
+
for (const sp of sleepPeriods) {
|
|
3389
|
+
insertSleepModel.run(sp.id, sp.day, sp.average_breath, sp.average_heart_rate, sp.average_hrv, sp.awake_time, sp.bedtime_end, sp.bedtime_start, sp.deep_sleep_duration, sp.efficiency, sp.latency, sp.light_sleep_duration, sp.lowest_heart_rate, sp.period, sp.rem_sleep_duration, sp.restless_periods, sp.time_in_bed, sp.total_sleep_duration, sp.type);
|
|
3390
|
+
_log(` + sleep_period ${sp.day} (${sp.type})`);
|
|
3391
|
+
}
|
|
3392
|
+
counts.sleep_model = sleepPeriods.length;
|
|
3393
|
+
const cvData = await client.fetch("daily_cardiovascular_age", startDate, today);
|
|
3394
|
+
const insertCv = db.query("INSERT OR REPLACE INTO cardiovascular_age VALUES (?,?,?)");
|
|
3395
|
+
for (const c of cvData) {
|
|
3396
|
+
insertCv.run(c.id, c.day, c.vascular_age);
|
|
3397
|
+
_log(` + cardiovascular_age ${c.day}`);
|
|
3398
|
+
}
|
|
3399
|
+
counts.cardiovascular_age = cvData.length;
|
|
3400
|
+
_log("Import complete.");
|
|
3401
|
+
return { startDate, endDate: today, counts };
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
// src/commands/sync.ts
|
|
3405
|
+
async function runSync(opts) {
|
|
3406
|
+
const format = resolveFormat({ explicit: opts.format, isTty: process.stdout.isTTY === true });
|
|
3407
|
+
const dbPath = getDbPath2({ dbPath: opts.db });
|
|
3408
|
+
mkdirSync2(dirname(dbPath), { recursive: true });
|
|
3409
|
+
const db = openDatabase2({ dbPath: opts.db });
|
|
3410
|
+
ensureSchema2(db);
|
|
3411
|
+
const client = getClient(opts);
|
|
3412
|
+
const log = format === "table" ? console.log : undefined;
|
|
3413
|
+
const importResult = await importDaily(db, client, log);
|
|
3414
|
+
const today = getDaySummary(db, todayDate());
|
|
3415
|
+
db.close();
|
|
3416
|
+
if (format === "json") {
|
|
3417
|
+
console.log(JSON.stringify({ import: importResult, today }, null, 2));
|
|
3418
|
+
} else {
|
|
3419
|
+
console.log(formatDaySummary(today, format));
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
function syncCommand() {
|
|
3423
|
+
return new Command("sync").description("Import latest data from Oura API and return today's summary").action(async (_, command) => {
|
|
3424
|
+
const opts = command.parent.opts();
|
|
3425
|
+
await runSync(opts);
|
|
3426
|
+
});
|
|
3427
|
+
}
|
|
3428
|
+
|
|
3395
3429
|
// src/commands/db.ts
|
|
3396
3430
|
function dbCommand() {
|
|
3397
3431
|
const cmd = new Command("db").description("Query and manage the local SQLite database");
|
|
3398
|
-
cmd.command("import").description("Sync new data from Oura API into local database").action(async (_, command) => {
|
|
3432
|
+
cmd.command("import").description("Sync new data from Oura API into local database (alias of sync)").action(async (_, command) => {
|
|
3399
3433
|
const opts = command.parent.parent.opts();
|
|
3400
|
-
|
|
3401
|
-
const dbPath = getDbPath2({ dbPath: opts.db });
|
|
3402
|
-
mkdirSync2(dirname(dbPath), { recursive: true });
|
|
3403
|
-
const db = openDatabase2({ dbPath: opts.db });
|
|
3404
|
-
ensureSchema2(db);
|
|
3405
|
-
const client = getClient(opts);
|
|
3406
|
-
const log = format === "table" ? console.log : undefined;
|
|
3407
|
-
const result = await importDaily(db, client, log);
|
|
3408
|
-
if (format === "json") {
|
|
3409
|
-
console.log(JSON.stringify(result, null, 2));
|
|
3410
|
-
}
|
|
3411
|
-
db.close();
|
|
3434
|
+
await runSync(opts);
|
|
3412
3435
|
});
|
|
3413
3436
|
cmd.command("today").description("Today's summary from local database").action((_, command) => {
|
|
3414
3437
|
const opts = command.parent.parent.opts();
|
|
@@ -3479,7 +3502,7 @@ function dbCommand() {
|
|
|
3479
3502
|
} catch {}
|
|
3480
3503
|
const log = format === "table" ? console.log : undefined;
|
|
3481
3504
|
log?.("Database deleted.");
|
|
3482
|
-
|
|
3505
|
+
mkdirSync3(dirname2(dbPath), { recursive: true });
|
|
3483
3506
|
const db = openDatabase2({ dbPath: opts.db });
|
|
3484
3507
|
ensureSchema2(db);
|
|
3485
3508
|
importFromCSV(db, log ?? (() => {}));
|
|
@@ -3491,30 +3514,6 @@ function dbCommand() {
|
|
|
3491
3514
|
return cmd;
|
|
3492
3515
|
}
|
|
3493
3516
|
|
|
3494
|
-
// src/commands/sync.ts
|
|
3495
|
-
import { mkdirSync as mkdirSync3 } from "fs";
|
|
3496
|
-
import { dirname as dirname2 } from "path";
|
|
3497
|
-
function syncCommand() {
|
|
3498
|
-
return new Command("sync").description("Import latest data from Oura API and return today's summary").action(async (_, command) => {
|
|
3499
|
-
const opts = command.parent.opts();
|
|
3500
|
-
const format = resolveFormat({ explicit: opts.format, isTty: process.stdout.isTTY === true });
|
|
3501
|
-
const dbPath = getDbPath2({ dbPath: opts.db });
|
|
3502
|
-
mkdirSync3(dirname2(dbPath), { recursive: true });
|
|
3503
|
-
const db = openDatabase2({ dbPath: opts.db });
|
|
3504
|
-
ensureSchema2(db);
|
|
3505
|
-
const client = getClient(opts);
|
|
3506
|
-
const log = format === "table" ? console.log : undefined;
|
|
3507
|
-
const importResult = await importDaily(db, client, log);
|
|
3508
|
-
const today = getDaySummary(db, todayDate());
|
|
3509
|
-
db.close();
|
|
3510
|
-
if (format === "json") {
|
|
3511
|
-
console.log(JSON.stringify({ import: importResult, today }, null, 2));
|
|
3512
|
-
} else {
|
|
3513
|
-
console.log(formatDaySummary(today, format));
|
|
3514
|
-
}
|
|
3515
|
-
});
|
|
3516
|
-
}
|
|
3517
|
-
|
|
3518
3517
|
// src/db/report.ts
|
|
3519
3518
|
function dayLabel(dateStr) {
|
|
3520
3519
|
const d = new Date(dateStr + "T12:00:00Z");
|
|
@@ -3524,20 +3523,21 @@ function dayLabel(dateStr) {
|
|
|
3524
3523
|
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
3525
3524
|
return `${day} ${dd}/${mm}`;
|
|
3526
3525
|
}
|
|
3527
|
-
function
|
|
3526
|
+
function getReport(db, days) {
|
|
3527
|
+
const period = days <= 7 ? "week" : "month";
|
|
3528
3528
|
const today = new Date;
|
|
3529
3529
|
const weekEnd = today.toISOString().slice(0, 10);
|
|
3530
|
-
const weekStartDate = new Date(today.getTime() -
|
|
3530
|
+
const weekStartDate = new Date(today.getTime() - (days - 1) * 86400000);
|
|
3531
3531
|
const weekStart = weekStartDate.toISOString().slice(0, 10);
|
|
3532
|
-
const prevWeekEnd = new Date(today.getTime() -
|
|
3533
|
-
const prevWeekStart = new Date(today.getTime() -
|
|
3534
|
-
const
|
|
3535
|
-
for (let i =
|
|
3532
|
+
const prevWeekEnd = new Date(today.getTime() - days * 86400000).toISOString().slice(0, 10);
|
|
3533
|
+
const prevWeekStart = new Date(today.getTime() - (days * 2 - 1) * 86400000).toISOString().slice(0, 10);
|
|
3534
|
+
const dailyRows = [];
|
|
3535
|
+
for (let i = days - 1;i >= 0; i--) {
|
|
3536
3536
|
const d = new Date(today.getTime() - i * 86400000).toISOString().slice(0, 10);
|
|
3537
3537
|
const sl = db.query("SELECT score FROM daily_sleep WHERE day=?").get(d);
|
|
3538
3538
|
const rd = db.query("SELECT score FROM daily_readiness WHERE day=?").get(d);
|
|
3539
3539
|
const ac = db.query("SELECT score, steps FROM daily_activity WHERE day=?").get(d);
|
|
3540
|
-
|
|
3540
|
+
dailyRows.push({
|
|
3541
3541
|
day: d,
|
|
3542
3542
|
dayLabel: dayLabel(d),
|
|
3543
3543
|
sleep: sl?.score ?? null,
|
|
@@ -3598,7 +3598,7 @@ function getWeeklyReport(db) {
|
|
|
3598
3598
|
} else if (avgSteps.avg !== null && avgSteps.avg >= 1e4) {
|
|
3599
3599
|
recommendations.push("steps_great");
|
|
3600
3600
|
}
|
|
3601
|
-
return { weekStart, weekEnd, days, averages, spo2, patterns: { lowSleep, lowReadiness, highActivity }, sleepDetails, recommendations };
|
|
3601
|
+
return { period, weekStart, weekEnd, days: dailyRows, averages, spo2, patterns: { lowSleep, lowReadiness, highActivity }, sleepDetails, recommendations };
|
|
3602
3602
|
}
|
|
3603
3603
|
|
|
3604
3604
|
// src/format-report.ts
|
|
@@ -3622,27 +3622,66 @@ var RECOMMENDATIONS = {
|
|
|
3622
3622
|
steps_low: "Low movement \u2014 aim for 8-10k steps daily.",
|
|
3623
3623
|
steps_great: "Great activity! Step goal achieved."
|
|
3624
3624
|
};
|
|
3625
|
-
function
|
|
3625
|
+
function bucketDaysIntoWeeks(days) {
|
|
3626
|
+
const buckets = [];
|
|
3627
|
+
for (let i = 0;i < days.length; i += 7) {
|
|
3628
|
+
const chunk = days.slice(i, i + 7);
|
|
3629
|
+
const weekOf = chunk[0].day;
|
|
3630
|
+
const sleepVals = chunk.map((d) => d.sleep).filter((v) => v !== null);
|
|
3631
|
+
const readinessVals = chunk.map((d) => d.readiness).filter((v) => v !== null);
|
|
3632
|
+
const activityVals = chunk.map((d) => d.activity).filter((v) => v !== null);
|
|
3633
|
+
const stepsVals = chunk.map((d) => d.steps).filter((v) => v !== null);
|
|
3634
|
+
buckets.push({
|
|
3635
|
+
weekOf,
|
|
3636
|
+
avgSleep: sleepVals.length > 0 ? sleepVals.reduce((a, b) => a + b, 0) / sleepVals.length : null,
|
|
3637
|
+
avgReadiness: readinessVals.length > 0 ? readinessVals.reduce((a, b) => a + b, 0) / readinessVals.length : null,
|
|
3638
|
+
avgActivity: activityVals.length > 0 ? activityVals.reduce((a, b) => a + b, 0) / activityVals.length : null,
|
|
3639
|
+
totalSteps: stepsVals.length > 0 ? stepsVals.reduce((a, b) => a + b, 0) : null
|
|
3640
|
+
});
|
|
3641
|
+
}
|
|
3642
|
+
return buckets;
|
|
3643
|
+
}
|
|
3644
|
+
function formatReport(data, format, period) {
|
|
3626
3645
|
if (format === "json")
|
|
3627
3646
|
return JSON.stringify(data, null, 2);
|
|
3628
3647
|
const lines = [];
|
|
3629
3648
|
lines.push("");
|
|
3630
|
-
|
|
3649
|
+
if (period === "week") {
|
|
3650
|
+
lines.push(source_default.bold(" Oura Weekly Report"));
|
|
3651
|
+
} else {
|
|
3652
|
+
lines.push(source_default.bold(" Oura Monthly Report"));
|
|
3653
|
+
}
|
|
3631
3654
|
lines.push(source_default.gray(` ${data.weekStart} \u2014 ${data.weekEnd}`));
|
|
3632
3655
|
lines.push("");
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3656
|
+
if (period === "week") {
|
|
3657
|
+
lines.push(source_default.bold(" Last 7 Days:"));
|
|
3658
|
+
lines.push(source_default.gray(" " + "\u2500".repeat(52)));
|
|
3659
|
+
lines.push(` ${"Day".padEnd(10)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(8)}`);
|
|
3660
|
+
lines.push(source_default.gray(" " + "\u2500".repeat(52)));
|
|
3661
|
+
for (const d of data.days) {
|
|
3662
|
+
const sleep = d.sleep !== null ? d.sleep >= 85 ? source_default.green(String(d.sleep)) : d.sleep >= 70 ? source_default.yellow(String(d.sleep)) : source_default.red(String(d.sleep)) : source_default.gray("\u2014");
|
|
3663
|
+
const ready = d.readiness !== null ? d.readiness >= 85 ? source_default.green(String(d.readiness)) : d.readiness >= 70 ? source_default.yellow(String(d.readiness)) : source_default.red(String(d.readiness)) : source_default.gray("\u2014");
|
|
3664
|
+
const active = d.activity !== null ? d.activity >= 85 ? source_default.green(String(d.activity)) : d.activity >= 70 ? source_default.yellow(String(d.activity)) : source_default.red(String(d.activity)) : source_default.gray("\u2014");
|
|
3665
|
+
const steps = d.steps !== null ? d.steps.toLocaleString() : source_default.gray("\u2014");
|
|
3666
|
+
lines.push(` ${d.dayLabel.padEnd(10)} ${sleep.padStart(6)} ${ready.padStart(6)} ${active.padStart(7)} ${steps.padStart(8)}`);
|
|
3667
|
+
}
|
|
3668
|
+
lines.push("");
|
|
3669
|
+
} else {
|
|
3670
|
+
const buckets = bucketDaysIntoWeeks(data.days);
|
|
3671
|
+
lines.push(source_default.bold(" Last 30 Days:"));
|
|
3672
|
+
lines.push(source_default.gray(" " + "\u2500".repeat(60)));
|
|
3673
|
+
lines.push(` ${"Week of".padEnd(12)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(10)}`);
|
|
3674
|
+
lines.push(source_default.gray(" " + "\u2500".repeat(60)));
|
|
3675
|
+
for (const b of buckets) {
|
|
3676
|
+
const sleep = b.avgSleep !== null ? b.avgSleep.toFixed(0) : "\u2014";
|
|
3677
|
+
const ready = b.avgReadiness !== null ? b.avgReadiness.toFixed(0) : "\u2014";
|
|
3678
|
+
const active = b.avgActivity !== null ? b.avgActivity.toFixed(0) : "\u2014";
|
|
3679
|
+
const steps = b.totalSteps !== null ? b.totalSteps.toLocaleString() : "\u2014";
|
|
3680
|
+
lines.push(` ${b.weekOf.padEnd(12)} ${sleep.padStart(6)} ${ready.padStart(6)} ${active.padStart(7)} ${steps.padStart(10)}`);
|
|
3681
|
+
}
|
|
3682
|
+
lines.push("");
|
|
3643
3683
|
}
|
|
3644
|
-
lines.push("");
|
|
3645
|
-
lines.push(source_default.bold(" Averages (this week vs previous):"));
|
|
3684
|
+
lines.push(source_default.bold(" Averages (this period vs previous):"));
|
|
3646
3685
|
for (const a of data.averages) {
|
|
3647
3686
|
const avgStr = fmtNumber(a.avg, a.isSteps);
|
|
3648
3687
|
let changeStr = "";
|
|
@@ -3691,17 +3730,20 @@ function formatWeeklyReport(data, format) {
|
|
|
3691
3730
|
|
|
3692
3731
|
// src/commands/report.ts
|
|
3693
3732
|
function reportCommand() {
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
const
|
|
3733
|
+
return new Command("report").description("Generate a narrative health report from local data.").option("--period <name>", "Report window: week | month", "week").action((_, command) => {
|
|
3734
|
+
const opts = command.parent.opts();
|
|
3735
|
+
const period = command.opts().period ?? "week";
|
|
3736
|
+
if (period !== "week" && period !== "month") {
|
|
3737
|
+
throw new CliError("BAD_ARGS", `--period must be "week" or "month", got "${period}".`);
|
|
3738
|
+
}
|
|
3697
3739
|
const format = resolveFormat({ explicit: opts.format, isTty: process.stdout.isTTY === true });
|
|
3698
3740
|
const db = openDatabase2({ dbPath: opts.db });
|
|
3699
3741
|
ensureSchema2(db);
|
|
3700
|
-
const
|
|
3701
|
-
|
|
3742
|
+
const days = period === "week" ? 7 : 30;
|
|
3743
|
+
const data = getReport(db, days);
|
|
3744
|
+
console.log(formatReport(data, format, period));
|
|
3702
3745
|
db.close();
|
|
3703
3746
|
});
|
|
3704
|
-
return cmd;
|
|
3705
3747
|
}
|
|
3706
3748
|
|
|
3707
3749
|
// src/commands/login.ts
|
|
@@ -3860,10 +3902,9 @@ function buildManifest(version) {
|
|
|
3860
3902
|
},
|
|
3861
3903
|
{
|
|
3862
3904
|
name: "report",
|
|
3863
|
-
description: "
|
|
3864
|
-
args: [
|
|
3865
|
-
|
|
3866
|
-
{ name: "weekly", description: "Weekly health summary with trends and recommendations.", args: [] }
|
|
3905
|
+
description: "Generate a narrative health report from local data.",
|
|
3906
|
+
args: [
|
|
3907
|
+
{ name: "--period", type: "enum", values: ["week", "month"], description: "Report window (default week)." }
|
|
3867
3908
|
]
|
|
3868
3909
|
}
|
|
3869
3910
|
]
|
|
@@ -3876,7 +3917,7 @@ function describeCommand(version) {
|
|
|
3876
3917
|
}
|
|
3877
3918
|
|
|
3878
3919
|
// src/index.ts
|
|
3879
|
-
var VERSION = "0.
|
|
3920
|
+
var VERSION = "0.3.0";
|
|
3880
3921
|
if (process.argv.includes("--no-color") || process.env.NO_COLOR) {
|
|
3881
3922
|
source_default.level = 0;
|
|
3882
3923
|
}
|
|
@@ -3929,7 +3970,7 @@ program2.command("manifest").description("Print openclaw-tool-registry-compatibl
|
|
|
3929
3970
|
{ name: "workout", description: "Fetch workout data from Oura API.", examples: ["oura-cli workout --start 2026-05-01"] },
|
|
3930
3971
|
{ name: "sync", description: "Sync all Oura collections into the local DB.", examples: ["oura-cli sync"] },
|
|
3931
3972
|
{ name: "db", description: "Query the local SQLite cache.", examples: ["oura-cli db today"] },
|
|
3932
|
-
{ name: "report", description: "Render a weekly or monthly summary report.", examples: ["oura-cli report
|
|
3973
|
+
{ name: "report", description: "Render a weekly or monthly summary report.", examples: ["oura-cli report --period week"] },
|
|
3933
3974
|
{ name: "healthcheck", description: "Quick local DB health probe.", examples: ["oura-cli healthcheck"] },
|
|
3934
3975
|
{ name: "manifest", description: "Print openclaw-tool-registry-compatible manifest as JSON.", examples: ["oura-cli manifest"] }
|
|
3935
3976
|
],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakulavich/oura-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Oura Ring CLI — query and analyze Oura Ring health data from the command line, designed for humans and AI agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"oura",
|
|
@@ -55,6 +55,8 @@
|
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^25.3.3",
|
|
58
|
+
"ajv": "^8.20.0",
|
|
59
|
+
"ajv-formats": "^3.0.1",
|
|
58
60
|
"typescript": "^5.9.3"
|
|
59
61
|
}
|
|
60
62
|
}
|