@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.
Files changed (3) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/index.js +199 -158
  3. 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
- const json = await response.json();
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 mkdirSync2, unlinkSync } from "fs";
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
- const format = resolveFormat({ explicit: opts.format, isTty: process.stdout.isTTY === true });
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
- mkdirSync2(dirname(dbPath), { recursive: true });
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 getWeeklyReport(db) {
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() - 6 * 86400000);
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() - 7 * 86400000).toISOString().slice(0, 10);
3533
- const prevWeekStart = new Date(today.getTime() - 13 * 86400000).toISOString().slice(0, 10);
3534
- const days = [];
3535
- for (let i = 6;i >= 0; 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
- days.push({
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 formatWeeklyReport(data, format) {
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
- lines.push(source_default.bold(" Oura Weekly Report"));
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
- lines.push(source_default.bold(" Last 7 Days:"));
3634
- lines.push(source_default.gray(" " + "\u2500".repeat(52)));
3635
- lines.push(` ${"Day".padEnd(10)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(8)}`);
3636
- lines.push(source_default.gray(" " + "\u2500".repeat(52)));
3637
- for (const d of data.days) {
3638
- 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");
3639
- 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");
3640
- 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");
3641
- const steps = d.steps !== null ? d.steps.toLocaleString() : source_default.gray("\u2014");
3642
- lines.push(` ${d.dayLabel.padEnd(10)} ${sleep.padStart(6)} ${ready.padStart(6)} ${active.padStart(7)} ${steps.padStart(8)}`);
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
- const cmd = new Command("report").description("Generate health reports from local data");
3695
- cmd.command("weekly").description("Weekly health summary with trends and recommendations").action((_, command) => {
3696
- const opts = command.parent.parent.opts();
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 data = getWeeklyReport(db);
3701
- console.log(formatWeeklyReport(data, format));
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: "Render a narrative health report. Pick a subcommand.",
3864
- args: [],
3865
- subcommands: [
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.2.1";
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 weekly"] },
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.2.1",
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
  }