@drakulavich/oura-cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +12 -2
- package/dist/index.js +581 -177
- package/package.json +1 -1
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
|
|
|
@@ -1528,11 +1534,46 @@ function resolveToken(explicit, tokenPath) {
|
|
|
1528
1534
|
|
|
1529
1535
|
// src/api/client.ts
|
|
1530
1536
|
var BASE_URL = "https://api.ouraring.com/v2/usercollection";
|
|
1537
|
+
function kindOf(value) {
|
|
1538
|
+
if (value === null)
|
|
1539
|
+
return "null";
|
|
1540
|
+
if (Array.isArray(value))
|
|
1541
|
+
return "an array";
|
|
1542
|
+
return typeof value === "object" ? "an object" : `a ${typeof value}`;
|
|
1543
|
+
}
|
|
1531
1544
|
var MAX_PAGES = 1e4;
|
|
1545
|
+
var RETRY_LIMIT = 3;
|
|
1546
|
+
var MAX_RETRY_AFTER_MS = 60000;
|
|
1547
|
+
var MAX_TOTAL_WAIT_MS = 3 * 60000;
|
|
1548
|
+
var BACKOFF_MS = [1000, 2000, 4000];
|
|
1549
|
+
function retryDelayMs(retryAfter, attempt, now = Date.now()) {
|
|
1550
|
+
let ms = Number.NaN;
|
|
1551
|
+
if (retryAfter !== null) {
|
|
1552
|
+
const whole = retryAfter.trim();
|
|
1553
|
+
const firstPart = whole.split(",")[0].trim();
|
|
1554
|
+
const firstDate = whole.match(/^[A-Za-z]{3}, [^,]*? GMT/)?.[0] ?? "";
|
|
1555
|
+
const seconds = [whole, firstPart].filter((r) => r !== "").map(Number).find(Number.isFinite);
|
|
1556
|
+
const date = [whole, firstDate, firstPart].map(Date.parse).find(Number.isFinite);
|
|
1557
|
+
if (seconds !== undefined)
|
|
1558
|
+
ms = seconds * 1000;
|
|
1559
|
+
else if (date !== undefined)
|
|
1560
|
+
ms = date - now;
|
|
1561
|
+
}
|
|
1562
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
1563
|
+
ms = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)];
|
|
1564
|
+
return Math.min(ms, MAX_RETRY_AFTER_MS);
|
|
1565
|
+
}
|
|
1532
1566
|
|
|
1533
1567
|
class OuraClient {
|
|
1534
1568
|
token;
|
|
1569
|
+
onPage;
|
|
1570
|
+
onRetry;
|
|
1571
|
+
sleep;
|
|
1572
|
+
waitedMs = 0;
|
|
1535
1573
|
constructor(options = {}) {
|
|
1574
|
+
this.onPage = options.onPage;
|
|
1575
|
+
this.onRetry = options.onRetry;
|
|
1576
|
+
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1536
1577
|
const { token, source } = resolveToken(options.token, options.tokenPath);
|
|
1537
1578
|
if (!token) {
|
|
1538
1579
|
throw new CliError("TOKEN_MISSING", `No Oura access token at ${source}.`, "Run `oura-cli login` or set OURA_TOKEN.");
|
|
@@ -1553,7 +1594,8 @@ class OuraClient {
|
|
|
1553
1594
|
const params = new URLSearchParams(query);
|
|
1554
1595
|
if (nextToken)
|
|
1555
1596
|
params.set("next_token", nextToken);
|
|
1556
|
-
const page = await this.getPage(`${BASE_URL}/${endpoint}?${params}`);
|
|
1597
|
+
const page = await this.getPage(endpoint, `${BASE_URL}/${endpoint}?${params}`);
|
|
1598
|
+
this.onPage?.({ endpoint, rows: page.data.length });
|
|
1557
1599
|
for (const row of page.data)
|
|
1558
1600
|
rows.push(row);
|
|
1559
1601
|
nextToken = page.next_token;
|
|
@@ -1565,17 +1607,38 @@ class OuraClient {
|
|
|
1565
1607
|
} while (nextToken);
|
|
1566
1608
|
return rows;
|
|
1567
1609
|
}
|
|
1568
|
-
async
|
|
1569
|
-
|
|
1570
|
-
headers: { Authorization: `Bearer ${this.token}` }
|
|
1571
|
-
})
|
|
1610
|
+
async request(url) {
|
|
1611
|
+
try {
|
|
1612
|
+
return await fetch(url, { headers: { Authorization: `Bearer ${this.token}` } });
|
|
1613
|
+
} catch (err) {
|
|
1614
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1615
|
+
throw new CliError("API_ERROR", `Could not reach the Oura API: ${msg}`, "Check the network connection and try again.");
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
async getPage(endpoint, url) {
|
|
1619
|
+
let response = await this.request(url);
|
|
1620
|
+
let retries = 0;
|
|
1621
|
+
for (;response.status === 429 && retries < RETRY_LIMIT; retries++) {
|
|
1622
|
+
const waitMs = retryDelayMs(response.headers.get("retry-after"), retries);
|
|
1623
|
+
if (this.waitedMs + waitMs > MAX_TOTAL_WAIT_MS)
|
|
1624
|
+
break;
|
|
1625
|
+
await response.body?.cancel();
|
|
1626
|
+
this.waitedMs += waitMs;
|
|
1627
|
+
this.onRetry?.({ endpoint, waitMs, attempt: retries });
|
|
1628
|
+
await this.sleep(waitMs);
|
|
1629
|
+
response = await this.request(url);
|
|
1630
|
+
}
|
|
1572
1631
|
if (!response.ok) {
|
|
1573
1632
|
const rawBody = await response.text();
|
|
1574
|
-
const redacted = redactSecrets(rawBody);
|
|
1633
|
+
const redacted = redactSecrets(rawBody).split(this.token).join("[REDACTED]");
|
|
1575
1634
|
const body = redacted.length > 200 ? redacted.slice(0, 200) + "\u2026 (truncated)" : redacted;
|
|
1576
1635
|
if (response.status === 401 || response.status === 403) {
|
|
1577
1636
|
throw new CliError("TOKEN_INVALID", `Oura API ${response.status}: ${body}`, "Run `oura-cli login` with a fresh Personal Access Token, or check OURA_TOKEN.");
|
|
1578
1637
|
}
|
|
1638
|
+
if (response.status === 429) {
|
|
1639
|
+
const waited = Math.round(this.waitedMs / 1000);
|
|
1640
|
+
throw new CliError("API_ERROR", `Oura API 429: ${body}`, `Rate limited; this page was retried ${retries} time${retries === 1 ? "" : "s"} and the command has waited ${waited} s on 429 answers (the most it will is ${MAX_TOTAL_WAIT_MS / 1000} s). Wait a few minutes and run it again, or ask for a shorter range.`);
|
|
1641
|
+
}
|
|
1579
1642
|
throw new CliError("API_ERROR", `Oura API ${response.status}: ${body}`);
|
|
1580
1643
|
}
|
|
1581
1644
|
let json;
|
|
@@ -1585,6 +1648,15 @@ class OuraClient {
|
|
|
1585
1648
|
throw new CliError("API_ERROR", "Empty response body from Oura API.");
|
|
1586
1649
|
}
|
|
1587
1650
|
const body = json;
|
|
1651
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
1652
|
+
throw new CliError("API_ERROR", `Oura API returned a malformed body for ${endpoint}: expected an object, got ${kindOf(body)}.`);
|
|
1653
|
+
}
|
|
1654
|
+
if (body.data != null && !Array.isArray(body.data)) {
|
|
1655
|
+
throw new CliError("API_ERROR", `Oura API returned a malformed body for ${endpoint}: expected an array under "data", got ${kindOf(body.data)}.`);
|
|
1656
|
+
}
|
|
1657
|
+
if (body.next_token != null && typeof body.next_token !== "string") {
|
|
1658
|
+
throw new CliError("API_ERROR", `Oura API returned a malformed body for ${endpoint}: expected a string or null under "next_token", got ${kindOf(body.next_token)}.`);
|
|
1659
|
+
}
|
|
1588
1660
|
return { data: body.data ?? [], next_token: body.next_token ?? null };
|
|
1589
1661
|
}
|
|
1590
1662
|
}
|
|
@@ -1679,6 +1751,45 @@ function formatFromArgv(argv, isTty) {
|
|
|
1679
1751
|
}
|
|
1680
1752
|
}
|
|
1681
1753
|
|
|
1754
|
+
// src/lib/progress.ts
|
|
1755
|
+
function pageProgress(sink, verb) {
|
|
1756
|
+
let endpoint;
|
|
1757
|
+
let pages = 0;
|
|
1758
|
+
let rows = 0;
|
|
1759
|
+
let widest = 0;
|
|
1760
|
+
let written = false;
|
|
1761
|
+
const show = (text) => {
|
|
1762
|
+
widest = Math.max(widest, text.length);
|
|
1763
|
+
written = true;
|
|
1764
|
+
sink.write(`\r${text}`);
|
|
1765
|
+
};
|
|
1766
|
+
const status = () => pages === 0 ? ` ${verb} ${endpoint}` : ` ${verb} ${endpoint}: page ${pages}, ${rows} rows so far`;
|
|
1767
|
+
return {
|
|
1768
|
+
onPage(page) {
|
|
1769
|
+
if (page.endpoint !== endpoint) {
|
|
1770
|
+
endpoint = page.endpoint;
|
|
1771
|
+
pages = 0;
|
|
1772
|
+
rows = 0;
|
|
1773
|
+
}
|
|
1774
|
+
pages++;
|
|
1775
|
+
rows += page.rows;
|
|
1776
|
+
show(`${status()}\u2026`);
|
|
1777
|
+
},
|
|
1778
|
+
onRetry(retry) {
|
|
1779
|
+
if (retry.endpoint !== endpoint) {
|
|
1780
|
+
endpoint = retry.endpoint;
|
|
1781
|
+
pages = 0;
|
|
1782
|
+
rows = 0;
|
|
1783
|
+
}
|
|
1784
|
+
show(`${status()}; rate limited, retrying in ${Math.ceil(retry.waitMs / 1000)} s\u2026`);
|
|
1785
|
+
},
|
|
1786
|
+
done() {
|
|
1787
|
+
if (written)
|
|
1788
|
+
sink.write(`\r${" ".repeat(widest)}\r`);
|
|
1789
|
+
}
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1682
1793
|
// src/lib/time.ts
|
|
1683
1794
|
function nowUtc() {
|
|
1684
1795
|
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
@@ -1788,7 +1899,8 @@ var processIo = {
|
|
|
1788
1899
|
`);
|
|
1789
1900
|
},
|
|
1790
1901
|
exit: (code) => process.exit(code),
|
|
1791
|
-
isTty: process.stdout.isTTY === true
|
|
1902
|
+
isTty: process.stdout.isTTY === true,
|
|
1903
|
+
progress: process.stderr.isTTY ? process.stderr : undefined
|
|
1792
1904
|
};
|
|
1793
1905
|
var camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
1794
1906
|
function assertKnownArgs(declared, args) {
|
|
@@ -1809,7 +1921,7 @@ function assertKnownArgs(declared, args) {
|
|
|
1809
1921
|
const unknown = Object.keys(args).filter((k) => !known.has(k));
|
|
1810
1922
|
if (unknown.length > 0) {
|
|
1811
1923
|
const flags = unknown.map((f) => f.length === 1 ? `-${f}` : `--${f}`).join(", ");
|
|
1812
|
-
const hint = unknown.every((f) => f.length === 1) ? 'oura-cli has no single-letter flags; a value that starts with "-" must come after "--".' : "Run the command with --help to see its flags.";
|
|
1924
|
+
const hint = unknown.every((f) => f.length === 1) ? 'oura-cli has no single-letter flags other than -v and -h; a value that starts with "-" must come after "--".' : "Run the command with --help to see its flags.";
|
|
1813
1925
|
throw new CliError("BAD_ARGS", `Unknown flag${unknown.length > 1 ? "s" : ""}: ${flags}.`, hint);
|
|
1814
1926
|
}
|
|
1815
1927
|
const extra = (args._ ?? []).slice(positionals);
|
|
@@ -1828,16 +1940,25 @@ async function execute(def, args, io = processIo) {
|
|
|
1828
1940
|
assertKnownArgs({ ...commonArgs, ...def.args ?? {} }, args);
|
|
1829
1941
|
const outputFormat = def.jsonOnly ? "json" : format;
|
|
1830
1942
|
const tz = assertTimezone(args.tz ?? resolveDefaultTimezone());
|
|
1831
|
-
const ctx = { format: outputFormat, tz, today: today(tz) };
|
|
1943
|
+
const ctx = { format: outputFormat, tz, today: today(tz), ...io.progress ? { progress: io.progress } : {} };
|
|
1832
1944
|
if (def.needs?.db) {
|
|
1833
1945
|
db = openDatabase(args.db);
|
|
1834
1946
|
ensureSchema(db);
|
|
1835
1947
|
ctx.db = db;
|
|
1836
1948
|
}
|
|
1949
|
+
const progress = def.needs?.client && io.progress ? pageProgress(io.progress, "syncing") : undefined;
|
|
1837
1950
|
if (def.needs?.client) {
|
|
1838
|
-
ctx.client = new OuraClient(
|
|
1951
|
+
ctx.client = new OuraClient({
|
|
1952
|
+
...args.token ? { token: args.token } : {},
|
|
1953
|
+
...progress ? { onPage: progress.onPage, onRetry: progress.onRetry } : {}
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
let out;
|
|
1957
|
+
try {
|
|
1958
|
+
out = await def.run(ctx, args);
|
|
1959
|
+
} finally {
|
|
1960
|
+
progress?.done();
|
|
1839
1961
|
}
|
|
1840
|
-
const out = await def.run(ctx, args);
|
|
1841
1962
|
io.stdout(outputFormat === "json" ? JSON.stringify(out.json, null, 2) : out.text());
|
|
1842
1963
|
exitCode = out.exitCode ?? 0;
|
|
1843
1964
|
} catch (raw) {
|
|
@@ -2013,7 +2134,8 @@ var activity = defineCollection({
|
|
|
2013
2134
|
{ name: "total_calories", type: "INTEGER", pick: (r) => r.total_calories },
|
|
2014
2135
|
{ name: "target_calories", type: "INTEGER", pick: (r) => r.target_calories },
|
|
2015
2136
|
{ name: "contributors", type: "TEXT", pick: (r) => JSON.stringify(r.contributors) },
|
|
2016
|
-
{ name: "timestamp", type: "TEXT", pick: (r) => r.timestamp }
|
|
2137
|
+
{ name: "timestamp", type: "TEXT", pick: (r) => r.timestamp },
|
|
2138
|
+
{ name: "class_5_min_slots", type: "INTEGER", pick: (r) => r.class_5_min?.length ?? null }
|
|
2017
2139
|
]
|
|
2018
2140
|
});
|
|
2019
2141
|
|
|
@@ -2385,6 +2507,24 @@ function insertSql(c) {
|
|
|
2385
2507
|
function rowValues(c, row) {
|
|
2386
2508
|
return c.columns.map((col) => col.pick(row));
|
|
2387
2509
|
}
|
|
2510
|
+
function identityColumns(c) {
|
|
2511
|
+
const unique = c.columns.filter((col) => col.unique).map((col) => col.name);
|
|
2512
|
+
if (unique.length > 0)
|
|
2513
|
+
return unique;
|
|
2514
|
+
const pk = c.columns.filter((col) => col.pk).map((col) => col.name);
|
|
2515
|
+
if (pk.length > 0)
|
|
2516
|
+
return pk;
|
|
2517
|
+
return (c.indexes ?? []).find((i) => i.unique)?.columns ?? [];
|
|
2518
|
+
}
|
|
2519
|
+
function hasIdentity(c, row) {
|
|
2520
|
+
if (row == null || typeof row !== "object")
|
|
2521
|
+
return false;
|
|
2522
|
+
return identityColumns(c).every((name) => {
|
|
2523
|
+
const col = c.columns.find((k) => k.name === name);
|
|
2524
|
+
const v = col?.pick(row);
|
|
2525
|
+
return col?.type === "TEXT" ? typeof v === "string" && v !== "" : typeof v === "number" && Number.isFinite(v);
|
|
2526
|
+
});
|
|
2527
|
+
}
|
|
2388
2528
|
var MS_PER_DAY = 86400000;
|
|
2389
2529
|
function dateQueries(start, end, maxDays, offset) {
|
|
2390
2530
|
const query = (s, e) => ({ start_date: shiftDay(s, offset[0]), end_date: shiftDay(e, offset[1]) });
|
|
@@ -2417,18 +2557,19 @@ function rangeQueries(c, start, end, tz) {
|
|
|
2417
2557
|
async function fetchCollectionByPiece(client, c, start, end, tz) {
|
|
2418
2558
|
const pieces = [];
|
|
2419
2559
|
for (const query of rangeQueries(c, start, end, tz)) {
|
|
2420
|
-
pieces.push(await client.fetch(c.endpoint, query));
|
|
2560
|
+
pieces.push({ query, rows: await client.fetch(c.endpoint, query) });
|
|
2421
2561
|
}
|
|
2422
2562
|
return pieces;
|
|
2423
2563
|
}
|
|
2424
2564
|
async function fetchCollection(client, c, start, end, tz) {
|
|
2425
|
-
return (await fetchCollectionByPiece(client, c, start, end, tz)).
|
|
2565
|
+
return (await fetchCollectionByPiece(client, c, start, end, tz)).flatMap((p) => p.rows);
|
|
2426
2566
|
}
|
|
2427
2567
|
|
|
2428
2568
|
// src/commands/describe.ts
|
|
2429
2569
|
var OUTPUT_SCHEMAS = { doctor: "docs/schemas/doctor.json", describe: "docs/schemas/describe.json" };
|
|
2430
2570
|
var ENUM_ARGS = {
|
|
2431
2571
|
fetch: { collection: names() },
|
|
2572
|
+
rows: { collection: names() },
|
|
2432
2573
|
report: { period: ["week", "month"] }
|
|
2433
2574
|
};
|
|
2434
2575
|
function resolved(def) {
|
|
@@ -2525,21 +2666,25 @@ function describeCommand(version, getCommands) {
|
|
|
2525
2666
|
}
|
|
2526
2667
|
|
|
2527
2668
|
// 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
2669
|
var MAX_REMOVED_SHARE = 0.5;
|
|
2538
2670
|
var ALWAYS_SAFE_TO_REMOVE = 5;
|
|
2539
2671
|
var KEY_SEPARATOR = "\x00";
|
|
2540
2672
|
function emptyPlan() {
|
|
2541
2673
|
return { added: 0, stale: [], refused: 0, bypassed: 0 };
|
|
2542
2674
|
}
|
|
2675
|
+
function requestedRange(c, query) {
|
|
2676
|
+
if (c.rangeParams === "datetime") {
|
|
2677
|
+
const from = Date.parse(query.start_datetime ?? "");
|
|
2678
|
+
const to = Date.parse(query.end_datetime ?? "");
|
|
2679
|
+
return (value) => {
|
|
2680
|
+
const t = Date.parse(value);
|
|
2681
|
+
return t >= from && t <= to;
|
|
2682
|
+
};
|
|
2683
|
+
}
|
|
2684
|
+
const from = query.start_date;
|
|
2685
|
+
const to = query.end_date;
|
|
2686
|
+
return (value) => from !== undefined && to !== undefined && value >= from && value <= to;
|
|
2687
|
+
}
|
|
2543
2688
|
function keyOf(values) {
|
|
2544
2689
|
return values.map((v) => String(v)).join(KEY_SEPARATOR);
|
|
2545
2690
|
}
|
|
@@ -2554,12 +2699,13 @@ function planWindow(db, c, pieces, options = {}) {
|
|
|
2554
2699
|
if (scopePick === undefined || identityPicks.some((p) => p === undefined))
|
|
2555
2700
|
return emptyPlan();
|
|
2556
2701
|
const keyFor = (row) => keyOf(identityPicks.map((pick) => pick(row)));
|
|
2557
|
-
const wanted = new Set(pieces.
|
|
2702
|
+
const wanted = new Set(pieces.flatMap((p) => p.rows).map(keyFor));
|
|
2558
2703
|
const plan = emptyPlan();
|
|
2559
2704
|
const judged = pieces.map((piece) => {
|
|
2560
|
-
if (piece.length === 0)
|
|
2705
|
+
if (piece.rows.length === 0)
|
|
2561
2706
|
return null;
|
|
2562
|
-
const
|
|
2707
|
+
const inRequested = requestedRange(c, piece.query);
|
|
2708
|
+
const scopeValues = piece.rows.map((row) => scopePick(row)).filter((v) => v !== null && v !== undefined).map(String).filter(inRequested);
|
|
2563
2709
|
if (scopeValues.length === 0)
|
|
2564
2710
|
return null;
|
|
2565
2711
|
const days = [...new Set(scopeValues)];
|
|
@@ -2575,7 +2721,7 @@ function planWindow(db, c, pieces, options = {}) {
|
|
|
2575
2721
|
stale.push(values);
|
|
2576
2722
|
}
|
|
2577
2723
|
const looksTruncated = stale.length > ALWAYS_SAFE_TO_REMOVE && stale.length > stored.length * MAX_REMOVED_SHARE;
|
|
2578
|
-
const fresh = [...new Set(piece.map(keyFor))].filter((key) => !storedKeys.has(key));
|
|
2724
|
+
const fresh = [...new Set(piece.rows.map(keyFor))].filter((key) => !storedKeys.has(key));
|
|
2579
2725
|
return { stale, looksTruncated, fresh };
|
|
2580
2726
|
});
|
|
2581
2727
|
const doubted = new Set;
|
|
@@ -2655,12 +2801,18 @@ async function importDaily(db, client, clock, log, window = {}, options = {}) {
|
|
|
2655
2801
|
const fetched = {};
|
|
2656
2802
|
const added = {};
|
|
2657
2803
|
const removed = {};
|
|
2804
|
+
const dropped = {};
|
|
2658
2805
|
const refused = {};
|
|
2659
2806
|
const pruned = {};
|
|
2660
2807
|
const mayPrune = (name) => options.prune === "all" || (options.prune?.includes(name) ?? false);
|
|
2661
2808
|
for (const { c, start } of plan) {
|
|
2662
|
-
const
|
|
2663
|
-
const
|
|
2809
|
+
const returned = await fetchCollectionByPiece(client, c, start, end, tz);
|
|
2810
|
+
const pieces = returned.map((piece) => ({ ...piece, rows: piece.rows.filter((r) => hasIdentity(c, r)) }));
|
|
2811
|
+
const rows = pieces.flatMap((p) => p.rows);
|
|
2812
|
+
const missing = returned.flatMap((p) => p.rows).length - rows.length;
|
|
2813
|
+
if (missing > 0)
|
|
2814
|
+
dropped[c.table] = missing;
|
|
2815
|
+
const droppedTail = missing > 0 ? `, ${missing} dropped (no ${identityColumns(c).join("/")})` : "";
|
|
2664
2816
|
const stmt = db.query(insertSql(c));
|
|
2665
2817
|
if (c.rangeParams === "none") {
|
|
2666
2818
|
const pk = c.columns.find((col) => col.pk)?.name;
|
|
@@ -2668,24 +2820,38 @@ async function importDaily(db, client, clock, log, window = {}, options = {}) {
|
|
|
2668
2820
|
throw new Error(`Snapshot collection ${c.name} must declare a primary-key column (enforced by the registry tests).`);
|
|
2669
2821
|
const ids = () => new Set(db.query(`SELECT ${pk} AS id FROM ${c.table}`).all().map((r) => r.id));
|
|
2670
2822
|
const known = ids();
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2823
|
+
const unstorable = rows.length === 0 && missing > 0;
|
|
2824
|
+
const refuse = rows.length === 0 && known.size > 0 && (unstorable || !mayPrune(c.name));
|
|
2825
|
+
if (!refuse) {
|
|
2826
|
+
db.transaction((rs) => {
|
|
2827
|
+
db.exec(`DELETE FROM ${c.table}`);
|
|
2828
|
+
for (const r of rs)
|
|
2829
|
+
stmt.run(...rowValues(c, r));
|
|
2830
|
+
})(rows);
|
|
2831
|
+
}
|
|
2832
|
+
const now = ids();
|
|
2833
|
+
const gone = [...known].filter((id) => !now.has(id)).length;
|
|
2834
|
+
fetched[c.table] = rows.length + missing;
|
|
2835
|
+
added[c.table] = [...now].filter((id) => !known.has(id)).length;
|
|
2836
|
+
if (gone > 0)
|
|
2837
|
+
removed[c.table] = gone;
|
|
2838
|
+
if (refuse && !unstorable)
|
|
2839
|
+
refused[c.table] = { rows: known.size, collection: c.name };
|
|
2840
|
+
else if (rows.length === 0 && gone > 0)
|
|
2841
|
+
pruned[c.table] = { rows: gone, collection: c.name };
|
|
2842
|
+
const tail = gone > 0 ? `, ${gone} stale removed${pruned[c.table] ? " (past the truncation guard)" : ""}` : "";
|
|
2843
|
+
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`;
|
|
2844
|
+
_log(` + ${c.name} (${c.table}): ${fetched[c.table]} fetched, ${added[c.table]} new${droppedTail}${tail}${kept}`);
|
|
2679
2845
|
continue;
|
|
2680
2846
|
}
|
|
2681
2847
|
const { windowPlan, gone } = db.transaction((ps) => {
|
|
2682
2848
|
const windowPlan = planWindow(db, c, ps, { prune: mayPrune(c.name) });
|
|
2683
2849
|
for (const piece of ps)
|
|
2684
|
-
for (const r of piece)
|
|
2850
|
+
for (const r of piece.rows)
|
|
2685
2851
|
stmt.run(...rowValues(c, r));
|
|
2686
2852
|
return { windowPlan, gone: applyWindowPlan(db, c, windowPlan) };
|
|
2687
2853
|
}).immediate(pieces);
|
|
2688
|
-
fetched[c.table] = rows.length;
|
|
2854
|
+
fetched[c.table] = rows.length + missing;
|
|
2689
2855
|
added[c.table] = windowPlan.added;
|
|
2690
2856
|
if (gone > 0)
|
|
2691
2857
|
removed[c.table] = gone;
|
|
@@ -2695,7 +2861,7 @@ async function importDaily(db, client, clock, log, window = {}, options = {}) {
|
|
|
2695
2861
|
pruned[c.table] = { rows: windowPlan.bypassed, collection: c.name };
|
|
2696
2862
|
const tail = gone > 0 ? `, ${gone} stale removed${windowPlan.bypassed > 0 ? ` (${windowPlan.bypassed} past the truncation guard)` : ""}` : "";
|
|
2697
2863
|
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}): ${
|
|
2864
|
+
_log(` + ${c.name} (${c.table}): ${fetched[c.table]} fetched, ${added[c.table]} new${droppedTail}${tail}${kept}`);
|
|
2699
2865
|
}
|
|
2700
2866
|
_log("Import complete.");
|
|
2701
2867
|
return {
|
|
@@ -2704,6 +2870,7 @@ async function importDaily(db, client, clock, log, window = {}, options = {}) {
|
|
|
2704
2870
|
fetched,
|
|
2705
2871
|
added,
|
|
2706
2872
|
removed,
|
|
2873
|
+
dropped,
|
|
2707
2874
|
refused,
|
|
2708
2875
|
pruned,
|
|
2709
2876
|
isFirstSync,
|
|
@@ -2711,8 +2878,28 @@ async function importDaily(db, client, clock, log, window = {}, options = {}) {
|
|
|
2711
2878
|
};
|
|
2712
2879
|
}
|
|
2713
2880
|
|
|
2881
|
+
// src/db/day-complete.ts
|
|
2882
|
+
var SLOTS_PER_DAY = 288;
|
|
2883
|
+
function dayCompleteness(db, today) {
|
|
2884
|
+
const rows = db.query("SELECT day, class_5_min_slots AS slots FROM daily_activity").all();
|
|
2885
|
+
const slotsByDay = new Map(rows.map((r) => [r.day, r.slots]));
|
|
2886
|
+
const daysDesc = rows.map((r) => r.day).sort().reverse();
|
|
2887
|
+
const newestDay = daysDesc[0] ?? null;
|
|
2888
|
+
const isComplete = (day) => {
|
|
2889
|
+
if (!slotsByDay.has(day))
|
|
2890
|
+
return false;
|
|
2891
|
+
if ((slotsByDay.get(day) ?? 0) >= SLOTS_PER_DAY)
|
|
2892
|
+
return true;
|
|
2893
|
+
return day < today && newestDay !== null && newestDay > day;
|
|
2894
|
+
};
|
|
2895
|
+
return {
|
|
2896
|
+
isComplete,
|
|
2897
|
+
completeThrough: (start, end) => daysDesc.find((d) => d >= start && d <= end && isComplete(d)) ?? null
|
|
2898
|
+
};
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2714
2901
|
// src/db/queries.ts
|
|
2715
|
-
function getDaySummary(db, day) {
|
|
2902
|
+
function getDaySummary(db, day, complete) {
|
|
2716
2903
|
const sl = db.query("SELECT score FROM daily_sleep WHERE day=?").get(day);
|
|
2717
2904
|
const rd = db.query("SELECT score, temperature_deviation FROM daily_readiness WHERE day=?").get(day);
|
|
2718
2905
|
const ac = db.query("SELECT score, steps FROM daily_activity WHERE day=?").get(day);
|
|
@@ -2721,6 +2908,7 @@ function getDaySummary(db, day) {
|
|
|
2721
2908
|
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
2909
|
return {
|
|
2723
2910
|
day,
|
|
2911
|
+
partial: ac != null && !complete.isComplete(day),
|
|
2724
2912
|
sleep_score: sl?.score ?? null,
|
|
2725
2913
|
readiness_score: rd?.score ?? null,
|
|
2726
2914
|
activity_score: ac?.score ?? null,
|
|
@@ -2739,15 +2927,17 @@ function getDaySummary(db, day) {
|
|
|
2739
2927
|
function getTrends(db, days, today) {
|
|
2740
2928
|
const start = shiftDay(today, -(days - 1));
|
|
2741
2929
|
const results = [];
|
|
2930
|
+
const activityEnd = dayCompleteness(db, today).completeThrough(start, today) ?? shiftDay(start, -1);
|
|
2742
2931
|
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"]
|
|
2932
|
+
["Sleep Score", "daily_sleep", "score", false],
|
|
2933
|
+
["Readiness", "daily_readiness", "score", false],
|
|
2934
|
+
["Activity", "daily_activity", "score", true],
|
|
2935
|
+
["Steps", "daily_activity", "steps", true],
|
|
2936
|
+
["Active Cal", "daily_activity", "active_calories", true]
|
|
2748
2937
|
];
|
|
2749
|
-
for (const [label, table, col] of metrics) {
|
|
2750
|
-
const
|
|
2938
|
+
for (const [label, table, col, accumulates] of metrics) {
|
|
2939
|
+
const end = accumulates ? activityEnd : today;
|
|
2940
|
+
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
2941
|
if (row.count > 0 && row.avg !== null) {
|
|
2752
2942
|
results.push({ label, avg: +row.avg.toFixed(0), min: row.min, max: row.max, count: row.count });
|
|
2753
2943
|
}
|
|
@@ -2761,7 +2951,7 @@ function getTrends(db, days, today) {
|
|
|
2761
2951
|
function getStats(db, today) {
|
|
2762
2952
|
const tables = COLLECTIONS.map((c) => {
|
|
2763
2953
|
const row = db.query(`SELECT COUNT(*) as cnt FROM ${c.table}`).get();
|
|
2764
|
-
return { table: c.table, rows: row.cnt };
|
|
2954
|
+
return { collection: c.name, table: c.table, rows: row.cnt };
|
|
2765
2955
|
});
|
|
2766
2956
|
const range = db.query("SELECT MIN(day) as first, MAX(day) as last FROM daily_sleep").get();
|
|
2767
2957
|
const trends = getTrends(db, 99999, today);
|
|
@@ -2797,7 +2987,28 @@ function padRight(text, width) {
|
|
|
2797
2987
|
|
|
2798
2988
|
// src/lib/terminal.ts
|
|
2799
2989
|
function terminalWidth() {
|
|
2800
|
-
return
|
|
2990
|
+
return screenWidth() ?? 80;
|
|
2991
|
+
}
|
|
2992
|
+
function screenWidth() {
|
|
2993
|
+
return process.stdout.isTTY && process.stdout.columns ? process.stdout.columns : undefined;
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
// src/render/rule.ts
|
|
2997
|
+
init_source();
|
|
2998
|
+
var INDENT = " ";
|
|
2999
|
+
function rule(width, glyph = "\u2500", max = screenWidth()) {
|
|
3000
|
+
const drawn = max === undefined ? width : Math.max(0, Math.min(width, max - INDENT.length));
|
|
3001
|
+
return drawn === 0 ? "" : source_default.gray(INDENT + glyph.repeat(drawn));
|
|
3002
|
+
}
|
|
3003
|
+
var PLACEHOLDER = "<rule ";
|
|
3004
|
+
var RULE = `${PLACEHOLDER}\u2500>`;
|
|
3005
|
+
var DOUBLE_RULE = `${PLACEHOLDER}\u2550>`;
|
|
3006
|
+
function finish(lines, max = screenWidth()) {
|
|
3007
|
+
let widest = 0;
|
|
3008
|
+
for (const l of lines)
|
|
3009
|
+
if (!l.startsWith(PLACEHOLDER))
|
|
3010
|
+
widest = Math.max(widest, visibleWidth(l) - INDENT.length);
|
|
3011
|
+
return lines.map((l) => l.startsWith(PLACEHOLDER) ? rule(widest, l.slice(PLACEHOLDER.length, -1), max) : l);
|
|
2801
3012
|
}
|
|
2802
3013
|
|
|
2803
3014
|
// src/render/format.ts
|
|
@@ -2822,19 +3033,19 @@ function formatDaySummary(summary, format, emptyHint) {
|
|
|
2822
3033
|
if (format === "json")
|
|
2823
3034
|
return JSON.stringify(summary, null, 2);
|
|
2824
3035
|
if (emptyHint && isEmptyDay(summary)) {
|
|
2825
|
-
return [
|
|
3036
|
+
return finish([
|
|
2826
3037
|
"",
|
|
2827
3038
|
source_default.bold(` ${summary.day}`),
|
|
2828
|
-
|
|
3039
|
+
RULE,
|
|
2829
3040
|
` No Oura data for ${summary.day} yet.`,
|
|
2830
3041
|
` ${emptyHint}`
|
|
2831
|
-
].join(`
|
|
3042
|
+
]).join(`
|
|
2832
3043
|
`);
|
|
2833
3044
|
}
|
|
2834
3045
|
const lines = [
|
|
2835
3046
|
"",
|
|
2836
|
-
source_default.bold(` ${summary.day}`),
|
|
2837
|
-
|
|
3047
|
+
source_default.bold(` ${summary.partial ? `${summary.day}*` : summary.day}`),
|
|
3048
|
+
RULE,
|
|
2838
3049
|
` Sleep: ${scoreColor(summary.sleep_score)} Readiness: ${scoreColor(summary.readiness_score)} Activity: ${scoreColor(summary.activity_score)}`,
|
|
2839
3050
|
` Steps: ${summary.steps ?? source_default.gray("\u2014")}`
|
|
2840
3051
|
];
|
|
@@ -2851,9 +3062,13 @@ function formatDaySummary(summary, format, emptyHint) {
|
|
|
2851
3062
|
lines.push(` Sleep: ${fmtHours(summary.sleep_hours)} total | ${fmtHours(summary.deep_hours)} deep | ${fmtHours(summary.rem_hours)} REM`);
|
|
2852
3063
|
lines.push(` HRV: ${summary.avg_hrv ?? "\u2014"} Lowest HR: ${summary.lowest_hr ?? "\u2014"} Efficiency: ${summary.efficiency ?? "\u2014"}%`);
|
|
2853
3064
|
}
|
|
2854
|
-
|
|
3065
|
+
if (summary.partial)
|
|
3066
|
+
lines.push("", PARTIAL_NOTE);
|
|
3067
|
+
return finish(lines).join(`
|
|
2855
3068
|
`);
|
|
2856
3069
|
}
|
|
3070
|
+
var PARTIAL_NOTE = " * activity totals are not final.";
|
|
3071
|
+
var PUBLISH_DELAY_NOTE = "Oura publishes a day's summary after that night's sleep syncs from the ring.";
|
|
2857
3072
|
var SUMMARY_INDENT = 4;
|
|
2858
3073
|
var SUMMARY_GAP = 2;
|
|
2859
3074
|
var SUMMARY_MAX_COLUMNS = 4;
|
|
@@ -2896,38 +3111,48 @@ function formatWeekTable(days, format, emptyHint) {
|
|
|
2896
3111
|
].join(`
|
|
2897
3112
|
`);
|
|
2898
3113
|
}
|
|
2899
|
-
const header = `${"Day".padEnd(12)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Activity".padStart(9)} ${"Steps".padStart(7)}
|
|
2900
|
-
const
|
|
2901
|
-
const
|
|
3114
|
+
const header = `${"Day".padEnd(12)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Activity".padStart(9)} ${"Steps".padStart(7)} Stress`;
|
|
3115
|
+
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)} ${d.stress ?? "\u2014"}`);
|
|
3116
|
+
const sep = rule(Math.max(visibleWidth(header), ...rows.map(visibleWidth)));
|
|
3117
|
+
const note = days.some((d) => d.partial) ? [PARTIAL_NOTE] : [];
|
|
2902
3118
|
return [`
|
|
2903
|
-
Last 7 Days`, sep, ` ${header}`, sep, ...rows.map((r) => ` ${r}`)].join(`
|
|
3119
|
+
Last 7 Days`, sep, ` ${header}`, sep, ...rows.map((r) => ` ${r}`), ...note].join(`
|
|
2904
3120
|
`);
|
|
2905
3121
|
}
|
|
2906
|
-
function formatTrends(trends, days, format) {
|
|
3122
|
+
function formatTrends(trends, days, format, emptyHint) {
|
|
2907
3123
|
if (format === "json")
|
|
2908
3124
|
return JSON.stringify(trends, null, 2);
|
|
2909
3125
|
const lines = [
|
|
2910
3126
|
"",
|
|
2911
3127
|
source_default.bold(` Trends: last ${days} days`),
|
|
2912
|
-
|
|
3128
|
+
RULE
|
|
2913
3129
|
];
|
|
3130
|
+
if (emptyHint && trends.length === 0)
|
|
3131
|
+
lines.push(` No Oura data for the last ${days} days yet.`, ` ${emptyHint}`);
|
|
2914
3132
|
for (const t of trends) {
|
|
2915
3133
|
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
3134
|
}
|
|
2917
|
-
return lines.join(`
|
|
3135
|
+
return finish(lines).join(`
|
|
2918
3136
|
`);
|
|
2919
3137
|
}
|
|
2920
|
-
function formatStats(stats, format) {
|
|
3138
|
+
function formatStats(stats, format, emptyHint) {
|
|
2921
3139
|
if (format === "json")
|
|
2922
3140
|
return JSON.stringify(stats, null, 2);
|
|
2923
3141
|
const lines = [
|
|
2924
3142
|
"",
|
|
2925
3143
|
source_default.bold(" Database Statistics"),
|
|
2926
|
-
|
|
3144
|
+
DOUBLE_RULE
|
|
2927
3145
|
];
|
|
3146
|
+
if (emptyHint && stats.tables.every((t) => t.rows === 0)) {
|
|
3147
|
+
return finish([...lines, " No Oura data in the database yet.", ` ${emptyHint}`]).join(`
|
|
3148
|
+
`);
|
|
3149
|
+
}
|
|
2928
3150
|
for (const t of stats.tables) {
|
|
2929
|
-
lines.push(` ${t.table
|
|
3151
|
+
lines.push(` ${`${t.collection} (${t.table})`.padEnd(38)} ${String(t.rows).padStart(8)} row${t.rows === 1 ? "" : "s"}`);
|
|
2930
3152
|
}
|
|
3153
|
+
const noDailyData = stats.dateRange.first === null && stats.trends.length === 0 && stats.records.mostSteps === null && stats.records.bestSleep === null;
|
|
3154
|
+
if (emptyHint && noDailyData)
|
|
3155
|
+
lines.push("", " No daily summaries in the database yet.", ` ${emptyHint}`);
|
|
2931
3156
|
if (stats.dateRange.first) {
|
|
2932
3157
|
lines.push(`
|
|
2933
3158
|
Date range: ${stats.dateRange.first} \u2192 ${stats.dateRange.last}`);
|
|
@@ -2942,7 +3167,7 @@ function formatStats(stats, format) {
|
|
|
2942
3167
|
if (stats.records.bestSleep) {
|
|
2943
3168
|
lines.push(` Best sleep: ${stats.records.bestSleep.score} on ${stats.records.bestSleep.day}`);
|
|
2944
3169
|
}
|
|
2945
|
-
return lines.join(`
|
|
3170
|
+
return finish(lines).join(`
|
|
2946
3171
|
`);
|
|
2947
3172
|
}
|
|
2948
3173
|
|
|
@@ -2980,14 +3205,15 @@ function resolvePruneScope(value) {
|
|
|
2980
3205
|
}
|
|
2981
3206
|
return [...new Set(wanted)];
|
|
2982
3207
|
}
|
|
3208
|
+
var TODAY_HINT_AFTER_SYNC = `${PUBLISH_DELAY_NOTE} Run \`oura-cli sync\` again later.`;
|
|
2983
3209
|
async function runSync(ctx, window = {}, options = {}) {
|
|
2984
3210
|
const lines = [];
|
|
2985
3211
|
const log = ctx.format === "table" ? (m) => lines.push(m) : undefined;
|
|
2986
3212
|
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);
|
|
3213
|
+
const today = getDaySummary(ctx.db, ctx.today, dayCompleteness(ctx.db, ctx.today));
|
|
2988
3214
|
return {
|
|
2989
3215
|
json: { import: importResult, today },
|
|
2990
|
-
text: () => [...lines, formatImportSummary(importResult), formatDaySummary(today, "table")].join(`
|
|
3216
|
+
text: () => [...lines, formatImportSummary(importResult), formatDaySummary(today, "table", TODAY_HINT_AFTER_SYNC)].join(`
|
|
2991
3217
|
`)
|
|
2992
3218
|
};
|
|
2993
3219
|
}
|
|
@@ -2997,15 +3223,171 @@ var syncArgs = {
|
|
|
2997
3223
|
prune: { type: "string", description: "Apply removals sync kept back: --prune=hr, a list, or --prune=all" }
|
|
2998
3224
|
};
|
|
2999
3225
|
var syncDef = {
|
|
3000
|
-
meta: { name: "sync", description: "
|
|
3226
|
+
meta: { name: "sync", description: "Download new Oura data into the local cache and report what each collection fetched" },
|
|
3001
3227
|
args: syncArgs,
|
|
3002
3228
|
needs: { db: true, client: true },
|
|
3003
3229
|
run: (ctx, args) => runSync(ctx, resolveWindow({ from: args.from, to: args.to }, ctx.today), { prune: resolvePruneScope(args.prune) })
|
|
3004
3230
|
};
|
|
3005
3231
|
var syncCommand = dataCommand(syncDef);
|
|
3006
3232
|
|
|
3233
|
+
// src/db/rows.ts
|
|
3234
|
+
function getRows(db, c, range, tz) {
|
|
3235
|
+
const cols = c.columns.map((k) => k.name);
|
|
3236
|
+
if (range !== null && !cols.includes("day"))
|
|
3237
|
+
throw new Error(`getRows: ${c.name} has no day column to bound a range on.`);
|
|
3238
|
+
const order = [...new Set([...cols.includes("day") ? ["day"] : [], ...identityColumns(c)])].join(", ");
|
|
3239
|
+
const select = `SELECT ${cols.join(", ")} FROM ${c.table}`;
|
|
3240
|
+
if (range === null)
|
|
3241
|
+
return db.query(`${select} ORDER BY ${order}`).all();
|
|
3242
|
+
const between = db.query(`${select} WHERE day BETWEEN ? AND ? ORDER BY ${order}`);
|
|
3243
|
+
if (c.rangeParams !== "datetime")
|
|
3244
|
+
return between.all(range.start, range.end);
|
|
3245
|
+
const fromMs = Date.parse(localDateToUtcRange(range.start, tz)[0]);
|
|
3246
|
+
const toMs = Date.parse(localDateToUtcRange(range.end, tz)[1]);
|
|
3247
|
+
const instant = (r) => Date.parse(String(r.timestamp));
|
|
3248
|
+
return between.all(shiftDay(range.start, -1), shiftDay(range.end, 1)).filter((r) => {
|
|
3249
|
+
const t = instant(r);
|
|
3250
|
+
return t >= fromMs && t < toMs;
|
|
3251
|
+
}).sort((a, b) => instant(a) - instant(b));
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
// src/render/format-rows.ts
|
|
3255
|
+
init_source();
|
|
3256
|
+
var MAX_CELL = 40;
|
|
3257
|
+
var MIN_CELL = 8;
|
|
3258
|
+
var GAP = 2;
|
|
3259
|
+
function printable(value) {
|
|
3260
|
+
if (value === null)
|
|
3261
|
+
return "\u2014";
|
|
3262
|
+
return String(value).replace(/\r\n|\r|\n/g, "\u23CE").replace(/[\u0000-\u001f\u007f]/g, " ");
|
|
3263
|
+
}
|
|
3264
|
+
function clip(text, width) {
|
|
3265
|
+
return text.length > width ? `${text.slice(0, Math.max(0, width - 1))}\u2026` : text;
|
|
3266
|
+
}
|
|
3267
|
+
function widest(values, floor = 0) {
|
|
3268
|
+
let out = floor;
|
|
3269
|
+
for (const v of values)
|
|
3270
|
+
if (v > out)
|
|
3271
|
+
out = v;
|
|
3272
|
+
return out;
|
|
3273
|
+
}
|
|
3274
|
+
function fit(natural, floors, available) {
|
|
3275
|
+
const out = [...natural];
|
|
3276
|
+
while (out.reduce((sum, w) => sum + w, 0) > available) {
|
|
3277
|
+
let target = -1;
|
|
3278
|
+
for (let i = 0;i < out.length; i++) {
|
|
3279
|
+
if (out[i] > floors[i] && (target === -1 || out[i] > out[target]))
|
|
3280
|
+
target = i;
|
|
3281
|
+
}
|
|
3282
|
+
if (target === -1)
|
|
3283
|
+
return null;
|
|
3284
|
+
out[target]--;
|
|
3285
|
+
}
|
|
3286
|
+
return out;
|
|
3287
|
+
}
|
|
3288
|
+
function records(names, texts, max) {
|
|
3289
|
+
const nameW = widest(names.map(visibleWidth));
|
|
3290
|
+
const valueW = max === undefined ? Number.POSITIVE_INFINITY : Math.max(MIN_CELL, max - INDENT.length - nameW - GAP);
|
|
3291
|
+
const lines = [];
|
|
3292
|
+
texts.forEach((row, r) => {
|
|
3293
|
+
if (r > 0)
|
|
3294
|
+
lines.push("");
|
|
3295
|
+
names.forEach((name, i) => lines.push(`${INDENT}${padRight(name, nameW)}${" ".repeat(GAP)}${clip(row[i], valueW)}`.trimEnd()));
|
|
3296
|
+
});
|
|
3297
|
+
return lines;
|
|
3298
|
+
}
|
|
3299
|
+
function formatRows(c, rows, scope, format, emptyHint, max = screenWidth(), total = rows.length) {
|
|
3300
|
+
if (format === "json")
|
|
3301
|
+
return JSON.stringify(rows, null, 2);
|
|
3302
|
+
const all = Math.max(total, rows.length);
|
|
3303
|
+
const count = rows.length === all ? `${all} row${all === 1 ? "" : "s"}` : `${rows.length} of ${all} rows`;
|
|
3304
|
+
const title = source_default.bold(` ${c.name} (${c.table}): ${count}${scope}`);
|
|
3305
|
+
if (rows.length === 0) {
|
|
3306
|
+
return finish(["", title, RULE, ` No cached ${c.name} rows${scope}.`, ` ${emptyHint}`], max).join(`
|
|
3307
|
+
`);
|
|
3308
|
+
}
|
|
3309
|
+
const names = c.columns.map((k) => k.name);
|
|
3310
|
+
const numeric = new Set(c.columns.filter((k) => k.type !== "TEXT").map((k) => k.name));
|
|
3311
|
+
const texts = rows.map((r) => names.map((name) => clip(printable(r[name] ?? null), MAX_CELL)));
|
|
3312
|
+
const headers = names.map(visibleWidth);
|
|
3313
|
+
const natural = names.map((_, i) => widest(texts.map((row) => visibleWidth(row[i])), headers[i]));
|
|
3314
|
+
const gaps = GAP * (names.length - 1);
|
|
3315
|
+
const widths = max === undefined ? natural : fit(natural, headers.map((h) => Math.max(h, MIN_CELL)), max - INDENT.length - gaps);
|
|
3316
|
+
if (widths === null)
|
|
3317
|
+
return finish(["", title, RULE, ...records(names, texts, max)], max).join(`
|
|
3318
|
+
`);
|
|
3319
|
+
const line = (parts) => `${INDENT}${parts.map((p, i) => numeric.has(names[i]) ? padLeft(clip(p, widths[i]), widths[i]) : padRight(clip(p, widths[i]), widths[i])).join(" ".repeat(GAP))}`.trimEnd();
|
|
3320
|
+
return finish(["", title, RULE, line(names), RULE, ...texts.map(line)], max).join(`
|
|
3321
|
+
`);
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
// src/commands/fetch.ts
|
|
3325
|
+
function resolveRange(opts) {
|
|
3326
|
+
const modes = [opts.day !== undefined, opts.from !== undefined || opts.to !== undefined, opts.days !== undefined].filter(Boolean).length;
|
|
3327
|
+
if (modes > 1)
|
|
3328
|
+
throw new CliError("BAD_ARGS", "Use only one of --day, --from/--to, or --days.");
|
|
3329
|
+
if (opts.day !== undefined) {
|
|
3330
|
+
const d = assertCalendarDate(opts.day, "--day");
|
|
3331
|
+
return { start: d, end: d };
|
|
3332
|
+
}
|
|
3333
|
+
if (opts.from !== undefined || opts.to !== undefined) {
|
|
3334
|
+
if (opts.from === undefined || opts.to === undefined)
|
|
3335
|
+
throw new CliError("BAD_ARGS", "--from and --to must be given together.");
|
|
3336
|
+
const start = assertCalendarDate(opts.from, "--from");
|
|
3337
|
+
const end = assertCalendarDate(opts.to, "--to");
|
|
3338
|
+
if (start > end)
|
|
3339
|
+
throw new CliError("BAD_ARGS", `--from (${start}) must not be after --to (${end}).`);
|
|
3340
|
+
return { start, end };
|
|
3341
|
+
}
|
|
3342
|
+
if (opts.days !== undefined) {
|
|
3343
|
+
const n = assertPositiveInt(opts.days, "--days");
|
|
3344
|
+
return { start: shiftDay(opts.today, -(n - 1)), end: opts.today };
|
|
3345
|
+
}
|
|
3346
|
+
return { start: opts.today, end: opts.today };
|
|
3347
|
+
}
|
|
3348
|
+
function assertRangeAllowed(c, opts) {
|
|
3349
|
+
if (c.rangeParams === "none" && [opts.day, opts.from, opts.to, opts.days].some((v) => v !== undefined)) {
|
|
3350
|
+
throw new CliError("BAD_ARGS", `"${c.name}" is a snapshot, not a day range; it takes no --day, --from/--to or --days.`);
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
var fetchCommand = dataCommand({
|
|
3354
|
+
meta: { name: "fetch", description: "Fetch raw records for one Oura collection straight from the API (JSON)." },
|
|
3355
|
+
args: {
|
|
3356
|
+
collection: { type: "positional", required: true, description: `Collection: ${names().join(" | ")} (ring is a snapshot and takes no range flags)` },
|
|
3357
|
+
day: { type: "string", description: "Single day (YYYY-MM-DD). Default: today." },
|
|
3358
|
+
from: { type: "string", description: "Range start (YYYY-MM-DD); requires --to" },
|
|
3359
|
+
to: { type: "string", description: "Range end (YYYY-MM-DD); requires --from" },
|
|
3360
|
+
days: { type: "string", description: "Last N days ending today" }
|
|
3361
|
+
},
|
|
3362
|
+
jsonOnly: true,
|
|
3363
|
+
async run(ctx, args) {
|
|
3364
|
+
const c = byName(args.collection);
|
|
3365
|
+
if (!c)
|
|
3366
|
+
throw new CliError("BAD_ARGS", `Unknown collection "${args.collection}".`, `Valid collections: ${names().join(", ")}`);
|
|
3367
|
+
const opts = {
|
|
3368
|
+
day: args.day,
|
|
3369
|
+
from: args.from,
|
|
3370
|
+
to: args.to,
|
|
3371
|
+
days: args.days
|
|
3372
|
+
};
|
|
3373
|
+
assertRangeAllowed(c, opts);
|
|
3374
|
+
const { start, end } = resolveRange({ ...opts, today: ctx.today });
|
|
3375
|
+
const progress = ctx.progress ? pageProgress(ctx.progress, "fetching") : undefined;
|
|
3376
|
+
const client = new OuraClient({
|
|
3377
|
+
...args.token ? { token: args.token } : {},
|
|
3378
|
+
...progress ? { onPage: progress.onPage, onRetry: progress.onRetry } : {}
|
|
3379
|
+
});
|
|
3380
|
+
try {
|
|
3381
|
+
const data = await fetchCollection(client, c, start, end, ctx.tz);
|
|
3382
|
+
return { json: data, text: () => JSON.stringify(data, null, 2) };
|
|
3383
|
+
} finally {
|
|
3384
|
+
progress?.done();
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
});
|
|
3388
|
+
|
|
3007
3389
|
// src/commands/db.ts
|
|
3008
|
-
var SYNC_HINT =
|
|
3390
|
+
var SYNC_HINT = `Run \`oura-cli sync\` to download your data. ${PUBLISH_DELAY_NOTE}`;
|
|
3009
3391
|
var dbCommand = defineCommand({
|
|
3010
3392
|
meta: { name: "db", description: "Query and manage the local SQLite database" },
|
|
3011
3393
|
subCommands: {
|
|
@@ -3013,7 +3395,7 @@ var dbCommand = defineCommand({
|
|
|
3013
3395
|
meta: { name: "today", description: "Today's summary from local database" },
|
|
3014
3396
|
needs: { db: true },
|
|
3015
3397
|
run(ctx) {
|
|
3016
|
-
const summary = getDaySummary(ctx.db, ctx.today);
|
|
3398
|
+
const summary = getDaySummary(ctx.db, ctx.today, dayCompleteness(ctx.db, ctx.today));
|
|
3017
3399
|
return { json: summary, text: () => formatDaySummary(summary, "table", SYNC_HINT) };
|
|
3018
3400
|
}
|
|
3019
3401
|
}),
|
|
@@ -3023,7 +3405,7 @@ var dbCommand = defineCommand({
|
|
|
3023
3405
|
needs: { db: true },
|
|
3024
3406
|
run(ctx, args) {
|
|
3025
3407
|
const day = assertCalendarDate(String(args.day), "<day>");
|
|
3026
|
-
const summary = getDaySummary(ctx.db, day);
|
|
3408
|
+
const summary = getDaySummary(ctx.db, day, dayCompleteness(ctx.db, ctx.today));
|
|
3027
3409
|
return { json: summary, text: () => formatDaySummary(summary, "table") };
|
|
3028
3410
|
}
|
|
3029
3411
|
}),
|
|
@@ -3031,7 +3413,8 @@ var dbCommand = defineCommand({
|
|
|
3031
3413
|
meta: { name: "week", description: "Last 7 days from local database" },
|
|
3032
3414
|
needs: { db: true },
|
|
3033
3415
|
run(ctx) {
|
|
3034
|
-
const
|
|
3416
|
+
const complete = dayCompleteness(ctx.db, ctx.today);
|
|
3417
|
+
const days = daysBack(ctx.today, 7).map((d) => getDaySummary(ctx.db, d, complete));
|
|
3035
3418
|
return { json: days, text: () => formatWeekTable(days, "table", "Run `oura-cli sync`, then `oura-cli db week` again.") };
|
|
3036
3419
|
}
|
|
3037
3420
|
}),
|
|
@@ -3042,7 +3425,7 @@ var dbCommand = defineCommand({
|
|
|
3042
3425
|
run(ctx, args) {
|
|
3043
3426
|
const n = args.days === undefined ? 30 : assertPositiveInt(String(args.days), "<days>");
|
|
3044
3427
|
const trends = getTrends(ctx.db, n, ctx.today);
|
|
3045
|
-
return { json: trends, text: () => formatTrends(trends, n, "table") };
|
|
3428
|
+
return { json: trends, text: () => formatTrends(trends, n, "table", "Run `oura-cli sync`, then `oura-cli db trends` again.") };
|
|
3046
3429
|
}
|
|
3047
3430
|
}),
|
|
3048
3431
|
stats: dataCommand({
|
|
@@ -3050,7 +3433,38 @@ var dbCommand = defineCommand({
|
|
|
3050
3433
|
needs: { db: true },
|
|
3051
3434
|
run(ctx) {
|
|
3052
3435
|
const stats = getStats(ctx.db, ctx.today);
|
|
3053
|
-
return { json: stats, text: () => formatStats(stats, "table") };
|
|
3436
|
+
return { json: stats, text: () => formatStats(stats, "table", "Run `oura-cli sync`, then `oura-cli db stats` again.") };
|
|
3437
|
+
}
|
|
3438
|
+
}),
|
|
3439
|
+
rows: dataCommand({
|
|
3440
|
+
meta: { name: "rows", description: "Cached rows of one collection, as stored: the local twin of `fetch`" },
|
|
3441
|
+
args: {
|
|
3442
|
+
collection: { type: "positional", required: true, description: `Collection: ${names().join(" | ")} (ring is a snapshot and takes no range flags)` },
|
|
3443
|
+
day: { type: "string", description: "Single day (YYYY-MM-DD). Default: today." },
|
|
3444
|
+
from: { type: "string", description: "Range start (YYYY-MM-DD); requires --to" },
|
|
3445
|
+
to: { type: "string", description: "Range end (YYYY-MM-DD); requires --from" },
|
|
3446
|
+
days: { type: "string", description: "Last N days ending today" },
|
|
3447
|
+
limit: { type: "string", description: "Print at most N rows, the earliest first (a day of heart rate is hundreds)" }
|
|
3448
|
+
},
|
|
3449
|
+
needs: { db: true },
|
|
3450
|
+
run(ctx, args) {
|
|
3451
|
+
const c = byName(String(args.collection));
|
|
3452
|
+
if (!c)
|
|
3453
|
+
throw new CliError("BAD_ARGS", `Unknown collection "${args.collection}".`, `Valid collections: ${names().join(", ")}`);
|
|
3454
|
+
const opts = {
|
|
3455
|
+
day: args.day,
|
|
3456
|
+
from: args.from,
|
|
3457
|
+
to: args.to,
|
|
3458
|
+
days: args.days
|
|
3459
|
+
};
|
|
3460
|
+
assertRangeAllowed(c, opts);
|
|
3461
|
+
const range = c.rangeParams === "none" ? null : resolveRange({ ...opts, today: ctx.today });
|
|
3462
|
+
const limit = args.limit === undefined ? undefined : assertPositiveInt(String(args.limit), "--limit");
|
|
3463
|
+
const all = getRows(ctx.db, c, range, ctx.tz);
|
|
3464
|
+
const rows = limit === undefined ? all : all.slice(0, limit);
|
|
3465
|
+
const scope = range === null ? "" : range.start === range.end ? ` for ${range.start}` : ` for ${range.start} \u2192 ${range.end}`;
|
|
3466
|
+
const hint = range === null ? `Run \`oura-cli sync\` to fill the cache, or \`oura-cli fetch ${c.name}\` to read the API.` : `Run \`oura-cli sync\` (\`sync --from <day>\` for older days), or \`oura-cli fetch ${c.name}\` to read the API.`;
|
|
3467
|
+
return { json: rows, text: () => formatRows(c, rows, scope, "table", hint, undefined, all.length) };
|
|
3054
3468
|
}
|
|
3055
3469
|
})
|
|
3056
3470
|
}
|
|
@@ -3073,9 +3487,8 @@ function getReport(db, days, today) {
|
|
|
3073
3487
|
const prevWeekStart = shiftDay(today, -(days * 2 - 1));
|
|
3074
3488
|
const windowDays = daysBack(today, days);
|
|
3075
3489
|
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;
|
|
3490
|
+
const complete = dayCompleteness(db, today);
|
|
3491
|
+
const completeThrough = complete.completeThrough(weekStart, today);
|
|
3079
3492
|
const activityEnd = completeThrough ?? shiftDay(weekStart, -1);
|
|
3080
3493
|
const dailyRows = [];
|
|
3081
3494
|
for (const d of windowDays) {
|
|
@@ -3089,7 +3502,7 @@ function getReport(db, days, today) {
|
|
|
3089
3502
|
readiness: rd?.score ?? null,
|
|
3090
3503
|
activity: ac?.score ?? null,
|
|
3091
3504
|
steps: ac?.steps ?? null,
|
|
3092
|
-
partial: ac != null && !isComplete(d)
|
|
3505
|
+
partial: ac != null && !complete.isComplete(d)
|
|
3093
3506
|
});
|
|
3094
3507
|
}
|
|
3095
3508
|
const metrics = [
|
|
@@ -3190,8 +3603,8 @@ var RECOMMENDATIONS = {
|
|
|
3190
3603
|
};
|
|
3191
3604
|
function bucketDaysIntoWeeks(days) {
|
|
3192
3605
|
const buckets = [];
|
|
3193
|
-
for (let
|
|
3194
|
-
const chunk = days.slice(
|
|
3606
|
+
for (let end = days.length;end > 0; end -= 7) {
|
|
3607
|
+
const chunk = days.slice(Math.max(0, end - 7), end);
|
|
3195
3608
|
const weekOf = chunk[0].day;
|
|
3196
3609
|
const sleepVals = chunk.map((d) => d.sleep).filter((v) => v !== null);
|
|
3197
3610
|
const readinessVals = chunk.map((d) => d.readiness).filter((v) => v !== null);
|
|
@@ -3199,6 +3612,7 @@ function bucketDaysIntoWeeks(days) {
|
|
|
3199
3612
|
const stepsVals = chunk.map((d) => d.steps).filter((v) => v !== null);
|
|
3200
3613
|
buckets.push({
|
|
3201
3614
|
weekOf,
|
|
3615
|
+
days: chunk.length,
|
|
3202
3616
|
avgSleep: sleepVals.length > 0 ? sleepVals.reduce((a, b) => a + b, 0) / sleepVals.length : null,
|
|
3203
3617
|
avgReadiness: readinessVals.length > 0 ? readinessVals.reduce((a, b) => a + b, 0) / readinessVals.length : null,
|
|
3204
3618
|
avgActivity: activityVals.length > 0 ? activityVals.reduce((a, b) => a + b, 0) / activityVals.length : null,
|
|
@@ -3206,15 +3620,19 @@ function bucketDaysIntoWeeks(days) {
|
|
|
3206
3620
|
partial: chunk.some((d) => d.partial)
|
|
3207
3621
|
});
|
|
3208
3622
|
}
|
|
3209
|
-
return buckets;
|
|
3623
|
+
return buckets.reverse();
|
|
3624
|
+
}
|
|
3625
|
+
function bucketLabel(b) {
|
|
3626
|
+
const stub = b.days < 7 ? ` (${b.days} day${b.days === 1 ? "" : "s"})` : "";
|
|
3627
|
+
return `${b.weekOf}${stub}${b.partial ? "*" : ""}`;
|
|
3210
3628
|
}
|
|
3211
|
-
function partialDayNote(data) {
|
|
3629
|
+
function partialDayNote(data, bucket) {
|
|
3212
3630
|
const partial = data.days.find((d) => d.partial);
|
|
3213
3631
|
if (!partial)
|
|
3214
3632
|
return null;
|
|
3215
|
-
const which = partial.day === data.weekEnd ? "today" : partial.dayLabel;
|
|
3633
|
+
const which = bucket ? `the week of ${bucket.weekOf}` : partial.day === data.weekEnd ? "today" : partial.dayLabel;
|
|
3216
3634
|
const covers = data.completeThrough ? `through ${data.completeThrough}` : "no complete day yet";
|
|
3217
|
-
return ` * ${which}
|
|
3635
|
+
return ` * ${which}: activity totals are not final; averages cover ${covers}.`;
|
|
3218
3636
|
}
|
|
3219
3637
|
function formatReport(data, format, period) {
|
|
3220
3638
|
if (format === "json")
|
|
@@ -3227,7 +3645,8 @@ function formatReport(data, format, period) {
|
|
|
3227
3645
|
lines.push(source_default.bold(" Oura Monthly Report"));
|
|
3228
3646
|
}
|
|
3229
3647
|
lines.push(source_default.gray(` ${data.weekStart} \u2014 ${data.weekEnd}`));
|
|
3230
|
-
const
|
|
3648
|
+
const buckets = period === "month" ? bucketDaysIntoWeeks(data.days) : [];
|
|
3649
|
+
const note = partialDayNote(data, buckets.find((b) => b.partial));
|
|
3231
3650
|
if (note)
|
|
3232
3651
|
lines.push(source_default.yellow(note));
|
|
3233
3652
|
lines.push("");
|
|
@@ -3241,26 +3660,21 @@ function formatReport(data, format, period) {
|
|
|
3241
3660
|
}
|
|
3242
3661
|
if (period === "week") {
|
|
3243
3662
|
lines.push(source_default.bold(" Last 7 Days:"));
|
|
3244
|
-
|
|
3245
|
-
lines.push(` ${"Day".padEnd(10)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(8)}`);
|
|
3246
|
-
lines.push(source_default.gray(" " + "\u2500".repeat(52)));
|
|
3663
|
+
const table = [RULE, ` ${"Day".padEnd(10)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(8)}`, RULE];
|
|
3247
3664
|
for (const d of data.days) {
|
|
3248
|
-
|
|
3665
|
+
table.push(` ${(d.partial ? d.dayLabel + "*" : d.dayLabel).padEnd(10)} ${scoreCell(d.sleep, 6)} ${scoreCell(d.readiness, 6)} ${scoreCell(d.activity, 7)} ${stepsCell(d.steps, 8)}`);
|
|
3249
3666
|
}
|
|
3250
|
-
lines.push("");
|
|
3667
|
+
lines.push(...finish(table), "");
|
|
3251
3668
|
} else {
|
|
3252
|
-
const buckets = bucketDaysIntoWeeks(data.days);
|
|
3253
3669
|
lines.push(source_default.bold(" Last 30 Days:"));
|
|
3254
|
-
|
|
3255
|
-
lines.push(` ${"Week of".padEnd(12)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(10)}`);
|
|
3256
|
-
lines.push(source_default.gray(" " + "\u2500".repeat(60)));
|
|
3670
|
+
const table = [RULE, ` ${"Week of".padEnd(21)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(10)}`, RULE];
|
|
3257
3671
|
for (const b of buckets) {
|
|
3258
3672
|
const avgSleepInt = b.avgSleep !== null ? Math.round(b.avgSleep) : null;
|
|
3259
3673
|
const avgReadyInt = b.avgReadiness !== null ? Math.round(b.avgReadiness) : null;
|
|
3260
3674
|
const avgActiveInt = b.avgActivity !== null ? Math.round(b.avgActivity) : null;
|
|
3261
|
-
|
|
3675
|
+
table.push(` ${bucketLabel(b).padEnd(21)} ${scoreCell(avgSleepInt, 6)} ${scoreCell(avgReadyInt, 6)} ${scoreCell(avgActiveInt, 7)} ${stepsCell(b.totalSteps, 10)}`);
|
|
3262
3676
|
}
|
|
3263
|
-
lines.push("");
|
|
3677
|
+
lines.push(...finish(table), "");
|
|
3264
3678
|
}
|
|
3265
3679
|
lines.push(source_default.bold(" Averages (this period vs previous):"));
|
|
3266
3680
|
for (const a of data.averages) {
|
|
@@ -3360,14 +3774,14 @@ function statusSymbol(status) {
|
|
|
3360
3774
|
return source_default.red("\u2717");
|
|
3361
3775
|
}
|
|
3362
3776
|
function formatDoctorTable(result) {
|
|
3363
|
-
const lines = ["", source_default.bold(" Doctor"),
|
|
3777
|
+
const lines = ["", source_default.bold(" Doctor"), RULE];
|
|
3364
3778
|
for (const c of result.checks) {
|
|
3365
3779
|
lines.push(` ${statusSymbol(c.status)} ${c.id.padEnd(12)} ${c.detail}`);
|
|
3366
3780
|
}
|
|
3367
3781
|
lines.push("");
|
|
3368
3782
|
const next = result.nextStep ?? (result.ok ? "nothing \u2014 everything looks healthy." : "see the failing checks above.");
|
|
3369
3783
|
lines.push(` Next: ${next}`);
|
|
3370
|
-
return lines.join(`
|
|
3784
|
+
return finish(lines).join(`
|
|
3371
3785
|
`);
|
|
3372
3786
|
}
|
|
3373
3787
|
|
|
@@ -3394,7 +3808,7 @@ async function runChecks(deps) {
|
|
|
3394
3808
|
checks.push({ id: "token-valid", status: "fail", detail: err.message, fix: "oura-cli login" });
|
|
3395
3809
|
} else {
|
|
3396
3810
|
const msg = err instanceof Error ? err.message : String(err);
|
|
3397
|
-
checks.push({ id: "token-valid", status: "warn", detail: `Could not reach the Oura API: ${msg}` });
|
|
3811
|
+
checks.push({ id: "token-valid", status: "warn", detail: `Could not reach the Oura API: ${msg}`, fix: "Check the network connection and run `oura-cli doctor` again in a few minutes." });
|
|
3398
3812
|
}
|
|
3399
3813
|
}
|
|
3400
3814
|
}
|
|
@@ -3422,9 +3836,14 @@ async function runChecks(deps) {
|
|
|
3422
3836
|
} else if (!last) {
|
|
3423
3837
|
checks.push({ id: "data", status: "warn", detail: "No data in the local cache yet.", fix: "oura-cli sync" });
|
|
3424
3838
|
} else {
|
|
3425
|
-
const
|
|
3426
|
-
if (
|
|
3427
|
-
checks.push({
|
|
3839
|
+
const hours = hoursSinceDayEnded(last, deps.now, deps.tz);
|
|
3840
|
+
if (hours > STALE_AFTER_HOURS) {
|
|
3841
|
+
checks.push({
|
|
3842
|
+
id: "data",
|
|
3843
|
+
status: "warn",
|
|
3844
|
+
detail: `Most recent data is from ${last}; that day ended over ${Math.floor(hours)} hours ago (the limit is ${STALE_AFTER_HOURS}).`,
|
|
3845
|
+
fix: "oura-cli sync"
|
|
3846
|
+
});
|
|
3428
3847
|
} else {
|
|
3429
3848
|
checks.push({ id: "data", status: "ok", detail: `Data current through ${last}.` });
|
|
3430
3849
|
}
|
|
@@ -3449,6 +3868,14 @@ function quickCheck(db) {
|
|
|
3449
3868
|
}
|
|
3450
3869
|
}
|
|
3451
3870
|
var DATA_TABLES = ["daily_sleep", "daily_readiness", "daily_activity"];
|
|
3871
|
+
var STALE_AFTER_HOURS = 36;
|
|
3872
|
+
function hoursSinceDayEnded(day, now, tz) {
|
|
3873
|
+
const ended = Date.parse(localDateToUtcRange(day, tz)[1]);
|
|
3874
|
+
const hours = (Date.parse(now) - ended) / 3600000;
|
|
3875
|
+
if (!Number.isFinite(hours))
|
|
3876
|
+
throw new Error(`hoursSinceDayEnded: cannot place ${JSON.stringify(now)} against day ${day}.`);
|
|
3877
|
+
return hours;
|
|
3878
|
+
}
|
|
3452
3879
|
function latestDataDay(db) {
|
|
3453
3880
|
let latest = null;
|
|
3454
3881
|
for (const tbl of DATA_TABLES) {
|
|
@@ -3477,7 +3904,9 @@ async function runDoctor(ctx, args) {
|
|
|
3477
3904
|
},
|
|
3478
3905
|
createClient: (token) => new OuraClient({ token }),
|
|
3479
3906
|
offline: args.offline === true,
|
|
3480
|
-
today: ctx.today
|
|
3907
|
+
today: ctx.today,
|
|
3908
|
+
now: nowUtc(),
|
|
3909
|
+
tz: ctx.tz
|
|
3481
3910
|
};
|
|
3482
3911
|
const result = await runChecks(deps);
|
|
3483
3912
|
return {
|
|
@@ -3531,62 +3960,21 @@ function manifestCommand(version, getCommands) {
|
|
|
3531
3960
|
});
|
|
3532
3961
|
}
|
|
3533
3962
|
|
|
3534
|
-
// src/commands/
|
|
3535
|
-
function
|
|
3536
|
-
const
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
if (start > end)
|
|
3549
|
-
throw new CliError("BAD_ARGS", `--from (${start}) must not be after --to (${end}).`);
|
|
3550
|
-
return { start, end };
|
|
3551
|
-
}
|
|
3552
|
-
if (opts.days !== undefined) {
|
|
3553
|
-
const n = assertPositiveInt(opts.days, "--days");
|
|
3554
|
-
return { start: shiftDay(opts.today, -(n - 1)), end: opts.today };
|
|
3555
|
-
}
|
|
3556
|
-
return { start: opts.today, end: opts.today };
|
|
3557
|
-
}
|
|
3558
|
-
function assertRangeAllowed(c, opts) {
|
|
3559
|
-
if (c.rangeParams === "none" && [opts.day, opts.from, opts.to, opts.days].some((v) => v !== undefined)) {
|
|
3560
|
-
throw new CliError("BAD_ARGS", `"${c.name}" is a snapshot, not a day range; it takes no --day, --from/--to or --days.`);
|
|
3561
|
-
}
|
|
3963
|
+
// src/commands/registry.ts
|
|
3964
|
+
function buildRegistry(version) {
|
|
3965
|
+
const subCommands = Object.assign(Object.create(null), {
|
|
3966
|
+
login: loginCommand,
|
|
3967
|
+
describe: describeCommand(version, () => subCommands),
|
|
3968
|
+
healthcheck: healthcheckCommand(version),
|
|
3969
|
+
doctor: doctorCommand,
|
|
3970
|
+
manifest: manifestCommand(version, () => subCommands),
|
|
3971
|
+
fetch: fetchCommand,
|
|
3972
|
+
sync: syncCommand,
|
|
3973
|
+
db: dbCommand,
|
|
3974
|
+
report: reportCommand
|
|
3975
|
+
});
|
|
3976
|
+
return subCommands;
|
|
3562
3977
|
}
|
|
3563
|
-
var fetchCommand = dataCommand({
|
|
3564
|
-
meta: { name: "fetch", description: "Fetch raw records for one Oura collection straight from the API (JSON)." },
|
|
3565
|
-
args: {
|
|
3566
|
-
collection: { type: "positional", required: true, description: `Collection: ${names().join(" | ")} (ring is a snapshot and takes no range flags)` },
|
|
3567
|
-
day: { type: "string", description: "Single day (YYYY-MM-DD). Default: today." },
|
|
3568
|
-
from: { type: "string", description: "Range start (YYYY-MM-DD); requires --to" },
|
|
3569
|
-
to: { type: "string", description: "Range end (YYYY-MM-DD); requires --from" },
|
|
3570
|
-
days: { type: "string", description: "Last N days ending today" }
|
|
3571
|
-
},
|
|
3572
|
-
jsonOnly: true,
|
|
3573
|
-
async run(ctx, args) {
|
|
3574
|
-
const c = byName(args.collection);
|
|
3575
|
-
if (!c)
|
|
3576
|
-
throw new CliError("BAD_ARGS", `Unknown collection "${args.collection}".`, `Valid collections: ${names().join(", ")}`);
|
|
3577
|
-
const opts = {
|
|
3578
|
-
day: args.day,
|
|
3579
|
-
from: args.from,
|
|
3580
|
-
to: args.to,
|
|
3581
|
-
days: args.days
|
|
3582
|
-
};
|
|
3583
|
-
assertRangeAllowed(c, opts);
|
|
3584
|
-
const { start, end } = resolveRange({ ...opts, today: ctx.today });
|
|
3585
|
-
const client = new OuraClient(args.token ? { token: args.token } : {});
|
|
3586
|
-
const data = await fetchCollection(client, c, start, end, ctx.tz);
|
|
3587
|
-
return { json: data, text: () => JSON.stringify(data, null, 2) };
|
|
3588
|
-
}
|
|
3589
|
-
});
|
|
3590
3978
|
|
|
3591
3979
|
// src/lib/citty-error.ts
|
|
3592
3980
|
var ANSI2 = /\u001b\[[0-9;]*m/g;
|
|
@@ -3619,11 +4007,28 @@ function nearestGlobalFlag(token) {
|
|
|
3619
4007
|
return;
|
|
3620
4008
|
return [...GLOBAL_FLAGS_WITH_VALUE].find((flag) => flag !== token && (editDistanceAtMostOne(flag, token) || flag.startsWith(token)));
|
|
3621
4009
|
}
|
|
3622
|
-
function
|
|
4010
|
+
function commandTokens(rawArgs) {
|
|
4011
|
+
const tokens = [];
|
|
4012
|
+
for (let i = 0;i < rawArgs.length; i++) {
|
|
4013
|
+
const tok = rawArgs[i];
|
|
4014
|
+
if (GLOBAL_FLAGS_WITH_VALUE.has(tok)) {
|
|
4015
|
+
i++;
|
|
4016
|
+
continue;
|
|
4017
|
+
}
|
|
4018
|
+
if (tok.startsWith("-"))
|
|
4019
|
+
continue;
|
|
4020
|
+
tokens.push(tok);
|
|
4021
|
+
}
|
|
4022
|
+
return tokens;
|
|
4023
|
+
}
|
|
4024
|
+
function fromCittyError(err, removedCommandHints = {}, rawArgs = [], parents = {}) {
|
|
3623
4025
|
const code = err?.code;
|
|
3624
4026
|
if (typeof code !== "string")
|
|
3625
4027
|
return err;
|
|
3626
4028
|
const message = (err instanceof Error ? err.message : String(err)).replace(ANSI2, "");
|
|
4029
|
+
const first = commandTokens(rawArgs)[0];
|
|
4030
|
+
const parent = first !== undefined && Object.hasOwn(parents, first) ? first : undefined;
|
|
4031
|
+
const parentHelp = parent === undefined ? undefined : `\`oura-cli ${parent}\` takes one of: ${parents[parent].join(", ")}. Run \`oura-cli ${parent} --help\` for details.`;
|
|
3627
4032
|
switch (code) {
|
|
3628
4033
|
case "E_UNKNOWN_COMMAND": {
|
|
3629
4034
|
const name = message.replace(/^Unknown command\s*/, "").trim();
|
|
@@ -3632,16 +4037,19 @@ function fromCittyError(err, removedCommandHints = {}, rawArgs = []) {
|
|
|
3632
4037
|
if (meant) {
|
|
3633
4038
|
return new CliError("BAD_ARGS", `Unknown flag "${before}".`, `Did you mean ${meant}? Its value was read as a command name.`);
|
|
3634
4039
|
}
|
|
4040
|
+
if (parentHelp !== undefined && before !== undefined && GLOBAL_FLAGS_WITH_VALUE.has(before)) {
|
|
4041
|
+
return new CliError("BAD_ARGS", `"${parent}" needs a subcommand.`, parentHelp);
|
|
4042
|
+
}
|
|
3635
4043
|
if (!COMMAND_NAME.test(name)) {
|
|
3636
4044
|
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.");
|
|
3637
4045
|
}
|
|
3638
|
-
const hint = Object.hasOwn(removedCommandHints, name) ? removedCommandHints[name] : "Run `oura-cli --help` for the list of commands.";
|
|
4046
|
+
const hint = Object.hasOwn(removedCommandHints, name) ? removedCommandHints[name] : parentHelp ?? "Run `oura-cli --help` for the list of commands.";
|
|
3639
4047
|
return new CliError("BAD_ARGS", `Unknown command "${name}".`, hint);
|
|
3640
4048
|
}
|
|
3641
4049
|
case "EARG":
|
|
3642
4050
|
return new CliError("BAD_ARGS", message.endsWith(".") ? message : `${message}.`, "Run the command with --help to see its arguments.");
|
|
3643
4051
|
case "E_NO_COMMAND":
|
|
3644
|
-
return new CliError("BAD_ARGS", "No command specified.", "Run `oura-cli --help` for the list of commands.");
|
|
4052
|
+
return parentHelp === undefined ? new CliError("BAD_ARGS", "No command specified.", "Run `oura-cli --help` for the list of commands.") : new CliError("BAD_ARGS", `"${parent}" needs a subcommand.`, parentHelp);
|
|
3645
4053
|
default:
|
|
3646
4054
|
return err;
|
|
3647
4055
|
}
|
|
@@ -3649,17 +4057,11 @@ function fromCittyError(err, removedCommandHints = {}, rawArgs = []) {
|
|
|
3649
4057
|
|
|
3650
4058
|
// src/index.ts
|
|
3651
4059
|
var VERSION = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf-8")).version;
|
|
3652
|
-
var subCommands =
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
manifest: manifestCommand(VERSION, () => subCommands),
|
|
3658
|
-
fetch: fetchCommand,
|
|
3659
|
-
sync: syncCommand,
|
|
3660
|
-
db: dbCommand,
|
|
3661
|
-
report: reportCommand
|
|
3662
|
-
});
|
|
4060
|
+
var subCommands = buildRegistry(VERSION);
|
|
4061
|
+
var PARENT_COMMANDS = Object.fromEntries(Object.entries(subCommands).flatMap(([name, def]) => {
|
|
4062
|
+
const subs = def.subCommands;
|
|
4063
|
+
return subs !== null && typeof subs === "object" ? [[name, Object.keys(subs)]] : [];
|
|
4064
|
+
}));
|
|
3663
4065
|
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
4066
|
var REMOVED_COMMANDS = {
|
|
3665
4067
|
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.",
|
|
@@ -3682,14 +4084,16 @@ var main = defineCommand({
|
|
|
3682
4084
|
subCommands
|
|
3683
4085
|
});
|
|
3684
4086
|
var rawArgs = normalizeArgv(process.argv).slice(2);
|
|
3685
|
-
var
|
|
4087
|
+
var commands = commandTokens(rawArgs);
|
|
4088
|
+
var bareParent = commands.length === 1 && Object.hasOwn(PARENT_COMMANDS, commands[0]);
|
|
4089
|
+
var wantsHelp = rawArgs.some((a) => a === "--help" || a === "-h") || (commands.length === 0 || bareParent) && process.stdout.isTTY === true;
|
|
3686
4090
|
if (isVersionRequest(rawArgs)) {
|
|
3687
4091
|
console.log(VERSION);
|
|
3688
4092
|
} else if (wantsHelp) {
|
|
3689
|
-
runMain(main, { rawArgs });
|
|
4093
|
+
runMain(main, { rawArgs: bareParent ? [...rawArgs, "--help"] : rawArgs });
|
|
3690
4094
|
} else {
|
|
3691
4095
|
runCommand(main, { rawArgs }).catch((raw) => {
|
|
3692
|
-
const err = fromCittyError(raw, REMOVED_COMMANDS, rawArgs);
|
|
4096
|
+
const err = fromCittyError(raw, REMOVED_COMMANDS, rawArgs, PARENT_COMMANDS);
|
|
3693
4097
|
emitError(err, formatFromArgv(rawArgs, process.stdout.isTTY === true));
|
|
3694
4098
|
process.exit(exitCodeFor(err));
|
|
3695
4099
|
});
|