@drakulavich/oura-cli 0.7.0 → 0.7.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/CHANGELOG.md CHANGED
@@ -6,6 +6,17 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.1] - 2026-09-12
10
+
11
+ ### Fixed
12
+ - Whether a day's activity totals are final is now read off the day itself. Oura's `class_5_min` carries one character per five-minute slot, so a day reporting a full 288 of them has been closed out; `sync` stores the length in a new `daily_activity.class_5_min_slots` column and `report` reads it. The converse does not hold, and the rule does not claim it: a count *below* 288 is not evidence a day is still open. A day's span is `timestamp(d+1) − timestamp(d)`, so travelling east shortens it — 2023-12-15 came back with 276 slots and 13,637 steps, 2024-01-13 with 270 and 15,736, both closed long ago — while 288 also comes back for 23-hour days and caps longer ones. So the slot count is read as positive evidence of closure only, and a day short of 288 falls back to the old next-day rule rather than being called open. The old rule inferred it instead — a day counted as complete once a *later* day had its own record — which described the ring's behaviour rather than the day, and came apart wherever the two did: a ring that stopped uploading froze its last day as "still accumulating" forever, keeping real steps out of every average; a report timezone west of the ring's discarded a day the cache had already closed; and `completeThrough` could name a day holding no activity record at all, promising an average over a day the table never printed. Rows written before the column exists keep NULL and take the same fallback, so an upgraded cache fills in as `sync` re-fetches each day — `sync --from` fills older days on demand. Because the slot count can only ever close a day and never reopen one, upgrading never makes a day with an activity record *less* complete than it already was: over a real 1,044-day cache the two rules together name exactly the days the old rule named. (#74)
13
+ - `db trends`, `db stats` and `db week` now use that same judgement, so the screens stop disagreeing about one week (the all-time averages in `db stats` move by a few steps for the same reason: the day in progress no longer counts). `db trends` averaged the day in progress as though it were whole while `report` excluded it, and `db week` showed its part-day steps unmarked: the same seven days gave a 7,610 step average in one place and 6,639 in another. Activity, steps and active calories now stop at the last complete day in the window; sleep and readiness are final once they exist and still cover all of it. The week table marks the day with `*` and `DaySummary` carries `partial`, matching what `report` has always shown. (#75)
14
+ - `sync` no longer aborts on a heart-rate sample without a `timestamp`. The `day` column is derived from it, so a null threw a `TypeError` at insert time, outside the CLI's error mapping, and every collection after `hr` in the run never synced. A row whose identity field is missing, null, empty, or not the type the column stores is now dropped before insert, counted in the JSON under `dropped` and named on the collection's line (`1 dropped (no timestamp/source)`); the row was unusable anyway, since those columns are what it would have been stored under. A row is judged by the columns its table keys on, not by the manifest's identity fields: a daily summary Oura sends without an `id` is still stored under its `day`. (#106)
15
+ - An empty `200` from a snapshot endpoint no longer clears its table. `ring` is fetched whole every run and was replaced with whatever came back, so a partial read or an upstream hiccup deleted every ring and the JSON showed nothing in `removed`. The snapshot path now follows the rule the ranged collections already had: an empty answer describes nothing, so the rows are kept and reported under `refused` with the `--prune=ring` hint; a ring genuinely gone from the account is applied with that flag. Rings a non-empty response no longer lists are counted in `removed`, as they always should have been. (#105)
16
+ - `db trends` and `db stats` say what is missing on an empty cache instead of printing a header over nothing, or seventeen lines of `0 rows`. Both now carry the same "run `oura-cli sync`, then this command again" line that `db today` and `db week` already print. (#85)
17
+ - `report --period month` chunks its week buckets from the newest day back, so the remainder of a 30-day window is the oldest row and is labelled with its size (`2026-08-13 (2 days)`), not an unlabelled stub at the bottom that read as activity collapsing by 85 %. The `*` note names the bucket it marks (`the week of …`) instead of saying "today" above a table with no day rows. (#84)
18
+ - The `describe` and `manifest` snapshots now cover the real command registry. The old snapshot was built from a hand-listed fixture that did not include `sync`, so #103 added `--prune` to a published contract and no test moved. The registry lives in `src/commands/registry.ts`, the contract test asserts it matches the argv normalizer's command set, and the fixture-based tests stay as they were, since a controlled input is what they need. (#104)
19
+
9
20
  ## [0.7.0] - 2026-09-09
10
21
 
11
22
  ### Added
package/README.md CHANGED
@@ -96,7 +96,7 @@ oura-cli report # weekly (default)
96
96
  oura-cli report --period month # 30-day window with weekly buckets
97
97
  ```
98
98
 
99
- Reports cover daily scores, averages, deltas vs the previous window, sleep details, and a short recommendation block. A day whose activity is still accumulating (today, or the last day before the ring stopped syncing) is shown with a `*` and kept out of the activity averages and recommendations; the JSON says so via `days[].partial` and `completeThrough`.
99
+ Reports cover daily scores, averages, deltas vs the previous window, sleep details, and a short recommendation block. A day whose activity is still accumulating normally just today is shown with a `*` and kept out of the activity averages and recommendations; the JSON says so via `days[].partial` and `completeThrough`. `db today`, `db date` and `db week` carry the same `partial` flag, and `db week` marks the day with a `*` too, so the two screens cannot disagree. A day is treated as closed once Oura reports a full 24 hours of five-minute activity slots for it, so a ring that stops syncing no longer freezes its last day as unfinished.
100
100
 
101
101
  ### Trends and stats
102
102
 
package/dist/index.js CHANGED
@@ -1407,6 +1407,12 @@ CREATE TABLE IF NOT EXISTS ring_battery_level (
1407
1407
  CREATE UNIQUE INDEX IF NOT EXISTS idx_ring_battery_level_unique ON ring_battery_level(timestamp);
1408
1408
  CREATE INDEX IF NOT EXISTS idx_ring_battery_level_day ON ring_battery_level(day);
1409
1409
  `
1410
+ },
1411
+ {
1412
+ version: 4,
1413
+ sql: `
1414
+ ALTER TABLE daily_activity ADD COLUMN class_5_min_slots INTEGER;
1415
+ `
1410
1416
  }
1411
1417
  ];
1412
1418
 
@@ -2013,7 +2019,8 @@ var activity = defineCollection({
2013
2019
  { name: "total_calories", type: "INTEGER", pick: (r) => r.total_calories },
2014
2020
  { name: "target_calories", type: "INTEGER", pick: (r) => r.target_calories },
2015
2021
  { name: "contributors", type: "TEXT", pick: (r) => JSON.stringify(r.contributors) },
2016
- { name: "timestamp", type: "TEXT", pick: (r) => r.timestamp }
2022
+ { name: "timestamp", type: "TEXT", pick: (r) => r.timestamp },
2023
+ { name: "class_5_min_slots", type: "INTEGER", pick: (r) => r.class_5_min?.length ?? null }
2017
2024
  ]
2018
2025
  });
2019
2026
 
@@ -2385,6 +2392,24 @@ function insertSql(c) {
2385
2392
  function rowValues(c, row) {
2386
2393
  return c.columns.map((col) => col.pick(row));
2387
2394
  }
2395
+ function identityColumns(c) {
2396
+ const unique = c.columns.filter((col) => col.unique).map((col) => col.name);
2397
+ if (unique.length > 0)
2398
+ return unique;
2399
+ const pk = c.columns.filter((col) => col.pk).map((col) => col.name);
2400
+ if (pk.length > 0)
2401
+ return pk;
2402
+ return (c.indexes ?? []).find((i) => i.unique)?.columns ?? [];
2403
+ }
2404
+ function hasIdentity(c, row) {
2405
+ if (row == null || typeof row !== "object")
2406
+ return false;
2407
+ return identityColumns(c).every((name) => {
2408
+ const col = c.columns.find((k) => k.name === name);
2409
+ const v = col?.pick(row);
2410
+ return col?.type === "TEXT" ? typeof v === "string" && v !== "" : typeof v === "number" && Number.isFinite(v);
2411
+ });
2412
+ }
2388
2413
  var MS_PER_DAY = 86400000;
2389
2414
  function dateQueries(start, end, maxDays, offset) {
2390
2415
  const query = (s, e) => ({ start_date: shiftDay(s, offset[0]), end_date: shiftDay(e, offset[1]) });
@@ -2525,15 +2550,6 @@ function describeCommand(version, getCommands) {
2525
2550
  }
2526
2551
 
2527
2552
  // src/db/reconcile.ts
2528
- function identityColumns(c) {
2529
- const unique = c.columns.filter((col) => col.unique).map((col) => col.name);
2530
- if (unique.length > 0)
2531
- return unique;
2532
- const pk = c.columns.filter((col) => col.pk).map((col) => col.name);
2533
- if (pk.length > 0)
2534
- return pk;
2535
- return (c.indexes ?? []).find((i) => i.unique)?.columns ?? [];
2536
- }
2537
2553
  var MAX_REMOVED_SHARE = 0.5;
2538
2554
  var ALWAYS_SAFE_TO_REMOVE = 5;
2539
2555
  var KEY_SEPARATOR = "\x00";
@@ -2655,12 +2671,18 @@ async function importDaily(db, client, clock, log, window = {}, options = {}) {
2655
2671
  const fetched = {};
2656
2672
  const added = {};
2657
2673
  const removed = {};
2674
+ const dropped = {};
2658
2675
  const refused = {};
2659
2676
  const pruned = {};
2660
2677
  const mayPrune = (name) => options.prune === "all" || (options.prune?.includes(name) ?? false);
2661
2678
  for (const { c, start } of plan) {
2662
- const pieces = await fetchCollectionByPiece(client, c, start, end, tz);
2679
+ const returned = await fetchCollectionByPiece(client, c, start, end, tz);
2680
+ const pieces = returned.map((piece) => piece.filter((r) => hasIdentity(c, r)));
2663
2681
  const rows = pieces.flat();
2682
+ const missing = returned.flat().length - rows.length;
2683
+ if (missing > 0)
2684
+ dropped[c.table] = missing;
2685
+ const droppedTail = missing > 0 ? `, ${missing} dropped (no ${identityColumns(c).join("/")})` : "";
2664
2686
  const stmt = db.query(insertSql(c));
2665
2687
  if (c.rangeParams === "none") {
2666
2688
  const pk = c.columns.find((col) => col.pk)?.name;
@@ -2668,14 +2690,28 @@ async function importDaily(db, client, clock, log, window = {}, options = {}) {
2668
2690
  throw new Error(`Snapshot collection ${c.name} must declare a primary-key column (enforced by the registry tests).`);
2669
2691
  const ids = () => new Set(db.query(`SELECT ${pk} AS id FROM ${c.table}`).all().map((r) => r.id));
2670
2692
  const known = ids();
2671
- db.transaction((rs) => {
2672
- db.exec(`DELETE FROM ${c.table}`);
2673
- for (const r of rs)
2674
- stmt.run(...rowValues(c, r));
2675
- })(rows);
2676
- fetched[c.table] = rows.length;
2677
- added[c.table] = [...ids()].filter((id) => !known.has(id)).length;
2678
- _log(` + ${c.name} (${c.table}): ${rows.length} fetched, ${added[c.table]} new${rows.length === 0 ? ", table cleared" : ""}`);
2693
+ const unstorable = rows.length === 0 && missing > 0;
2694
+ const refuse = rows.length === 0 && known.size > 0 && (unstorable || !mayPrune(c.name));
2695
+ if (!refuse) {
2696
+ db.transaction((rs) => {
2697
+ db.exec(`DELETE FROM ${c.table}`);
2698
+ for (const r of rs)
2699
+ stmt.run(...rowValues(c, r));
2700
+ })(rows);
2701
+ }
2702
+ const now = ids();
2703
+ const gone = [...known].filter((id) => !now.has(id)).length;
2704
+ fetched[c.table] = rows.length + missing;
2705
+ added[c.table] = [...now].filter((id) => !known.has(id)).length;
2706
+ if (gone > 0)
2707
+ removed[c.table] = gone;
2708
+ if (refuse && !unstorable)
2709
+ refused[c.table] = { rows: known.size, collection: c.name };
2710
+ else if (rows.length === 0 && gone > 0)
2711
+ pruned[c.table] = { rows: gone, collection: c.name };
2712
+ const tail = gone > 0 ? `, ${gone} stale removed${pruned[c.table] ? " (past the truncation guard)" : ""}` : "";
2713
+ const kept = !refuse ? "" : unstorable ? `, ${known.size} rows kept: the response held no storable rows` : `, ${known.size} rows kept that the API did not return \u2014 an empty answer describes nothing; re-run with --prune=${c.name} to apply it`;
2714
+ _log(` + ${c.name} (${c.table}): ${fetched[c.table]} fetched, ${added[c.table]} new${droppedTail}${tail}${kept}`);
2679
2715
  continue;
2680
2716
  }
2681
2717
  const { windowPlan, gone } = db.transaction((ps) => {
@@ -2685,7 +2721,7 @@ async function importDaily(db, client, clock, log, window = {}, options = {}) {
2685
2721
  stmt.run(...rowValues(c, r));
2686
2722
  return { windowPlan, gone: applyWindowPlan(db, c, windowPlan) };
2687
2723
  }).immediate(pieces);
2688
- fetched[c.table] = rows.length;
2724
+ fetched[c.table] = rows.length + missing;
2689
2725
  added[c.table] = windowPlan.added;
2690
2726
  if (gone > 0)
2691
2727
  removed[c.table] = gone;
@@ -2695,7 +2731,7 @@ async function importDaily(db, client, clock, log, window = {}, options = {}) {
2695
2731
  pruned[c.table] = { rows: windowPlan.bypassed, collection: c.name };
2696
2732
  const tail = gone > 0 ? `, ${gone} stale removed${windowPlan.bypassed > 0 ? ` (${windowPlan.bypassed} past the truncation guard)` : ""}` : "";
2697
2733
  const kept = windowPlan.refused > 0 ? `, ${windowPlan.refused} rows kept that the API did not return \u2014 too many to drop on one response; re-run with --prune=${c.name} to apply them` : "";
2698
- _log(` + ${c.name} (${c.table}): ${rows.length} fetched, ${added[c.table]} new${tail}${kept}`);
2734
+ _log(` + ${c.name} (${c.table}): ${fetched[c.table]} fetched, ${added[c.table]} new${droppedTail}${tail}${kept}`);
2699
2735
  }
2700
2736
  _log("Import complete.");
2701
2737
  return {
@@ -2704,6 +2740,7 @@ async function importDaily(db, client, clock, log, window = {}, options = {}) {
2704
2740
  fetched,
2705
2741
  added,
2706
2742
  removed,
2743
+ dropped,
2707
2744
  refused,
2708
2745
  pruned,
2709
2746
  isFirstSync,
@@ -2711,8 +2748,28 @@ async function importDaily(db, client, clock, log, window = {}, options = {}) {
2711
2748
  };
2712
2749
  }
2713
2750
 
2751
+ // src/db/day-complete.ts
2752
+ var SLOTS_PER_DAY = 288;
2753
+ function dayCompleteness(db, today) {
2754
+ const rows = db.query("SELECT day, class_5_min_slots AS slots FROM daily_activity").all();
2755
+ const slotsByDay = new Map(rows.map((r) => [r.day, r.slots]));
2756
+ const daysDesc = rows.map((r) => r.day).sort().reverse();
2757
+ const newestDay = daysDesc[0] ?? null;
2758
+ const isComplete = (day) => {
2759
+ if (!slotsByDay.has(day))
2760
+ return false;
2761
+ if ((slotsByDay.get(day) ?? 0) >= SLOTS_PER_DAY)
2762
+ return true;
2763
+ return day < today && newestDay !== null && newestDay > day;
2764
+ };
2765
+ return {
2766
+ isComplete,
2767
+ completeThrough: (start, end) => daysDesc.find((d) => d >= start && d <= end && isComplete(d)) ?? null
2768
+ };
2769
+ }
2770
+
2714
2771
  // src/db/queries.ts
2715
- function getDaySummary(db, day) {
2772
+ function getDaySummary(db, day, complete) {
2716
2773
  const sl = db.query("SELECT score FROM daily_sleep WHERE day=?").get(day);
2717
2774
  const rd = db.query("SELECT score, temperature_deviation FROM daily_readiness WHERE day=?").get(day);
2718
2775
  const ac = db.query("SELECT score, steps FROM daily_activity WHERE day=?").get(day);
@@ -2721,6 +2778,7 @@ function getDaySummary(db, day) {
2721
2778
  const sm = db.query(`SELECT total_sleep_duration, deep_sleep_duration, rem_sleep_duration, average_hrv, lowest_heart_rate, efficiency FROM sleep_model WHERE day=? AND type='long_sleep'`).get(day);
2722
2779
  return {
2723
2780
  day,
2781
+ partial: ac != null && !complete.isComplete(day),
2724
2782
  sleep_score: sl?.score ?? null,
2725
2783
  readiness_score: rd?.score ?? null,
2726
2784
  activity_score: ac?.score ?? null,
@@ -2739,15 +2797,17 @@ function getDaySummary(db, day) {
2739
2797
  function getTrends(db, days, today) {
2740
2798
  const start = shiftDay(today, -(days - 1));
2741
2799
  const results = [];
2800
+ const activityEnd = dayCompleteness(db, today).completeThrough(start, today) ?? shiftDay(start, -1);
2742
2801
  const metrics = [
2743
- ["Sleep Score", "daily_sleep", "score"],
2744
- ["Readiness", "daily_readiness", "score"],
2745
- ["Activity", "daily_activity", "score"],
2746
- ["Steps", "daily_activity", "steps"],
2747
- ["Active Cal", "daily_activity", "active_calories"]
2802
+ ["Sleep Score", "daily_sleep", "score", false],
2803
+ ["Readiness", "daily_readiness", "score", false],
2804
+ ["Activity", "daily_activity", "score", true],
2805
+ ["Steps", "daily_activity", "steps", true],
2806
+ ["Active Cal", "daily_activity", "active_calories", true]
2748
2807
  ];
2749
- for (const [label, table, col] of metrics) {
2750
- const row = db.query(`SELECT AVG(${col}) as avg, MIN(${col}) as min, MAX(${col}) as max, COUNT(${col}) as count FROM ${table} WHERE day BETWEEN ? AND ?`).get(start, today);
2808
+ for (const [label, table, col, accumulates] of metrics) {
2809
+ const end = accumulates ? activityEnd : today;
2810
+ const row = db.query(`SELECT AVG(${col}) as avg, MIN(${col}) as min, MAX(${col}) as max, COUNT(${col}) as count FROM ${table} WHERE day BETWEEN ? AND ?`).get(start, end);
2751
2811
  if (row.count > 0 && row.avg !== null) {
2752
2812
  results.push({ label, avg: +row.avg.toFixed(0), min: row.min, max: row.max, count: row.count });
2753
2813
  }
@@ -2898,12 +2958,13 @@ function formatWeekTable(days, format, emptyHint) {
2898
2958
  }
2899
2959
  const header = `${"Day".padEnd(12)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Activity".padStart(9)} ${"Steps".padStart(7)} ${"Stress".padEnd(10)}`;
2900
2960
  const sep = source_default.gray("\u2500".repeat(56));
2901
- const rows = days.map((d) => `${padRight(d.day, 12)} ${padLeft(scoreColor(d.sleep_score), 6)} ${padLeft(scoreColor(d.readiness_score), 6)} ` + `${padLeft(scoreColor(d.activity_score), 9)} ${padLeft(String(d.steps ?? "\u2014"), 7)} ${padRight(d.stress ?? "\u2014", 10)}`);
2961
+ const rows = days.map((d) => `${padRight(d.partial ? `${d.day}*` : d.day, 12)} ${padLeft(scoreColor(d.sleep_score), 6)} ${padLeft(scoreColor(d.readiness_score), 6)} ` + `${padLeft(scoreColor(d.activity_score), 9)} ${padLeft(String(d.steps ?? "\u2014"), 7)} ${padRight(d.stress ?? "\u2014", 10)}`);
2962
+ const note = days.some((d) => d.partial) ? [" * still accumulating; its activity totals are not final."] : [];
2902
2963
  return [`
2903
- Last 7 Days`, sep, ` ${header}`, sep, ...rows.map((r) => ` ${r}`)].join(`
2964
+ Last 7 Days`, sep, ` ${header}`, sep, ...rows.map((r) => ` ${r}`), ...note].join(`
2904
2965
  `);
2905
2966
  }
2906
- function formatTrends(trends, days, format) {
2967
+ function formatTrends(trends, days, format, emptyHint) {
2907
2968
  if (format === "json")
2908
2969
  return JSON.stringify(trends, null, 2);
2909
2970
  const lines = [
@@ -2911,13 +2972,15 @@ function formatTrends(trends, days, format) {
2911
2972
  source_default.bold(` Trends: last ${days} days`),
2912
2973
  source_default.gray("\u2500".repeat(50))
2913
2974
  ];
2975
+ if (emptyHint && trends.length === 0)
2976
+ lines.push(` No Oura data for the last ${days} days yet.`, ` ${emptyHint}`);
2914
2977
  for (const t of trends) {
2915
2978
  lines.push(` ${t.label.padEnd(15)} avg: ${String(t.avg).padStart(5)} min: ${String(t.min).padStart(5)} max: ${String(t.max).padStart(5)} (${t.count} days)`);
2916
2979
  }
2917
2980
  return lines.join(`
2918
2981
  `);
2919
2982
  }
2920
- function formatStats(stats, format) {
2983
+ function formatStats(stats, format, emptyHint) {
2921
2984
  if (format === "json")
2922
2985
  return JSON.stringify(stats, null, 2);
2923
2986
  const lines = [
@@ -2925,6 +2988,10 @@ function formatStats(stats, format) {
2925
2988
  source_default.bold(" Database Statistics"),
2926
2989
  source_default.gray("\u2550".repeat(50))
2927
2990
  ];
2991
+ if (emptyHint && stats.tables.every((t) => t.rows === 0)) {
2992
+ return [...lines, " No Oura data in the database yet.", ` ${emptyHint}`].join(`
2993
+ `);
2994
+ }
2928
2995
  for (const t of stats.tables) {
2929
2996
  lines.push(` ${t.table.padEnd(22)} ${String(t.rows).padStart(8)} rows`);
2930
2997
  }
@@ -2984,7 +3051,7 @@ async function runSync(ctx, window = {}, options = {}) {
2984
3051
  const lines = [];
2985
3052
  const log = ctx.format === "table" ? (m) => lines.push(m) : undefined;
2986
3053
  const importResult = await importDaily(ctx.db, ctx.client, { today: ctx.today, tz: ctx.tz }, log, window, options);
2987
- const today = getDaySummary(ctx.db, ctx.today);
3054
+ const today = getDaySummary(ctx.db, ctx.today, dayCompleteness(ctx.db, ctx.today));
2988
3055
  return {
2989
3056
  json: { import: importResult, today },
2990
3057
  text: () => [...lines, formatImportSummary(importResult), formatDaySummary(today, "table")].join(`
@@ -3013,7 +3080,7 @@ var dbCommand = defineCommand({
3013
3080
  meta: { name: "today", description: "Today's summary from local database" },
3014
3081
  needs: { db: true },
3015
3082
  run(ctx) {
3016
- const summary = getDaySummary(ctx.db, ctx.today);
3083
+ const summary = getDaySummary(ctx.db, ctx.today, dayCompleteness(ctx.db, ctx.today));
3017
3084
  return { json: summary, text: () => formatDaySummary(summary, "table", SYNC_HINT) };
3018
3085
  }
3019
3086
  }),
@@ -3023,7 +3090,7 @@ var dbCommand = defineCommand({
3023
3090
  needs: { db: true },
3024
3091
  run(ctx, args) {
3025
3092
  const day = assertCalendarDate(String(args.day), "<day>");
3026
- const summary = getDaySummary(ctx.db, day);
3093
+ const summary = getDaySummary(ctx.db, day, dayCompleteness(ctx.db, ctx.today));
3027
3094
  return { json: summary, text: () => formatDaySummary(summary, "table") };
3028
3095
  }
3029
3096
  }),
@@ -3031,7 +3098,8 @@ var dbCommand = defineCommand({
3031
3098
  meta: { name: "week", description: "Last 7 days from local database" },
3032
3099
  needs: { db: true },
3033
3100
  run(ctx) {
3034
- const days = daysBack(ctx.today, 7).map((d) => getDaySummary(ctx.db, d));
3101
+ const complete = dayCompleteness(ctx.db, ctx.today);
3102
+ const days = daysBack(ctx.today, 7).map((d) => getDaySummary(ctx.db, d, complete));
3035
3103
  return { json: days, text: () => formatWeekTable(days, "table", "Run `oura-cli sync`, then `oura-cli db week` again.") };
3036
3104
  }
3037
3105
  }),
@@ -3042,7 +3110,7 @@ var dbCommand = defineCommand({
3042
3110
  run(ctx, args) {
3043
3111
  const n = args.days === undefined ? 30 : assertPositiveInt(String(args.days), "<days>");
3044
3112
  const trends = getTrends(ctx.db, n, ctx.today);
3045
- return { json: trends, text: () => formatTrends(trends, n, "table") };
3113
+ return { json: trends, text: () => formatTrends(trends, n, "table", "Run `oura-cli sync`, then `oura-cli db trends` again.") };
3046
3114
  }
3047
3115
  }),
3048
3116
  stats: dataCommand({
@@ -3050,7 +3118,7 @@ var dbCommand = defineCommand({
3050
3118
  needs: { db: true },
3051
3119
  run(ctx) {
3052
3120
  const stats = getStats(ctx.db, ctx.today);
3053
- return { json: stats, text: () => formatStats(stats, "table") };
3121
+ return { json: stats, text: () => formatStats(stats, "table", "Run `oura-cli sync`, then `oura-cli db stats` again.") };
3054
3122
  }
3055
3123
  })
3056
3124
  }
@@ -3073,9 +3141,8 @@ function getReport(db, days, today) {
3073
3141
  const prevWeekStart = shiftDay(today, -(days * 2 - 1));
3074
3142
  const windowDays = daysBack(today, days);
3075
3143
  const lastUpload = db.query("SELECT MAX(timestamp) AS t FROM heartrate").get().t;
3076
- const newestActivityDay = db.query("SELECT MAX(day) AS d FROM daily_activity").get().d;
3077
- const isComplete = (d) => d < today && newestActivityDay !== null && newestActivityDay > d;
3078
- const completeThrough = [...windowDays].reverse().find(isComplete) ?? null;
3144
+ const complete = dayCompleteness(db, today);
3145
+ const completeThrough = complete.completeThrough(weekStart, today);
3079
3146
  const activityEnd = completeThrough ?? shiftDay(weekStart, -1);
3080
3147
  const dailyRows = [];
3081
3148
  for (const d of windowDays) {
@@ -3089,7 +3156,7 @@ function getReport(db, days, today) {
3089
3156
  readiness: rd?.score ?? null,
3090
3157
  activity: ac?.score ?? null,
3091
3158
  steps: ac?.steps ?? null,
3092
- partial: ac != null && !isComplete(d)
3159
+ partial: ac != null && !complete.isComplete(d)
3093
3160
  });
3094
3161
  }
3095
3162
  const metrics = [
@@ -3190,8 +3257,8 @@ var RECOMMENDATIONS = {
3190
3257
  };
3191
3258
  function bucketDaysIntoWeeks(days) {
3192
3259
  const buckets = [];
3193
- for (let i = 0;i < days.length; i += 7) {
3194
- const chunk = days.slice(i, i + 7);
3260
+ for (let end = days.length;end > 0; end -= 7) {
3261
+ const chunk = days.slice(Math.max(0, end - 7), end);
3195
3262
  const weekOf = chunk[0].day;
3196
3263
  const sleepVals = chunk.map((d) => d.sleep).filter((v) => v !== null);
3197
3264
  const readinessVals = chunk.map((d) => d.readiness).filter((v) => v !== null);
@@ -3199,6 +3266,7 @@ function bucketDaysIntoWeeks(days) {
3199
3266
  const stepsVals = chunk.map((d) => d.steps).filter((v) => v !== null);
3200
3267
  buckets.push({
3201
3268
  weekOf,
3269
+ days: chunk.length,
3202
3270
  avgSleep: sleepVals.length > 0 ? sleepVals.reduce((a, b) => a + b, 0) / sleepVals.length : null,
3203
3271
  avgReadiness: readinessVals.length > 0 ? readinessVals.reduce((a, b) => a + b, 0) / readinessVals.length : null,
3204
3272
  avgActivity: activityVals.length > 0 ? activityVals.reduce((a, b) => a + b, 0) / activityVals.length : null,
@@ -3206,13 +3274,17 @@ function bucketDaysIntoWeeks(days) {
3206
3274
  partial: chunk.some((d) => d.partial)
3207
3275
  });
3208
3276
  }
3209
- return buckets;
3277
+ return buckets.reverse();
3210
3278
  }
3211
- function partialDayNote(data) {
3279
+ function bucketLabel(b) {
3280
+ const stub = b.days < 7 ? ` (${b.days} day${b.days === 1 ? "" : "s"})` : "";
3281
+ return `${b.weekOf}${stub}${b.partial ? "*" : ""}`;
3282
+ }
3283
+ function partialDayNote(data, bucket) {
3212
3284
  const partial = data.days.find((d) => d.partial);
3213
3285
  if (!partial)
3214
3286
  return null;
3215
- const which = partial.day === data.weekEnd ? "today" : partial.dayLabel;
3287
+ const which = bucket ? `the week of ${bucket.weekOf}` : partial.day === data.weekEnd ? "today" : partial.dayLabel;
3216
3288
  const covers = data.completeThrough ? `through ${data.completeThrough}` : "no complete day yet";
3217
3289
  return ` * ${which} is still accumulating; activity averages cover ${covers}.`;
3218
3290
  }
@@ -3227,7 +3299,8 @@ function formatReport(data, format, period) {
3227
3299
  lines.push(source_default.bold(" Oura Monthly Report"));
3228
3300
  }
3229
3301
  lines.push(source_default.gray(` ${data.weekStart} \u2014 ${data.weekEnd}`));
3230
- const note = partialDayNote(data);
3302
+ const buckets = period === "month" ? bucketDaysIntoWeeks(data.days) : [];
3303
+ const note = partialDayNote(data, buckets.find((b) => b.partial));
3231
3304
  if (note)
3232
3305
  lines.push(source_default.yellow(note));
3233
3306
  lines.push("");
@@ -3249,16 +3322,15 @@ function formatReport(data, format, period) {
3249
3322
  }
3250
3323
  lines.push("");
3251
3324
  } else {
3252
- const buckets = bucketDaysIntoWeeks(data.days);
3253
3325
  lines.push(source_default.bold(" Last 30 Days:"));
3254
- lines.push(source_default.gray(" " + "\u2500".repeat(60)));
3255
- lines.push(` ${"Week of".padEnd(12)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(10)}`);
3256
- lines.push(source_default.gray(" " + "\u2500".repeat(60)));
3326
+ lines.push(source_default.gray(" " + "\u2500".repeat(69)));
3327
+ lines.push(` ${"Week of".padEnd(21)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(10)}`);
3328
+ lines.push(source_default.gray(" " + "\u2500".repeat(69)));
3257
3329
  for (const b of buckets) {
3258
3330
  const avgSleepInt = b.avgSleep !== null ? Math.round(b.avgSleep) : null;
3259
3331
  const avgReadyInt = b.avgReadiness !== null ? Math.round(b.avgReadiness) : null;
3260
3332
  const avgActiveInt = b.avgActivity !== null ? Math.round(b.avgActivity) : null;
3261
- lines.push(` ${(b.partial ? b.weekOf + "*" : b.weekOf).padEnd(12)} ${scoreCell(avgSleepInt, 6)} ${scoreCell(avgReadyInt, 6)} ${scoreCell(avgActiveInt, 7)} ${stepsCell(b.totalSteps, 10)}`);
3333
+ lines.push(` ${bucketLabel(b).padEnd(21)} ${scoreCell(avgSleepInt, 6)} ${scoreCell(avgReadyInt, 6)} ${scoreCell(avgActiveInt, 7)} ${stepsCell(b.totalSteps, 10)}`);
3262
3334
  }
3263
3335
  lines.push("");
3264
3336
  }
@@ -3588,6 +3660,22 @@ var fetchCommand = dataCommand({
3588
3660
  }
3589
3661
  });
3590
3662
 
3663
+ // src/commands/registry.ts
3664
+ function buildRegistry(version) {
3665
+ const subCommands = Object.assign(Object.create(null), {
3666
+ login: loginCommand,
3667
+ describe: describeCommand(version, () => subCommands),
3668
+ healthcheck: healthcheckCommand(version),
3669
+ doctor: doctorCommand,
3670
+ manifest: manifestCommand(version, () => subCommands),
3671
+ fetch: fetchCommand,
3672
+ sync: syncCommand,
3673
+ db: dbCommand,
3674
+ report: reportCommand
3675
+ });
3676
+ return subCommands;
3677
+ }
3678
+
3591
3679
  // src/lib/citty-error.ts
3592
3680
  var ANSI2 = /\u001b\[[0-9;]*m/g;
3593
3681
  var COMMAND_NAME = /^[a-z][a-z0-9-]{0,19}$/;
@@ -3649,17 +3737,7 @@ function fromCittyError(err, removedCommandHints = {}, rawArgs = []) {
3649
3737
 
3650
3738
  // src/index.ts
3651
3739
  var VERSION = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf-8")).version;
3652
- var subCommands = Object.assign(Object.create(null), {
3653
- login: loginCommand,
3654
- describe: describeCommand(VERSION, () => subCommands),
3655
- healthcheck: healthcheckCommand(VERSION),
3656
- doctor: doctorCommand,
3657
- manifest: manifestCommand(VERSION, () => subCommands),
3658
- fetch: fetchCommand,
3659
- sync: syncCommand,
3660
- db: dbCommand,
3661
- report: reportCommand
3662
- });
3740
+ var subCommands = buildRegistry(VERSION);
3663
3741
  var FETCH_HINT = "The per-collection commands were replaced in 0.5.0 by `oura-cli fetch <collection>`, e.g. `oura-cli fetch sleep --day 2026-09-01`. Run `oura-cli fetch --help`.";
3664
3742
  var REMOVED_COMMANDS = {
3665
3743
  reset: "`db reset` was removed in 0.5.0; delete the database file (`--db` / OURA_DB_PATH) and run `oura-cli sync` to rebuild it.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/oura-cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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",