@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 +11 -0
- package/README.md +1 -1
- package/dist/index.js +143 -65
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
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}): ${
|
|
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
|
|
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
|
|
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
|
|
3077
|
-
const
|
|
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
|
|
3194
|
-
const chunk = days.slice(
|
|
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
|
|
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
|
|
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(
|
|
3255
|
-
lines.push(` ${"Week of".padEnd(
|
|
3256
|
-
lines.push(source_default.gray(" " + "\u2500".repeat(
|
|
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
|
|
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 =
|
|
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