@drakulavich/oura-cli 0.6.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,34 @@ 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
+
20
+ ## [0.7.0] - 2026-09-09
21
+
22
+ ### Added
23
+ - `sync --prune=<collections>` applies removals the truncation guard refuses. The guard keeps rows when a response drops most of what one request described, which is right for a partial or short answer and wrong for a genuine large correction — and it cannot tell them apart, so a genuine one was refused on every run with no way through. Narrowing the window did not help: the scope is the returned rows' own bounds, so the ratio never moved, and `sync --from D --to D` refused identically. The flag is the user's answer to the question the guard cannot ask, per run and never stored.
24
+
25
+ It is scoped to the collections named because consent is per collection: the refusal message names the collection to pass (`re-run with --prune=hr`), so lifting the guard for one does not lift it for another that answers short in the same run — a loss that never comes back behind `--from`, since the next ordinary sync resumes from the watermark. `--prune=all` covers every collection; a valueless `--prune` is refused, because as a string flag it would otherwise swallow whatever came next on the line. Removals that went past the guard are counted separately from ordinary reconciliation, in the JSON as `pruned` and on the collection line as `(N past the truncation guard)`, so a bypass is never silent. `refused` and `pruned` carry the collection name alongside the count (`{rows, collection}`), because they are the only fields that imply a follow-up command and the command takes a name the table-keyed payload did not contain — `sync` is JSON-only off a TTY, and an agent that cannot map `heartrate` back to `hr` would reach for `--prune=all`. A run that passed the flag also records `pruneScope`, so a kept JSON log can say whether the guard was lifted and over what. A piece that returns nothing still removes nothing, flag or not. Where two pieces overlap, every piece covering a row is consulted before that row is decided, and one doubting piece is enough to keep it — a verdict taken while walking the pieces would have been settled by whichever came first, so the same responses in a different order could delete a row instead of keeping it. (#100)
26
+
27
+ ### Fixed
28
+ - `sync` kept both rows when Oura reclassified a heart-rate sample (`awake` → `workout` shares the timestamp, and the unique index is `(timestamp, source)`), and no command could repair it. A re-fetched window is now reconciled against the response: rows the API no longer has are removed, inside the same transaction as the inserts. Verified on a copy of a real cache, where it removed exactly the five stale samples the exploratory session had found. (#91)
29
+ - The same for the five tables whose `day` is not unique (`sleep_model`, `workouts`, `sessions`, `rest_mode_periods`, `enhanced_tags`): a record re-issued under a new id used to leave both rows, so `report` averaged one night twice. The scope of a removal always comes from the response and from one request at a time — a range wider than the endpoint allows is fetched in pieces, and a piece that answers with nothing describes nothing, so its days keep their rows. A piece whose answer would drop most of what is stored for it is treated as truncated: the removal is refused and reported. (#71)
30
+ - `sync` reports `removed` and `refused` per table in its JSON, and the progress line says how many stale rows went and how many were kept because the response looked truncated.
31
+ - `ensureSchema` read the schema version and applied migrations with nothing serialising the two, so concurrent commands recorded the same migration several times — harmless only while every migration is `CREATE ... IF NOT EXISTS`, and a hard failure for the loser as soon as one is an `ALTER TABLE`. Check and apply now run under `BEGIN IMMEDIATE`. Two processes creating the same cache also raced on `PRAGMA journal_mode = WAL`, which answers `SQLITE_BUSY` without waiting out `busy_timeout`; losing that race is no longer an error, since the winner set the mode this process wanted. (#77)
32
+ - A misspelled global flag left its value in the command position, so the CLI reported the value as the command name — and for `--tok <token>` that put a Personal Access Token into an error message. A near miss of a known flag is now blamed on the flag (`Unknown flag "--tok". Did you mean --token?`), and a value that cannot be a command name is never quoted back. (#95)
33
+ - An empty `--token` or `OURA_TOKEN` fell through to the token file, so a wrapper expanding an unset variable authenticated as whoever that file holds; an empty `OURA_TZ` fell through to the system zone and shifted every day boundary silently. Both are now `BAD_ARGS`, like the `--db` case fixed in 0.6.0, through one shared guard. (#92)
34
+ - A database in a directory the user cannot write reported `attempt to write a readonly database` with a hint about the file's format. The file is fine; SQLite needs to create `-wal` and `-shm` beside it. Permission failures now get their own hint. (#78)
35
+ - `doctor` runs SQLite's `quick_check` and reports it as a new `integrity` check, so a cache with a damaged page is named instead of passing every command until something touches the broken page. `doctor` also survives reading a corrupt cache rather than crashing part-way through its own checks, and `healthcheck`'s description now says it proves the file opens, not that its contents are intact. (#78)
36
+
9
37
  ## [0.6.0] - 2026-09-06
10
38
 
11
39
  ### Fixed
package/README.md CHANGED
@@ -54,7 +54,7 @@ oura-cli sync # first sync fetches the last 30 days; later syncs resume from
54
54
  oura-cli report # weekly digest in the terminal
55
55
  ```
56
56
 
57
- Subsequent `oura-cli sync` re-fetches each collection from its own last stored day (Oura revises recent days, so the overlap is deliberate) and reports rows fetched (+new). Heart rate goes back two weeks each time, because Oura publishes workout samples days after the day they belong to. `oura-cli sync --from 2026-08-01 [--to 2026-08-07]` re-fetches an explicit window for every collection instead — for example after an interrupted sync. `oura-cli db today` / `oura-cli db week` read the local cache instantly, no API call.
57
+ Subsequent `oura-cli sync` re-fetches each collection from its own last stored day (Oura revises recent days, so the overlap is deliberate) and reports rows fetched (+new). Heart rate goes back two weeks each time, because Oura publishes workout samples days after the day they belong to. A re-fetched window ends up holding exactly what the API returned for it: a sample Oura reclassified, or a record it re-issued under a new id, replaces the row it supersedes instead of joining it. When a response drops most of what one request covered — the shape of a partial or short answer — those rows are kept rather than deleted, and `sync` names the collection and how many. `oura-cli sync --prune=hr` then applies them for that collection once you have judged the correction genuine; `oura-cli sync --prune=all` does it for every collection in the run, which also lifts the guard on any collection that answers short in the same run. `oura-cli sync --from 2026-08-01 [--to 2026-08-07]` re-fetches an explicit window for every collection instead — for example after an interrupted sync. `oura-cli db today` / `oura-cli db week` read the local cache instantly, no API call.
58
58
 
59
59
  ### If something looks wrong
60
60
 
@@ -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
 
@@ -183,7 +183,7 @@ Runtime: [Bun](https://bun.sh). Storage: built-in `bun:sqlite`. CLI parsing: [ci
183
183
  If you're driving the CLI from a script or LLM harness:
184
184
 
185
185
  - `oura-cli describe` — JSON manifest of every command, argument, and output schema. Agents discover capabilities without scraping `--help`.
186
- - `oura-cli healthcheck` — `{ok, version, latencyMs}` JSON for liveness probes, plus `error` when `ok` is false.
186
+ - `oura-cli healthcheck` — `{ok, version, latencyMs}` JSON for liveness probes, plus `error` when `ok` is false. It proves the database opens and answers a query; it does not inspect the contents. `oura-cli doctor` runs SQLite's `quick_check` for that.
187
187
  - Gate on `.ok`, not on the exit code: `doctor` exits 0 with `ok: false` for any warning-level check (no data yet, stale data, Oura API unreachable), and `healthcheck` exits 0 with `ok: false` for an unusable database (the probe itself ran). `doctor --offline` skips the token-validation call, and a skipped check still counts towards `ok`.
188
188
  - Errors emit a stable JSON envelope on stderr: `{"error":{"code":"…","message":"…","hint":"…"}}`.
189
189
  - Documented exit codes: `0` success, `1` user error, `2` auth, `3` API, `4` storage.
package/dist/index.js CHANGED
@@ -1198,6 +1198,14 @@ import { resolve, dirname } from "path";
1198
1198
  import { homedir } from "os";
1199
1199
  import { chmodSync, existsSync, mkdirSync } from "fs";
1200
1200
 
1201
+ // src/lib/require-value.ts
1202
+ function requireValue(value, source, fallback) {
1203
+ if (value.trim() === "") {
1204
+ throw new CliError("BAD_ARGS", `${source} has no value`, `Pass a value, or remove ${source} to fall back to ${fallback}.`);
1205
+ }
1206
+ return value;
1207
+ }
1208
+
1201
1209
  // src/db/migrations.ts
1202
1210
  var MIGRATIONS = [
1203
1211
  {
@@ -1399,33 +1407,42 @@ CREATE TABLE IF NOT EXISTS ring_battery_level (
1399
1407
  CREATE UNIQUE INDEX IF NOT EXISTS idx_ring_battery_level_unique ON ring_battery_level(timestamp);
1400
1408
  CREATE INDEX IF NOT EXISTS idx_ring_battery_level_day ON ring_battery_level(day);
1401
1409
  `
1410
+ },
1411
+ {
1412
+ version: 4,
1413
+ sql: `
1414
+ ALTER TABLE daily_activity ADD COLUMN class_5_min_slots INTEGER;
1415
+ `
1402
1416
  }
1403
1417
  ];
1404
1418
 
1405
1419
  // src/db/open.ts
1406
1420
  var DB_HINT = "Check the path in --db / OURA_DB_PATH and that the file is a SQLite database oura-cli created.";
1407
1421
  var BUSY_HINT = "Another oura-cli process is using this database; wait for it to finish and retry.";
1422
+ var PERMISSION_HINT = "Check that you can write both the file and the directory holding it \u2014 SQLite creates -wal and -shm files alongside the database.";
1408
1423
  var BUSY_TIMEOUT_MS = 5000;
1424
+ var WAL_SWITCH_ATTEMPTS = 20;
1425
+ var WAL_SWITCH_WAIT_MS = 25;
1426
+ function hintFor(detail) {
1427
+ if (/database is locked|SQLITE_BUSY/i.test(detail))
1428
+ return BUSY_HINT;
1429
+ if (/readonly database|read-only|EACCES|permission denied|unable to open database file/i.test(detail))
1430
+ return PERMISSION_HINT;
1431
+ return DB_HINT;
1432
+ }
1409
1433
  function dbError(what, err) {
1410
1434
  const detail = err instanceof Error ? err.message : String(err);
1411
- const hint = /database is locked|SQLITE_BUSY/i.test(detail) ? BUSY_HINT : DB_HINT;
1412
- return new CliError("DB_ERROR", `${what}: ${detail}`, hint);
1435
+ return new CliError("DB_ERROR", `${what}: ${detail}`, hintFor(detail));
1413
1436
  }
1414
1437
  function asDbError(err) {
1415
1438
  return err instanceof SQLiteError ? dbError("Database query failed", err) : undefined;
1416
1439
  }
1417
- function requirePath(value, source) {
1418
- if (value.trim() === "") {
1419
- throw new CliError("BAD_ARGS", `${source} has no path`, `Pass a path to a SQLite file, or remove ${source} to fall back to the default database.`);
1420
- }
1421
- return value;
1422
- }
1423
1440
  function getDbPath(explicit) {
1424
1441
  if (explicit !== undefined)
1425
- return requirePath(explicit, "--db");
1442
+ return requireValue(explicit, "--db", "the default database");
1426
1443
  const fromEnv = process.env.OURA_DB_PATH;
1427
1444
  if (fromEnv !== undefined)
1428
- return requirePath(fromEnv, "OURA_DB_PATH");
1445
+ return requireValue(fromEnv, "OURA_DB_PATH", "the default database");
1429
1446
  return resolve(homedir(), ".oura-cli", "oura.db");
1430
1447
  }
1431
1448
  function openDatabase(explicit) {
@@ -1439,15 +1456,31 @@ function openDatabase(explicit) {
1439
1456
  if (isNew)
1440
1457
  chmodSync(dbPath, 384);
1441
1458
  db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
1442
- const mode = db.query("PRAGMA journal_mode").get();
1443
- if (mode.journal_mode !== "wal")
1444
- db.exec("PRAGMA journal_mode = WAL");
1459
+ if (journalMode(db) !== "wal")
1460
+ enableWal(db);
1445
1461
  db.exec("PRAGMA foreign_keys = ON");
1446
1462
  return db;
1447
1463
  } catch (err) {
1448
1464
  throw dbError(`Cannot open database ${dbPath}`, err);
1449
1465
  }
1450
1466
  }
1467
+ function journalMode(db) {
1468
+ return db.query("PRAGMA journal_mode").get().journal_mode;
1469
+ }
1470
+ function enableWal(db) {
1471
+ for (let attempt = 0;; attempt++) {
1472
+ try {
1473
+ db.exec("PRAGMA journal_mode = WAL");
1474
+ return;
1475
+ } catch (err) {
1476
+ if (journalMode(db) === "wal")
1477
+ return;
1478
+ if (attempt >= WAL_SWITCH_ATTEMPTS)
1479
+ throw err;
1480
+ Bun.sleepSync(WAL_SWITCH_WAIT_MS + Math.random() * WAL_SWITCH_WAIT_MS);
1481
+ }
1482
+ }
1483
+ }
1451
1484
  function schemaVersion(db) {
1452
1485
  db.exec("CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER NOT NULL)");
1453
1486
  const row = db.query("SELECT MAX(version) AS v FROM _schema_version").get();
@@ -1455,12 +1488,23 @@ function schemaVersion(db) {
1455
1488
  }
1456
1489
  function ensureSchema(db, migrations = MIGRATIONS) {
1457
1490
  try {
1458
- const current = schemaVersion(db);
1459
- for (const m of migrations) {
1460
- if (m.version > current) {
1461
- db.exec(m.sql);
1462
- db.query("INSERT INTO _schema_version (version) VALUES (?)").run(m.version);
1491
+ if (schemaVersion(db) >= Math.max(0, ...migrations.map((m) => m.version)))
1492
+ return;
1493
+ db.exec("BEGIN IMMEDIATE");
1494
+ try {
1495
+ const current = schemaVersion(db);
1496
+ for (const m of migrations) {
1497
+ if (m.version > current) {
1498
+ db.exec(m.sql);
1499
+ db.query("INSERT INTO _schema_version (version) VALUES (?)").run(m.version);
1500
+ }
1463
1501
  }
1502
+ db.exec("COMMIT");
1503
+ } catch (err) {
1504
+ try {
1505
+ db.exec("ROLLBACK");
1506
+ } catch {}
1507
+ throw err;
1464
1508
  }
1465
1509
  } catch (err) {
1466
1510
  throw dbError("Schema migration failed", err);
@@ -1475,10 +1519,11 @@ function defaultTokenPath() {
1475
1519
  return process.env.OURA_TOKEN_PATH ?? resolve2(homedir2(), ".oura-token");
1476
1520
  }
1477
1521
  function resolveToken(explicit, tokenPath) {
1478
- if (explicit)
1479
- return { token: explicit.trim(), source: "--token" };
1480
- if (process.env.OURA_TOKEN)
1481
- return { token: process.env.OURA_TOKEN.trim(), source: "OURA_TOKEN" };
1522
+ if (explicit !== undefined)
1523
+ return { token: requireValue(explicit, "--token", "the token file").trim(), source: "--token" };
1524
+ const fromEnv = process.env.OURA_TOKEN;
1525
+ if (fromEnv !== undefined)
1526
+ return { token: requireValue(fromEnv, "OURA_TOKEN", "the token file").trim(), source: "OURA_TOKEN" };
1482
1527
  const path = tokenPath ?? defaultTokenPath();
1483
1528
  try {
1484
1529
  return { token: readFileSync(path, "utf-8").trim(), source: path };
@@ -1685,8 +1730,9 @@ function localDateToUtcRange(localDate, timezone) {
1685
1730
  return [iso(localMidnightMs(localDate, timezone)), iso(localMidnightMs(shiftDay(localDate, 1), timezone))];
1686
1731
  }
1687
1732
  function resolveDefaultTimezone() {
1688
- if (process.env.OURA_TZ)
1689
- return process.env.OURA_TZ;
1733
+ const fromEnv = process.env.OURA_TZ;
1734
+ if (fromEnv !== undefined)
1735
+ return requireValue(fromEnv, "OURA_TZ", "the system timezone");
1690
1736
  try {
1691
1737
  return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
1692
1738
  } catch {
@@ -1973,7 +2019,8 @@ var activity = defineCollection({
1973
2019
  { name: "total_calories", type: "INTEGER", pick: (r) => r.total_calories },
1974
2020
  { name: "target_calories", type: "INTEGER", pick: (r) => r.target_calories },
1975
2021
  { name: "contributors", type: "TEXT", pick: (r) => JSON.stringify(r.contributors) },
1976
- { 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 }
1977
2024
  ]
1978
2025
  });
1979
2026
 
@@ -2345,6 +2392,24 @@ function insertSql(c) {
2345
2392
  function rowValues(c, row) {
2346
2393
  return c.columns.map((col) => col.pick(row));
2347
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
+ }
2348
2413
  var MS_PER_DAY = 86400000;
2349
2414
  function dateQueries(start, end, maxDays, offset) {
2350
2415
  const query = (s, e) => ({ start_date: shiftDay(s, offset[0]), end_date: shiftDay(e, offset[1]) });
@@ -2374,13 +2439,15 @@ function rangeQueries(c, start, end, tz) {
2374
2439
  return [];
2375
2440
  return c.rangeParams === "date" ? dateQueries(start, end, c.maxRangeDays, c.dayRangeOffset ?? [0, 0]) : datetimeQueries(start, end, tz, c.maxRangeDays);
2376
2441
  }
2377
- async function fetchCollection(client, c, start, end, tz) {
2378
- const rows = [];
2442
+ async function fetchCollectionByPiece(client, c, start, end, tz) {
2443
+ const pieces = [];
2379
2444
  for (const query of rangeQueries(c, start, end, tz)) {
2380
- for (const row of await client.fetch(c.endpoint, query))
2381
- rows.push(row);
2445
+ pieces.push(await client.fetch(c.endpoint, query));
2382
2446
  }
2383
- return rows;
2447
+ return pieces;
2448
+ }
2449
+ async function fetchCollection(client, c, start, end, tz) {
2450
+ return (await fetchCollectionByPiece(client, c, start, end, tz)).flat();
2384
2451
  }
2385
2452
 
2386
2453
  // src/commands/describe.ts
@@ -2482,15 +2549,107 @@ function describeCommand(version, getCommands) {
2482
2549
  });
2483
2550
  }
2484
2551
 
2552
+ // src/db/reconcile.ts
2553
+ var MAX_REMOVED_SHARE = 0.5;
2554
+ var ALWAYS_SAFE_TO_REMOVE = 5;
2555
+ var KEY_SEPARATOR = "\x00";
2556
+ function emptyPlan() {
2557
+ return { added: 0, stale: [], refused: 0, bypassed: 0 };
2558
+ }
2559
+ function keyOf(values) {
2560
+ return values.map((v) => String(v)).join(KEY_SEPARATOR);
2561
+ }
2562
+ function planWindow(db, c, pieces, options = {}) {
2563
+ const identity = identityColumns(c);
2564
+ if (identity.length === 0)
2565
+ return emptyPlan();
2566
+ const pickOf = (name) => c.columns.find((col) => col.name === name)?.pick;
2567
+ const identityPicks = identity.map(pickOf);
2568
+ const scopeName = c.rangeParams === "datetime" ? "timestamp" : "day";
2569
+ const scopePick = pickOf(scopeName);
2570
+ if (scopePick === undefined || identityPicks.some((p) => p === undefined))
2571
+ return emptyPlan();
2572
+ const keyFor = (row) => keyOf(identityPicks.map((pick) => pick(row)));
2573
+ const wanted = new Set(pieces.flat().map(keyFor));
2574
+ const plan = emptyPlan();
2575
+ const judged = pieces.map((piece) => {
2576
+ if (piece.length === 0)
2577
+ return null;
2578
+ const scopeValues = piece.map((row) => scopePick(row)).filter((v) => v !== null && v !== undefined).map(String);
2579
+ if (scopeValues.length === 0)
2580
+ return null;
2581
+ const days = [...new Set(scopeValues)];
2582
+ const [where, params] = c.rangeParams === "datetime" ? [`${scopeName} BETWEEN ? AND ?`, [minOf(scopeValues), maxOf(scopeValues)]] : [`${scopeName} IN (${days.map(() => "?").join(", ")})`, days];
2583
+ const stored = db.query(`SELECT ${identity.join(", ")} FROM ${c.table} WHERE ${where}`).all(...params);
2584
+ const storedKeys = new Set;
2585
+ const stale = [];
2586
+ for (const row of stored) {
2587
+ const values = identity.map((name) => row[name] ?? null);
2588
+ const key = keyOf(values);
2589
+ storedKeys.add(key);
2590
+ if (!wanted.has(key))
2591
+ stale.push(values);
2592
+ }
2593
+ const looksTruncated = stale.length > ALWAYS_SAFE_TO_REMOVE && stale.length > stored.length * MAX_REMOVED_SHARE;
2594
+ const fresh = [...new Set(piece.map(keyFor))].filter((key) => !storedKeys.has(key));
2595
+ return { stale, looksTruncated, fresh };
2596
+ });
2597
+ const doubted = new Set;
2598
+ for (const p of judged) {
2599
+ if (p?.looksTruncated)
2600
+ for (const values of p.stale)
2601
+ doubted.add(keyOf(values));
2602
+ }
2603
+ const countedNew = new Set;
2604
+ const countedStale = new Set;
2605
+ for (const p of judged) {
2606
+ if (p === null)
2607
+ continue;
2608
+ for (const key of p.fresh) {
2609
+ if (countedNew.has(key))
2610
+ continue;
2611
+ countedNew.add(key);
2612
+ plan.added += 1;
2613
+ }
2614
+ for (const values of p.stale) {
2615
+ const key = keyOf(values);
2616
+ if (countedStale.has(key))
2617
+ continue;
2618
+ countedStale.add(key);
2619
+ if (doubted.has(key) && !options.prune) {
2620
+ plan.refused += 1;
2621
+ } else {
2622
+ if (doubted.has(key))
2623
+ plan.bypassed += 1;
2624
+ plan.stale.push(values);
2625
+ }
2626
+ }
2627
+ }
2628
+ return plan;
2629
+ }
2630
+ function applyWindowPlan(db, c, plan) {
2631
+ if (plan.stale.length === 0)
2632
+ return 0;
2633
+ const identity = identityColumns(c);
2634
+ const del = db.query(`DELETE FROM ${c.table} WHERE ${identity.map((name) => `${name} IS ?`).join(" AND ")}`);
2635
+ let removed = 0;
2636
+ for (const values of plan.stale)
2637
+ removed += del.run(...values).changes;
2638
+ return removed;
2639
+ }
2640
+ function minOf(values) {
2641
+ return values.reduce((a, b) => b < a ? b : a);
2642
+ }
2643
+ function maxOf(values) {
2644
+ return values.reduce((a, b) => b > a ? b : a);
2645
+ }
2646
+
2485
2647
  // src/db/sync.ts
2486
2648
  var BACKFILL_DAYS = 30;
2487
2649
  function lastDay(db, table, end) {
2488
2650
  return db.query(`SELECT MAX(day) AS d FROM ${table} WHERE day <= ?`).get(end).d;
2489
2651
  }
2490
- function rowCount(db, table) {
2491
- return db.query(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
2492
- }
2493
- async function importDaily(db, client, clock, log, window = {}) {
2652
+ async function importDaily(db, client, clock, log, window = {}, options = {}) {
2494
2653
  const { today, tz } = clock;
2495
2654
  const _log = log ?? (() => {});
2496
2655
  const end = window.to ?? today;
@@ -2505,10 +2664,25 @@ async function importDaily(db, client, clock, log, window = {}) {
2505
2664
  const isFirstSync = ranged.every((p) => p.last === null);
2506
2665
  const startDate = ranged.map((p) => p.resume).sort()[0];
2507
2666
  _log(isFirstSync && window.from === undefined ? `First sync \u2014 backfilling the last ${BACKFILL_DAYS} days: ${startDate} \u2192 ${end}` : `Syncing ${startDate} \u2192 ${end}`);
2667
+ if (options.prune !== undefined) {
2668
+ const scope = options.prune === "all" ? "every collection" : options.prune.join(", ");
2669
+ _log(`--prune: applying removals even where a response looks truncated (${scope})`);
2670
+ }
2508
2671
  const fetched = {};
2509
2672
  const added = {};
2673
+ const removed = {};
2674
+ const dropped = {};
2675
+ const refused = {};
2676
+ const pruned = {};
2677
+ const mayPrune = (name) => options.prune === "all" || (options.prune?.includes(name) ?? false);
2510
2678
  for (const { c, start } of plan) {
2511
- const rows = await fetchCollection(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)));
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("/")})` : "";
2512
2686
  const stmt = db.query(insertSql(c));
2513
2687
  if (c.rangeParams === "none") {
2514
2688
  const pk = c.columns.find((col) => col.pk)?.name;
@@ -2516,31 +2690,86 @@ async function importDaily(db, client, clock, log, window = {}) {
2516
2690
  throw new Error(`Snapshot collection ${c.name} must declare a primary-key column (enforced by the registry tests).`);
2517
2691
  const ids = () => new Set(db.query(`SELECT ${pk} AS id FROM ${c.table}`).all().map((r) => r.id));
2518
2692
  const known = ids();
2519
- db.transaction((rs) => {
2520
- db.exec(`DELETE FROM ${c.table}`);
2521
- for (const r of rs)
2522
- stmt.run(...rowValues(c, r));
2523
- })(rows);
2524
- fetched[c.table] = rows.length;
2525
- added[c.table] = [...ids()].filter((id) => !known.has(id)).length;
2526
- _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}`);
2527
2715
  continue;
2528
2716
  }
2529
- const before = rowCount(db, c.table);
2530
- db.transaction((rs) => {
2531
- for (const r of rs)
2532
- stmt.run(...rowValues(c, r));
2533
- })(rows);
2534
- fetched[c.table] = rows.length;
2535
- added[c.table] = rowCount(db, c.table) - before;
2536
- _log(` + ${c.name} (${c.table}): ${rows.length} fetched, ${added[c.table]} new`);
2717
+ const { windowPlan, gone } = db.transaction((ps) => {
2718
+ const windowPlan = planWindow(db, c, ps, { prune: mayPrune(c.name) });
2719
+ for (const piece of ps)
2720
+ for (const r of piece)
2721
+ stmt.run(...rowValues(c, r));
2722
+ return { windowPlan, gone: applyWindowPlan(db, c, windowPlan) };
2723
+ }).immediate(pieces);
2724
+ fetched[c.table] = rows.length + missing;
2725
+ added[c.table] = windowPlan.added;
2726
+ if (gone > 0)
2727
+ removed[c.table] = gone;
2728
+ if (windowPlan.refused > 0)
2729
+ refused[c.table] = { rows: windowPlan.refused, collection: c.name };
2730
+ if (windowPlan.bypassed > 0)
2731
+ pruned[c.table] = { rows: windowPlan.bypassed, collection: c.name };
2732
+ const tail = gone > 0 ? `, ${gone} stale removed${windowPlan.bypassed > 0 ? ` (${windowPlan.bypassed} past the truncation guard)` : ""}` : "";
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` : "";
2734
+ _log(` + ${c.name} (${c.table}): ${fetched[c.table]} fetched, ${added[c.table]} new${droppedTail}${tail}${kept}`);
2537
2735
  }
2538
2736
  _log("Import complete.");
2539
- return { startDate, endDate: end, fetched, added, isFirstSync };
2737
+ return {
2738
+ startDate,
2739
+ endDate: end,
2740
+ fetched,
2741
+ added,
2742
+ removed,
2743
+ dropped,
2744
+ refused,
2745
+ pruned,
2746
+ isFirstSync,
2747
+ ...options.prune === undefined ? {} : { pruneScope: options.prune }
2748
+ };
2749
+ }
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
+ };
2540
2769
  }
2541
2770
 
2542
2771
  // src/db/queries.ts
2543
- function getDaySummary(db, day) {
2772
+ function getDaySummary(db, day, complete) {
2544
2773
  const sl = db.query("SELECT score FROM daily_sleep WHERE day=?").get(day);
2545
2774
  const rd = db.query("SELECT score, temperature_deviation FROM daily_readiness WHERE day=?").get(day);
2546
2775
  const ac = db.query("SELECT score, steps FROM daily_activity WHERE day=?").get(day);
@@ -2549,6 +2778,7 @@ function getDaySummary(db, day) {
2549
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);
2550
2779
  return {
2551
2780
  day,
2781
+ partial: ac != null && !complete.isComplete(day),
2552
2782
  sleep_score: sl?.score ?? null,
2553
2783
  readiness_score: rd?.score ?? null,
2554
2784
  activity_score: ac?.score ?? null,
@@ -2567,15 +2797,17 @@ function getDaySummary(db, day) {
2567
2797
  function getTrends(db, days, today) {
2568
2798
  const start = shiftDay(today, -(days - 1));
2569
2799
  const results = [];
2800
+ const activityEnd = dayCompleteness(db, today).completeThrough(start, today) ?? shiftDay(start, -1);
2570
2801
  const metrics = [
2571
- ["Sleep Score", "daily_sleep", "score"],
2572
- ["Readiness", "daily_readiness", "score"],
2573
- ["Activity", "daily_activity", "score"],
2574
- ["Steps", "daily_activity", "steps"],
2575
- ["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]
2576
2807
  ];
2577
- for (const [label, table, col] of metrics) {
2578
- 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);
2579
2811
  if (row.count > 0 && row.avg !== null) {
2580
2812
  results.push({ label, avg: +row.avg.toFixed(0), min: row.min, max: row.max, count: row.count });
2581
2813
  }
@@ -2726,12 +2958,13 @@ function formatWeekTable(days, format, emptyHint) {
2726
2958
  }
2727
2959
  const header = `${"Day".padEnd(12)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Activity".padStart(9)} ${"Steps".padStart(7)} ${"Stress".padEnd(10)}`;
2728
2960
  const sep = source_default.gray("\u2500".repeat(56));
2729
- 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."] : [];
2730
2963
  return [`
2731
- Last 7 Days`, sep, ` ${header}`, sep, ...rows.map((r) => ` ${r}`)].join(`
2964
+ Last 7 Days`, sep, ` ${header}`, sep, ...rows.map((r) => ` ${r}`), ...note].join(`
2732
2965
  `);
2733
2966
  }
2734
- function formatTrends(trends, days, format) {
2967
+ function formatTrends(trends, days, format, emptyHint) {
2735
2968
  if (format === "json")
2736
2969
  return JSON.stringify(trends, null, 2);
2737
2970
  const lines = [
@@ -2739,13 +2972,15 @@ function formatTrends(trends, days, format) {
2739
2972
  source_default.bold(` Trends: last ${days} days`),
2740
2973
  source_default.gray("\u2500".repeat(50))
2741
2974
  ];
2975
+ if (emptyHint && trends.length === 0)
2976
+ lines.push(` No Oura data for the last ${days} days yet.`, ` ${emptyHint}`);
2742
2977
  for (const t of trends) {
2743
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)`);
2744
2979
  }
2745
2980
  return lines.join(`
2746
2981
  `);
2747
2982
  }
2748
- function formatStats(stats, format) {
2983
+ function formatStats(stats, format, emptyHint) {
2749
2984
  if (format === "json")
2750
2985
  return JSON.stringify(stats, null, 2);
2751
2986
  const lines = [
@@ -2753,6 +2988,10 @@ function formatStats(stats, format) {
2753
2988
  source_default.bold(" Database Statistics"),
2754
2989
  source_default.gray("\u2550".repeat(50))
2755
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
+ }
2756
2995
  for (const t of stats.tables) {
2757
2996
  lines.push(` ${t.table.padEnd(22)} ${String(t.rows).padStart(8)} rows`);
2758
2997
  }
@@ -2788,26 +3027,49 @@ function resolveWindow(opts, today) {
2788
3027
  }
2789
3028
  return { from, to };
2790
3029
  }
2791
- async function runSync(ctx, window = {}) {
3030
+ var PRUNE_ALL = "all";
3031
+ function resolvePruneScope(value) {
3032
+ if (value === undefined || value === false)
3033
+ return;
3034
+ const raw = value === true ? "" : String(value);
3035
+ const wanted = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
3036
+ if (wanted.length === 0 || wanted.some((n) => n.startsWith("-"))) {
3037
+ throw new CliError("BAD_ARGS", "--prune needs a value naming what to prune.", `Use --prune=<collection> (for example --prune=hr), a comma-separated list, or --prune=${PRUNE_ALL} for every collection.`);
3038
+ }
3039
+ if (wanted.includes(PRUNE_ALL)) {
3040
+ if (wanted.length === 1)
3041
+ return PRUNE_ALL;
3042
+ throw new CliError("BAD_ARGS", `--prune=${PRUNE_ALL} cannot be combined with collection names.`, `Use --prune=${PRUNE_ALL} on its own, or list the collections you mean.`);
3043
+ }
3044
+ const unknown = wanted.filter((n) => byName(n) === undefined);
3045
+ if (unknown.length > 0) {
3046
+ throw new CliError("BAD_ARGS", `--prune: unknown collection${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.`, `Known collections: ${names().join(", ")}.`);
3047
+ }
3048
+ return [...new Set(wanted)];
3049
+ }
3050
+ async function runSync(ctx, window = {}, options = {}) {
2792
3051
  const lines = [];
2793
3052
  const log = ctx.format === "table" ? (m) => lines.push(m) : undefined;
2794
- const importResult = await importDaily(ctx.db, ctx.client, { today: ctx.today, tz: ctx.tz }, log, window);
2795
- const today = getDaySummary(ctx.db, ctx.today);
3053
+ const importResult = await importDaily(ctx.db, ctx.client, { today: ctx.today, tz: ctx.tz }, log, window, options);
3054
+ const today = getDaySummary(ctx.db, ctx.today, dayCompleteness(ctx.db, ctx.today));
2796
3055
  return {
2797
3056
  json: { import: importResult, today },
2798
3057
  text: () => [...lines, formatImportSummary(importResult), formatDaySummary(today, "table")].join(`
2799
3058
  `)
2800
3059
  };
2801
3060
  }
2802
- var syncCommand = dataCommand({
3061
+ var syncArgs = {
3062
+ from: { type: "string", description: "Re-fetch every collection from this day (YYYY-MM-DD) instead of from its last stored day" },
3063
+ to: { type: "string", description: "End of the explicit window (YYYY-MM-DD, default: today); requires --from" },
3064
+ prune: { type: "string", description: "Apply removals sync kept back: --prune=hr, a list, or --prune=all" }
3065
+ };
3066
+ var syncDef = {
2803
3067
  meta: { name: "sync", description: "Import latest data from Oura API and return today's summary" },
2804
- args: {
2805
- from: { type: "string", description: "Re-fetch every collection from this day (YYYY-MM-DD) instead of from its last stored day" },
2806
- to: { type: "string", description: "End of the explicit window (YYYY-MM-DD, default: today); requires --from" }
2807
- },
3068
+ args: syncArgs,
2808
3069
  needs: { db: true, client: true },
2809
- run: (ctx, args) => runSync(ctx, resolveWindow({ from: args.from, to: args.to }, ctx.today))
2810
- });
3070
+ run: (ctx, args) => runSync(ctx, resolveWindow({ from: args.from, to: args.to }, ctx.today), { prune: resolvePruneScope(args.prune) })
3071
+ };
3072
+ var syncCommand = dataCommand(syncDef);
2811
3073
 
2812
3074
  // src/commands/db.ts
2813
3075
  var SYNC_HINT = "Run `oura-cli sync` to download your data. Oura publishes a day's summary after that night's sleep syncs from the ring.";
@@ -2818,7 +3080,7 @@ var dbCommand = defineCommand({
2818
3080
  meta: { name: "today", description: "Today's summary from local database" },
2819
3081
  needs: { db: true },
2820
3082
  run(ctx) {
2821
- const summary = getDaySummary(ctx.db, ctx.today);
3083
+ const summary = getDaySummary(ctx.db, ctx.today, dayCompleteness(ctx.db, ctx.today));
2822
3084
  return { json: summary, text: () => formatDaySummary(summary, "table", SYNC_HINT) };
2823
3085
  }
2824
3086
  }),
@@ -2828,7 +3090,7 @@ var dbCommand = defineCommand({
2828
3090
  needs: { db: true },
2829
3091
  run(ctx, args) {
2830
3092
  const day = assertCalendarDate(String(args.day), "<day>");
2831
- const summary = getDaySummary(ctx.db, day);
3093
+ const summary = getDaySummary(ctx.db, day, dayCompleteness(ctx.db, ctx.today));
2832
3094
  return { json: summary, text: () => formatDaySummary(summary, "table") };
2833
3095
  }
2834
3096
  }),
@@ -2836,7 +3098,8 @@ var dbCommand = defineCommand({
2836
3098
  meta: { name: "week", description: "Last 7 days from local database" },
2837
3099
  needs: { db: true },
2838
3100
  run(ctx) {
2839
- 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));
2840
3103
  return { json: days, text: () => formatWeekTable(days, "table", "Run `oura-cli sync`, then `oura-cli db week` again.") };
2841
3104
  }
2842
3105
  }),
@@ -2847,7 +3110,7 @@ var dbCommand = defineCommand({
2847
3110
  run(ctx, args) {
2848
3111
  const n = args.days === undefined ? 30 : assertPositiveInt(String(args.days), "<days>");
2849
3112
  const trends = getTrends(ctx.db, n, ctx.today);
2850
- 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.") };
2851
3114
  }
2852
3115
  }),
2853
3116
  stats: dataCommand({
@@ -2855,7 +3118,7 @@ var dbCommand = defineCommand({
2855
3118
  needs: { db: true },
2856
3119
  run(ctx) {
2857
3120
  const stats = getStats(ctx.db, ctx.today);
2858
- 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.") };
2859
3122
  }
2860
3123
  })
2861
3124
  }
@@ -2878,9 +3141,8 @@ function getReport(db, days, today) {
2878
3141
  const prevWeekStart = shiftDay(today, -(days * 2 - 1));
2879
3142
  const windowDays = daysBack(today, days);
2880
3143
  const lastUpload = db.query("SELECT MAX(timestamp) AS t FROM heartrate").get().t;
2881
- const newestActivityDay = db.query("SELECT MAX(day) AS d FROM daily_activity").get().d;
2882
- const isComplete = (d) => d < today && newestActivityDay !== null && newestActivityDay > d;
2883
- const completeThrough = [...windowDays].reverse().find(isComplete) ?? null;
3144
+ const complete = dayCompleteness(db, today);
3145
+ const completeThrough = complete.completeThrough(weekStart, today);
2884
3146
  const activityEnd = completeThrough ?? shiftDay(weekStart, -1);
2885
3147
  const dailyRows = [];
2886
3148
  for (const d of windowDays) {
@@ -2894,7 +3156,7 @@ function getReport(db, days, today) {
2894
3156
  readiness: rd?.score ?? null,
2895
3157
  activity: ac?.score ?? null,
2896
3158
  steps: ac?.steps ?? null,
2897
- partial: ac != null && !isComplete(d)
3159
+ partial: ac != null && !complete.isComplete(d)
2898
3160
  });
2899
3161
  }
2900
3162
  const metrics = [
@@ -2995,8 +3257,8 @@ var RECOMMENDATIONS = {
2995
3257
  };
2996
3258
  function bucketDaysIntoWeeks(days) {
2997
3259
  const buckets = [];
2998
- for (let i = 0;i < days.length; i += 7) {
2999
- 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);
3000
3262
  const weekOf = chunk[0].day;
3001
3263
  const sleepVals = chunk.map((d) => d.sleep).filter((v) => v !== null);
3002
3264
  const readinessVals = chunk.map((d) => d.readiness).filter((v) => v !== null);
@@ -3004,6 +3266,7 @@ function bucketDaysIntoWeeks(days) {
3004
3266
  const stepsVals = chunk.map((d) => d.steps).filter((v) => v !== null);
3005
3267
  buckets.push({
3006
3268
  weekOf,
3269
+ days: chunk.length,
3007
3270
  avgSleep: sleepVals.length > 0 ? sleepVals.reduce((a, b) => a + b, 0) / sleepVals.length : null,
3008
3271
  avgReadiness: readinessVals.length > 0 ? readinessVals.reduce((a, b) => a + b, 0) / readinessVals.length : null,
3009
3272
  avgActivity: activityVals.length > 0 ? activityVals.reduce((a, b) => a + b, 0) / activityVals.length : null,
@@ -3011,13 +3274,17 @@ function bucketDaysIntoWeeks(days) {
3011
3274
  partial: chunk.some((d) => d.partial)
3012
3275
  });
3013
3276
  }
3014
- return buckets;
3277
+ return buckets.reverse();
3278
+ }
3279
+ function bucketLabel(b) {
3280
+ const stub = b.days < 7 ? ` (${b.days} day${b.days === 1 ? "" : "s"})` : "";
3281
+ return `${b.weekOf}${stub}${b.partial ? "*" : ""}`;
3015
3282
  }
3016
- function partialDayNote(data) {
3283
+ function partialDayNote(data, bucket) {
3017
3284
  const partial = data.days.find((d) => d.partial);
3018
3285
  if (!partial)
3019
3286
  return null;
3020
- 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;
3021
3288
  const covers = data.completeThrough ? `through ${data.completeThrough}` : "no complete day yet";
3022
3289
  return ` * ${which} is still accumulating; activity averages cover ${covers}.`;
3023
3290
  }
@@ -3032,7 +3299,8 @@ function formatReport(data, format, period) {
3032
3299
  lines.push(source_default.bold(" Oura Monthly Report"));
3033
3300
  }
3034
3301
  lines.push(source_default.gray(` ${data.weekStart} \u2014 ${data.weekEnd}`));
3035
- const note = partialDayNote(data);
3302
+ const buckets = period === "month" ? bucketDaysIntoWeeks(data.days) : [];
3303
+ const note = partialDayNote(data, buckets.find((b) => b.partial));
3036
3304
  if (note)
3037
3305
  lines.push(source_default.yellow(note));
3038
3306
  lines.push("");
@@ -3054,16 +3322,15 @@ function formatReport(data, format, period) {
3054
3322
  }
3055
3323
  lines.push("");
3056
3324
  } else {
3057
- const buckets = bucketDaysIntoWeeks(data.days);
3058
3325
  lines.push(source_default.bold(" Last 30 Days:"));
3059
- lines.push(source_default.gray(" " + "\u2500".repeat(60)));
3060
- lines.push(` ${"Week of".padEnd(12)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(10)}`);
3061
- 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)));
3062
3329
  for (const b of buckets) {
3063
3330
  const avgSleepInt = b.avgSleep !== null ? Math.round(b.avgSleep) : null;
3064
3331
  const avgReadyInt = b.avgReadiness !== null ? Math.round(b.avgReadiness) : null;
3065
3332
  const avgActiveInt = b.avgActivity !== null ? Math.round(b.avgActivity) : null;
3066
- 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)}`);
3067
3334
  }
3068
3335
  lines.push("");
3069
3336
  }
@@ -3132,7 +3399,7 @@ var reportCommand = dataCommand({
3132
3399
  // src/commands/healthcheck.ts
3133
3400
  function healthcheckCommand(version) {
3134
3401
  return defineCommand({
3135
- meta: { name: "healthcheck", description: "Quick local DB health probe (JSON: {ok, version, latencyMs}, plus error when ok is false)." },
3402
+ meta: { name: "healthcheck", description: "Fast liveness probe: opens the local database and runs one query (JSON: {ok, version, latencyMs}, plus error when ok is false). It proves the file opens, not that its contents are intact \u2014 `doctor` checks that." },
3136
3403
  args: { ...commonArgs },
3137
3404
  run({ args }) {
3138
3405
  assertKnownArgs(commonArgs, args);
@@ -3213,8 +3480,18 @@ async function runChecks(deps) {
3213
3480
  checks.push({ id: "database", status: "fail", detail: msg });
3214
3481
  }
3215
3482
  if (db) {
3216
- const last = latestDataDay(db);
3217
- if (!last) {
3483
+ const damage = quickCheck(db);
3484
+ checks.push(damage === null ? { id: "integrity", status: "ok", detail: "Database passes SQLite quick_check." } : { id: "integrity", status: "fail", detail: `Database is damaged: ${damage}`, fix: "Delete the cache file (--db / OURA_DB_PATH) and run `oura-cli sync` to rebuild it." });
3485
+ let last = null;
3486
+ let readFailed;
3487
+ try {
3488
+ last = latestDataDay(db);
3489
+ } catch (err) {
3490
+ readFailed = err instanceof Error ? err.message : String(err);
3491
+ }
3492
+ if (readFailed !== undefined) {
3493
+ checks.push({ id: "data", status: "fail", detail: `Cannot read the cache: ${readFailed}`, fix: "Delete the cache file (--db / OURA_DB_PATH) and run `oura-cli sync` to rebuild it." });
3494
+ } else if (!last) {
3218
3495
  checks.push({ id: "data", status: "warn", detail: "No data in the local cache yet.", fix: "oura-cli sync" });
3219
3496
  } else {
3220
3497
  const ageDays = Math.round((new Date(`${deps.today}T00:00:00Z`).getTime() - new Date(`${last}T00:00:00Z`).getTime()) / 86400000);
@@ -3225,6 +3502,7 @@ async function runChecks(deps) {
3225
3502
  }
3226
3503
  }
3227
3504
  } else {
3505
+ checks.push({ id: "integrity", status: "fail", detail: "Cannot check integrity \u2014 database unavailable." });
3228
3506
  checks.push({ id: "data", status: "fail", detail: "Cannot check data \u2014 database unavailable." });
3229
3507
  }
3230
3508
  db?.close();
@@ -3233,6 +3511,15 @@ async function runChecks(deps) {
3233
3511
  const nextStep = checks.find((c) => !settled(c.status))?.fix ?? null;
3234
3512
  return { ok, checks, nextStep };
3235
3513
  }
3514
+ function quickCheck(db) {
3515
+ try {
3516
+ const rows = db.query("PRAGMA quick_check(1)").all();
3517
+ const first = rows[0] === undefined ? "ok" : Object.values(rows[0])[0] ?? "ok";
3518
+ return first === "ok" ? null : first;
3519
+ } catch (err) {
3520
+ return err instanceof Error ? err.message : String(err);
3521
+ }
3522
+ }
3236
3523
  var DATA_TABLES = ["daily_sleep", "daily_readiness", "daily_activity"];
3237
3524
  function latestDataDay(db) {
3238
3525
  let latest = null;
@@ -3373,9 +3660,54 @@ var fetchCommand = dataCommand({
3373
3660
  }
3374
3661
  });
3375
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
+
3376
3679
  // src/lib/citty-error.ts
3377
3680
  var ANSI2 = /\u001b\[[0-9;]*m/g;
3378
- function fromCittyError(err, removedCommandHints = {}) {
3681
+ var COMMAND_NAME = /^[a-z][a-z0-9-]{0,19}$/;
3682
+ function editDistanceAtMostOne(a, b) {
3683
+ if (Math.abs(a.length - b.length) > 1)
3684
+ return false;
3685
+ let i = 0, j = 0, edits = 0;
3686
+ while (i < a.length && j < b.length) {
3687
+ if (a[i] === b[j]) {
3688
+ i++;
3689
+ j++;
3690
+ continue;
3691
+ }
3692
+ if (++edits > 1)
3693
+ return false;
3694
+ if (a.length > b.length)
3695
+ i++;
3696
+ else if (a.length < b.length)
3697
+ j++;
3698
+ else {
3699
+ i++;
3700
+ j++;
3701
+ }
3702
+ }
3703
+ return edits + (a.length - i) + (b.length - j) <= 1;
3704
+ }
3705
+ function nearestGlobalFlag(token) {
3706
+ if (!token.startsWith("--") || token.length < 4)
3707
+ return;
3708
+ return [...GLOBAL_FLAGS_WITH_VALUE].find((flag) => flag !== token && (editDistanceAtMostOne(flag, token) || flag.startsWith(token)));
3709
+ }
3710
+ function fromCittyError(err, removedCommandHints = {}, rawArgs = []) {
3379
3711
  const code = err?.code;
3380
3712
  if (typeof code !== "string")
3381
3713
  return err;
@@ -3383,6 +3715,14 @@ function fromCittyError(err, removedCommandHints = {}) {
3383
3715
  switch (code) {
3384
3716
  case "E_UNKNOWN_COMMAND": {
3385
3717
  const name = message.replace(/^Unknown command\s*/, "").trim();
3718
+ const before = rawArgs[rawArgs.lastIndexOf(name) - 1];
3719
+ const meant = before === undefined ? undefined : nearestGlobalFlag(before);
3720
+ if (meant) {
3721
+ return new CliError("BAD_ARGS", `Unknown flag "${before}".`, `Did you mean ${meant}? Its value was read as a command name.`);
3722
+ }
3723
+ if (!COMMAND_NAME.test(name)) {
3724
+ return new CliError("BAD_ARGS", "Unknown command.", "A value was read as a command name. Check the flags before it, and run `oura-cli --help` for the list of commands.");
3725
+ }
3386
3726
  const hint = Object.hasOwn(removedCommandHints, name) ? removedCommandHints[name] : "Run `oura-cli --help` for the list of commands.";
3387
3727
  return new CliError("BAD_ARGS", `Unknown command "${name}".`, hint);
3388
3728
  }
@@ -3397,17 +3737,7 @@ function fromCittyError(err, removedCommandHints = {}) {
3397
3737
 
3398
3738
  // src/index.ts
3399
3739
  var VERSION = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf-8")).version;
3400
- var subCommands = Object.assign(Object.create(null), {
3401
- login: loginCommand,
3402
- describe: describeCommand(VERSION, () => subCommands),
3403
- healthcheck: healthcheckCommand(VERSION),
3404
- doctor: doctorCommand,
3405
- manifest: manifestCommand(VERSION, () => subCommands),
3406
- fetch: fetchCommand,
3407
- sync: syncCommand,
3408
- db: dbCommand,
3409
- report: reportCommand
3410
- });
3740
+ var subCommands = buildRegistry(VERSION);
3411
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`.";
3412
3742
  var REMOVED_COMMANDS = {
3413
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.",
@@ -3437,7 +3767,7 @@ if (isVersionRequest(rawArgs)) {
3437
3767
  runMain(main, { rawArgs });
3438
3768
  } else {
3439
3769
  runCommand(main, { rawArgs }).catch((raw) => {
3440
- const err = fromCittyError(raw, REMOVED_COMMANDS);
3770
+ const err = fromCittyError(raw, REMOVED_COMMANDS, rawArgs);
3441
3771
  emitError(err, formatFromArgv(rawArgs, process.stdout.isTTY === true));
3442
3772
  process.exit(exitCodeFor(err));
3443
3773
  });
@@ -13,7 +13,7 @@
13
13
  "required": ["id", "status", "detail"],
14
14
  "additionalProperties": false,
15
15
  "properties": {
16
- "id": { "type": "string", "enum": ["token", "token-valid", "database", "data"] },
16
+ "id": { "type": "string", "enum": ["token", "token-valid", "database", "integrity", "data"] },
17
17
  "status": { "type": "string", "enum": ["ok", "warn", "fail", "skip"] },
18
18
  "detail": { "type": "string" },
19
19
  "fix": { "type": "string" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/oura-cli",
3
- "version": "0.6.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",