@drakulavich/oura-cli 0.5.2 → 0.6.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 +20 -0
- package/README.md +16 -3
- package/dist/index.js +457 -93
- package/docs/schemas/battery.json +21 -0
- package/docs/schemas/resilience.json +26 -0
- package/docs/schemas/rest-mode.json +26 -0
- package/docs/schemas/ring.json +20 -0
- package/docs/schemas/session.json +26 -0
- package/docs/schemas/sleep-time.json +26 -0
- package/docs/schemas/tags.json +26 -0
- package/docs/schemas/vo2max.json +26 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.6.0] - 2026-09-06
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- A global flag between a command and its subcommand made the CLI reject the flag's *value* as a command: `db --format json today` failed with `Unknown command "json"`, and `db --db <path> today` echoed the whole path back. Global flags are now lifted to the end of the command line wherever they appear, so the three spellings of `--format json db today` all work. Tokens after `--` are left alone. (#79)
|
|
13
|
+
- `--no-color` left citty's help output coloured, because citty reads `NO_COLOR` when its module is evaluated and the variable was only set afterwards. The rule moved into `src/lib/color-mode.ts` and is applied by `src/lib/apply-color-mode.ts`, which the entry point imports first. Piped help is plain too unless `FORCE_COLOR` asks otherwise, `FORCE_COLOR=0` included, which chalk reads as off. (#80)
|
|
14
|
+
- `describe`, `manifest`, `healthcheck` and `login` accepted an unknown `--format` (`xml`) and exited 0, while every other command exited 1 with `BAD_ARGS`. The value is validated in `assertKnownArgs`, which every command calls, instead of only in the resolver the JSON-only commands never reach. (#81)
|
|
15
|
+
- `db week` lost its column alignment whenever colour was on: `padStart` counted the ANSI escapes chalk had already wrapped each score in, so it padded by nothing. Piped output was correct, which is why the tests never saw it. Padding now measures visible width (`src/lib/pad.ts`), and `report` uses the same helper. (#82)
|
|
16
|
+
- An empty `--db` (or `OURA_DB_PATH`) was falsy, so it fell through to the default and every command silently opened — and migrated — the home cache at `~/.oura-cli/oura.db`. A wrapper expanding an unset variable into `--db "$VAR"` therefore wrote to the user's real database. The same went for `--db=` and a trailing `--db` with no value. All of them now fail with `BAD_ARGS`, like the blank-but-not-empty value already did, and an `OURA_DB_PATH` set to an empty string is an error rather than a request for the default. `doctor` reports it as an argument error too, instead of a `database: fail` health finding with a database exit code. (#76)
|
|
17
|
+
- `sync` fetched nothing, and said nothing, for any collection whose last stored day was after the end of the window. A timezone west of the one the cache was built in (`--tz`, `OURA_TZ`, a laptop that travelled) did this to every daily collection for up to a day; a row dated in the future — a tag on next month, which Oura allows — did it to that collection on every run from then on. A collection now resumes from its newest stored day *at or before* the end of the window, so a future-dated row is ignored rather than freezing the collection, and the days behind it are still fetched. An explicit `--from` after the end of the window is rejected with `BAD_ARGS`. (#69)
|
|
18
|
+
- `sync` never fetched the heart-rate samples Oura adds to days already behind the watermark: on a live account 8,004 `source='workout'` samples over nine days were missing from an incrementally synced cache and present in a fresh one, with nothing in the output to say so. A collection can now declare `syncLookbackDays`, and `hr` re-walks the last 14 days on every sync. The window `sync` reports still describes what the cache needed, so a lookback does not make every run announce a fortnight. Samples Oura later reclassifies (`awake` → `workout`) are still kept, since `heartrate` never deletes. (#68)
|
|
19
|
+
- `report` decided whether a day's activity was complete from the newest heart-rate sample, but Oura publishes heart-rate days after the daily summaries: right after a ring sync the report called three fully synced days "still accumulating". A day now counts as complete once a later day has its own activity record; heart-rate plays no part. `lastUpload` stays in the JSON as information and the note no longer quotes it. (#57)
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- Eight collections from the Oura OpenAPI spec (1.37), each available through `fetch <name>`, stored by `sync`, listed in `describe`/`manifest`, counted by `db stats`, with a JSON Schema under `docs/schemas/`: `resilience` (daily_resilience), `vo2max` (vO2_max — its table has existed since 0.1 and is finally populated), `sleep-time` (sleep_time), `session` (session), `rest-mode` (rest_mode_period), `tags` (enhanced_tag; the legacy `tag` endpoint is not offered), `ring` (ring_configuration) and `battery` (ring_battery_level, a timestamp-keyed timeseries fetched in pieces of at most 30 days like `hr`). Schema migration 3 creates the seven new tables. (#44)
|
|
23
|
+
- Collection registry: `rangeParams: 'none'` for snapshot endpoints that take no range. `fetch` rejects `--day`/`--from`/`--to`/`--days` for them with `BAD_ARGS` instead of ignoring the flags, and `sync` replaces the table with each response (a ring removed from the account disappears locally too) instead of keeping a watermark — also under `--from/--to`, since a snapshot has no history to backfill.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- The `sync` text summary lists every registry collection by its `fetch` name (`sleep 28 (+2) readiness 0 (+0)`), in a fixed-width grid whose column count follows the terminal width (up to four, and a single unpadded cell per line once two no longer fit — around 46 columns, sooner when a count is wide), instead of a hand-written list of nine tables. Cells no longer break across lines on a narrow terminal, and the counts line up. (#83)
|
|
27
|
+
- `sync` prints a progress line for every collection, including those that returned nothing, and names both the collection and its table (` + tags (enhanced_tags): 0 fetched, 0 new`) so the lines and the summary can be read together. A silent collection used to be indistinguishable from a failed one while the summary listed it anyway. (#83)
|
|
28
|
+
|
|
9
29
|
## [0.5.2] - 2026-09-06
|
|
10
30
|
|
|
11
31
|
### Fixed
|
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ oura-cli sync # first sync fetches the last 30 days; later syncs resume from
|
|
|
54
54
|
oura-cli report # weekly digest in the terminal
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
Subsequent `oura-cli sync` re-fetches each collection from its own last stored day (Oura revises recent days, so the overlap is deliberate) and reports rows fetched (+new). `oura-cli sync --from 2026-08-01 [--to 2026-08-07]` re-fetches an explicit window for every collection instead — for example after an interrupted sync. `oura-cli db today` / `oura-cli db week` read the local cache instantly, no API call.
|
|
57
|
+
Subsequent `oura-cli sync` re-fetches each collection from its own last stored day (Oura revises recent days, so the overlap is deliberate) and reports rows fetched (+new). Heart rate goes back two weeks each time, because Oura publishes workout samples days after the day they belong to. `oura-cli sync --from 2026-08-01 [--to 2026-08-07]` re-fetches an explicit window for every collection instead — for example after an interrupted sync. `oura-cli db today` / `oura-cli db week` read the local cache instantly, no API call.
|
|
58
58
|
|
|
59
59
|
### If something looks wrong
|
|
60
60
|
|
|
@@ -116,7 +116,9 @@ oura-cli fetch workout --from 2026-05-01 --to 2026-05-31
|
|
|
116
116
|
oura-cli fetch sleep-periods --day 2026-06-01 | jq '.[] | {day, type, average_hrv}'
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
-
Collections: `sleep readiness activity hr spo2 stress workout sleep-periods cv-age`.
|
|
119
|
+
Collections: `sleep readiness activity hr spo2 stress workout sleep-periods cv-age resilience vo2max sleep-time session rest-mode tags ring battery`.
|
|
120
|
+
|
|
121
|
+
`ring` is a snapshot of your ring's hardware, not a day range, so it takes no `--day`/`--from`/`--days`. `battery` is a timeseries like `hr`: keyed by timestamp, fetched in pieces of at most 30 days.
|
|
120
122
|
|
|
121
123
|
### Piping to other tools
|
|
122
124
|
|
|
@@ -136,6 +138,9 @@ oura-cli db trends 90 > trends.json
|
|
|
136
138
|
| Database path | `--db` | `OURA_DB_PATH` | `~/.oura-cli/oura.db` |
|
|
137
139
|
| Timezone | `--tz` | `OURA_TZ` | system timezone, else `UTC` |
|
|
138
140
|
| Output format | `--format` | | auto-detect (TTY → table) |
|
|
141
|
+
| Colour | `--no-color`| `NO_COLOR` | on for a terminal, off when piped |
|
|
142
|
+
|
|
143
|
+
These are global: they may appear anywhere on the command line, before the command, between a command and its subcommand, or at the end. `oura-cli --format json db today`, `oura-cli db --format json today` and `oura-cli db today --format json` are the same command.
|
|
139
144
|
|
|
140
145
|
## Security
|
|
141
146
|
|
|
@@ -162,6 +167,14 @@ This tool reads your personal health data — handle the token with care.
|
|
|
162
167
|
| Workouts | Oura V2 `workout` | `workouts` |
|
|
163
168
|
| Sleep model | Oura V2 `sleep` | `sleep_model` |
|
|
164
169
|
| Cardiovascular age | Oura V2 `cardiovascular_age` | `cardiovascular_age` |
|
|
170
|
+
| Resilience | Oura V2 `daily_resilience` | `daily_resilience` |
|
|
171
|
+
| VO₂ max | Oura V2 `vO2_max` | `vo2max` |
|
|
172
|
+
| Sleep time | Oura V2 `sleep_time` | `sleep_time` |
|
|
173
|
+
| Sessions | Oura V2 `session` | `sessions` |
|
|
174
|
+
| Rest mode | Oura V2 `rest_mode_period` | `rest_mode_periods` |
|
|
175
|
+
| Tags | Oura V2 `enhanced_tag` | `enhanced_tags` |
|
|
176
|
+
| Ring | Oura V2 `ring_configuration` | `ring_configuration` |
|
|
177
|
+
| Battery | Oura V2 `ring_battery_level` | `ring_battery_level` |
|
|
165
178
|
|
|
166
179
|
Runtime: [Bun](https://bun.sh). Storage: built-in `bun:sqlite`. CLI parsing: [citty](https://github.com/unjs/citty). Output styling: [chalk](https://github.com/chalk/chalk). One ~110 kB `dist/index.js`, no native deps.
|
|
167
180
|
|
|
@@ -175,7 +188,7 @@ If you're driving the CLI from a script or LLM harness:
|
|
|
175
188
|
- Errors emit a stable JSON envelope on stderr: `{"error":{"code":"…","message":"…","hint":"…"}}`.
|
|
176
189
|
- Documented exit codes: `0` success, `1` user error, `2` auth, `3` API, `4` storage.
|
|
177
190
|
- JSON Schemas under [`docs/schemas/`](docs/schemas/) cover `fetch <collection>`, `doctor` and the `describe` manifest itself, semver-stable; `describe` names the schema next to each command. Per-collection schemas pin the identity fields (`id`, `day`, `timestamp`) and allow the rest of the Oura record through unchanged, so new upstream fields never break validation. The local-data commands (`sync`, `db *`, `report`) have no schema files yet; their shapes are versioned through the CHANGELOG.
|
|
178
|
-
- Two contract quirks, kept for compatibility: `report --period month` returns its window as `weekStart`/`weekEnd`, and `heartrate.day` in the cache is the date written in Oura's timestamp (UTC in practice) while every `--day`/`--tz` argument is local.
|
|
191
|
+
- Two contract quirks, kept for compatibility: `report --period month` returns its window as `weekStart`/`weekEnd`, and `heartrate.day` (likewise `ring_battery_level.day`) in the cache is the date written in Oura's timestamp (UTC in practice) while every `--day`/`--tz` argument is local.
|
|
179
192
|
|
|
180
193
|
Plays cleanly with [OpenClaw](https://github.com/openclaw/openclaw) — `oura-cli manifest` returns the tool-registry shape. A first-party `oura-mcp` companion is on the roadmap.
|
|
181
194
|
|
package/dist/index.js
CHANGED
|
@@ -557,8 +557,26 @@ var init_source = __esm(() => {
|
|
|
557
557
|
source_default = chalk;
|
|
558
558
|
});
|
|
559
559
|
|
|
560
|
-
// src/
|
|
560
|
+
// src/lib/apply-color-mode.ts
|
|
561
561
|
init_source();
|
|
562
|
+
|
|
563
|
+
// src/lib/color-mode.ts
|
|
564
|
+
function forcesColor(value) {
|
|
565
|
+
return value !== undefined && value !== "" && value !== "0" && value !== "false";
|
|
566
|
+
}
|
|
567
|
+
function shouldDisableColor(argv, env, isTty) {
|
|
568
|
+
if (argv.includes("--no-color") || env.NO_COLOR)
|
|
569
|
+
return true;
|
|
570
|
+
return !isTty && !forcesColor(env.FORCE_COLOR);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// src/lib/apply-color-mode.ts
|
|
574
|
+
if (shouldDisableColor(process.argv, process.env, process.stdout.isTTY === true)) {
|
|
575
|
+
process.env.NO_COLOR = "1";
|
|
576
|
+
source_default.level = 0;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// src/index.ts
|
|
562
580
|
import { readFileSync as readFileSync2 } from "fs";
|
|
563
581
|
|
|
564
582
|
// node_modules/citty/dist/_chunks/libs/scule.mjs
|
|
@@ -1309,6 +1327,78 @@ CREATE VIEW IF NOT EXISTS v_sleep_detail AS
|
|
|
1309
1327
|
average_hrv, average_heart_rate as avg_hr, lowest_heart_rate as lowest_hr, efficiency
|
|
1310
1328
|
FROM sleep_model ORDER BY day DESC;
|
|
1311
1329
|
`
|
|
1330
|
+
},
|
|
1331
|
+
{
|
|
1332
|
+
version: 3,
|
|
1333
|
+
sql: `
|
|
1334
|
+
CREATE TABLE IF NOT EXISTS daily_resilience (
|
|
1335
|
+
id TEXT PRIMARY KEY,
|
|
1336
|
+
day TEXT UNIQUE,
|
|
1337
|
+
level TEXT,
|
|
1338
|
+
sleep_recovery REAL,
|
|
1339
|
+
daytime_recovery REAL,
|
|
1340
|
+
stress REAL
|
|
1341
|
+
);
|
|
1342
|
+
CREATE TABLE IF NOT EXISTS sleep_time (
|
|
1343
|
+
id TEXT PRIMARY KEY,
|
|
1344
|
+
day TEXT UNIQUE,
|
|
1345
|
+
status TEXT,
|
|
1346
|
+
recommendation TEXT,
|
|
1347
|
+
bedtime_start_offset INTEGER,
|
|
1348
|
+
bedtime_end_offset INTEGER,
|
|
1349
|
+
day_tz INTEGER
|
|
1350
|
+
);
|
|
1351
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
1352
|
+
id TEXT PRIMARY KEY,
|
|
1353
|
+
day TEXT,
|
|
1354
|
+
type TEXT,
|
|
1355
|
+
mood TEXT,
|
|
1356
|
+
start_datetime TEXT,
|
|
1357
|
+
end_datetime TEXT,
|
|
1358
|
+
heart_rate TEXT,
|
|
1359
|
+
heart_rate_variability TEXT,
|
|
1360
|
+
motion_count TEXT
|
|
1361
|
+
);
|
|
1362
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_day ON sessions(day);
|
|
1363
|
+
CREATE TABLE IF NOT EXISTS rest_mode_periods (
|
|
1364
|
+
id TEXT PRIMARY KEY,
|
|
1365
|
+
day TEXT,
|
|
1366
|
+
end_day TEXT,
|
|
1367
|
+
start_time TEXT,
|
|
1368
|
+
end_time TEXT,
|
|
1369
|
+
episodes TEXT
|
|
1370
|
+
);
|
|
1371
|
+
CREATE INDEX IF NOT EXISTS idx_rest_mode_periods_day ON rest_mode_periods(day);
|
|
1372
|
+
CREATE TABLE IF NOT EXISTS enhanced_tags (
|
|
1373
|
+
id TEXT PRIMARY KEY,
|
|
1374
|
+
day TEXT,
|
|
1375
|
+
end_day TEXT,
|
|
1376
|
+
start_time TEXT,
|
|
1377
|
+
end_time TEXT,
|
|
1378
|
+
tag_type_code TEXT,
|
|
1379
|
+
comment TEXT,
|
|
1380
|
+
custom_name TEXT
|
|
1381
|
+
);
|
|
1382
|
+
CREATE INDEX IF NOT EXISTS idx_enhanced_tags_day ON enhanced_tags(day);
|
|
1383
|
+
CREATE TABLE IF NOT EXISTS ring_configuration (
|
|
1384
|
+
id TEXT PRIMARY KEY,
|
|
1385
|
+
color TEXT,
|
|
1386
|
+
design TEXT,
|
|
1387
|
+
firmware_version TEXT,
|
|
1388
|
+
hardware_type TEXT,
|
|
1389
|
+
set_up_at TEXT,
|
|
1390
|
+
size INTEGER
|
|
1391
|
+
);
|
|
1392
|
+
CREATE TABLE IF NOT EXISTS ring_battery_level (
|
|
1393
|
+
timestamp TEXT,
|
|
1394
|
+
level INTEGER,
|
|
1395
|
+
charging INTEGER,
|
|
1396
|
+
in_charger INTEGER,
|
|
1397
|
+
day TEXT
|
|
1398
|
+
);
|
|
1399
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ring_battery_level_unique ON ring_battery_level(timestamp);
|
|
1400
|
+
CREATE INDEX IF NOT EXISTS idx_ring_battery_level_day ON ring_battery_level(day);
|
|
1401
|
+
`
|
|
1312
1402
|
}
|
|
1313
1403
|
];
|
|
1314
1404
|
|
|
@@ -1324,11 +1414,18 @@ function dbError(what, err) {
|
|
|
1324
1414
|
function asDbError(err) {
|
|
1325
1415
|
return err instanceof SQLiteError ? dbError("Database query failed", err) : undefined;
|
|
1326
1416
|
}
|
|
1417
|
+
function requirePath(value, source) {
|
|
1418
|
+
if (value.trim() === "") {
|
|
1419
|
+
throw new CliError("BAD_ARGS", `${source} has no path`, `Pass a path to a SQLite file, or remove ${source} to fall back to the default database.`);
|
|
1420
|
+
}
|
|
1421
|
+
return value;
|
|
1422
|
+
}
|
|
1327
1423
|
function getDbPath(explicit) {
|
|
1328
|
-
if (explicit)
|
|
1329
|
-
return explicit;
|
|
1330
|
-
|
|
1331
|
-
|
|
1424
|
+
if (explicit !== undefined)
|
|
1425
|
+
return requirePath(explicit, "--db");
|
|
1426
|
+
const fromEnv = process.env.OURA_DB_PATH;
|
|
1427
|
+
if (fromEnv !== undefined)
|
|
1428
|
+
return requirePath(fromEnv, "OURA_DB_PATH");
|
|
1332
1429
|
return resolve(homedir(), ".oura-cli", "oura.db");
|
|
1333
1430
|
}
|
|
1334
1431
|
function openDatabase(explicit) {
|
|
@@ -1469,15 +1566,13 @@ var SUBCOMMANDS = new Set([
|
|
|
1469
1566
|
]);
|
|
1470
1567
|
function normalizeArgv(argv) {
|
|
1471
1568
|
const [bun, script, ...rest] = argv;
|
|
1472
|
-
const
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
const
|
|
1476
|
-
const subcommandOnwards = rest.slice(subIdx);
|
|
1569
|
+
const dd = rest.indexOf("--");
|
|
1570
|
+
const scanned = dd >= 0 ? rest.slice(0, dd) : rest;
|
|
1571
|
+
const passthrough = dd >= 0 ? rest.slice(dd) : [];
|
|
1572
|
+
const kept = [];
|
|
1477
1573
|
const hoisted = [];
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
const tok = before[i];
|
|
1574
|
+
for (let i = 0;i < scanned.length; i++) {
|
|
1575
|
+
const tok = scanned[i];
|
|
1481
1576
|
if (tok.includes("=")) {
|
|
1482
1577
|
const name = tok.slice(0, tok.indexOf("="));
|
|
1483
1578
|
if (GLOBAL_FLAGS_WITH_VALUE.has(name) || GLOBAL_FLAGS_BOOLEAN.has(name)) {
|
|
@@ -1487,8 +1582,8 @@ function normalizeArgv(argv) {
|
|
|
1487
1582
|
}
|
|
1488
1583
|
if (GLOBAL_FLAGS_WITH_VALUE.has(tok)) {
|
|
1489
1584
|
hoisted.push(tok);
|
|
1490
|
-
if (i + 1 <
|
|
1491
|
-
hoisted.push(
|
|
1585
|
+
if (i + 1 < scanned.length) {
|
|
1586
|
+
hoisted.push(scanned[i + 1]);
|
|
1492
1587
|
i++;
|
|
1493
1588
|
}
|
|
1494
1589
|
continue;
|
|
@@ -1497,13 +1592,9 @@ function normalizeArgv(argv) {
|
|
|
1497
1592
|
hoisted.push(tok);
|
|
1498
1593
|
continue;
|
|
1499
1594
|
}
|
|
1500
|
-
|
|
1595
|
+
kept.push(tok);
|
|
1501
1596
|
}
|
|
1502
|
-
|
|
1503
|
-
if (dd >= 0) {
|
|
1504
|
-
return [bun, script, ...leftover, ...subcommandOnwards.slice(0, dd), ...hoisted, ...subcommandOnwards.slice(dd)];
|
|
1505
|
-
}
|
|
1506
|
-
return [bun, script, ...leftover, ...subcommandOnwards, ...hoisted];
|
|
1597
|
+
return [bun, script, ...kept, ...hoisted, ...passthrough];
|
|
1507
1598
|
}
|
|
1508
1599
|
function isVersionRequest(rawArgs) {
|
|
1509
1600
|
for (let i = 0;i < rawArgs.length; i++) {
|
|
@@ -1521,13 +1612,15 @@ function isVersionRequest(rawArgs) {
|
|
|
1521
1612
|
}
|
|
1522
1613
|
|
|
1523
1614
|
// src/lib/format-resolve.ts
|
|
1524
|
-
function
|
|
1525
|
-
if (explicit === undefined)
|
|
1526
|
-
return
|
|
1527
|
-
if (explicit === "table" || explicit === "json")
|
|
1528
|
-
return explicit;
|
|
1615
|
+
function assertValidFormat(explicit) {
|
|
1616
|
+
if (explicit === undefined || explicit === "table" || explicit === "json")
|
|
1617
|
+
return;
|
|
1529
1618
|
throw new CliError("BAD_ARGS", `Unknown --format value: "${explicit}". Use "table" or "json".`);
|
|
1530
1619
|
}
|
|
1620
|
+
function resolveFormat({ explicit, isTty }) {
|
|
1621
|
+
assertValidFormat(explicit);
|
|
1622
|
+
return explicit ?? (isTty ? "table" : "json");
|
|
1623
|
+
}
|
|
1531
1624
|
function formatFromArgv(argv, isTty) {
|
|
1532
1625
|
let explicit;
|
|
1533
1626
|
for (let i = 0;i < argv.length; i++) {
|
|
@@ -1659,6 +1752,8 @@ var processIo = {
|
|
|
1659
1752
|
};
|
|
1660
1753
|
var camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
1661
1754
|
function assertKnownArgs(declared, args) {
|
|
1755
|
+
if ("format" in declared)
|
|
1756
|
+
assertValidFormat(args.format);
|
|
1662
1757
|
const known = new Set(["_"]);
|
|
1663
1758
|
let positionals = 0;
|
|
1664
1759
|
for (const [name, def] of Object.entries(declared)) {
|
|
@@ -1891,6 +1986,7 @@ var hr = defineCollection({
|
|
|
1891
1986
|
conflict: "ignore",
|
|
1892
1987
|
rangeParams: "datetime",
|
|
1893
1988
|
maxRangeDays: 30,
|
|
1989
|
+
syncLookbackDays: 14,
|
|
1894
1990
|
identity: [{ field: "timestamp", format: "date-time", description: "ISO 8601 timestamp of the sample" }],
|
|
1895
1991
|
columns: [
|
|
1896
1992
|
{ name: "timestamp", type: "TEXT", pick: (r) => r.timestamp },
|
|
@@ -2028,6 +2124,192 @@ var cvAge = defineCollection({
|
|
|
2028
2124
|
]
|
|
2029
2125
|
});
|
|
2030
2126
|
|
|
2127
|
+
// src/collections/resilience.ts
|
|
2128
|
+
var resilience = defineCollection({
|
|
2129
|
+
name: "resilience",
|
|
2130
|
+
endpoint: "daily_resilience",
|
|
2131
|
+
table: "daily_resilience",
|
|
2132
|
+
description: "Daily resilience level with its sleep-recovery, daytime-recovery and stress contributors",
|
|
2133
|
+
conflict: "replace",
|
|
2134
|
+
rangeParams: "date",
|
|
2135
|
+
identity: [
|
|
2136
|
+
{ field: "id", description: "Oura record id" },
|
|
2137
|
+
{ field: "day", format: "date", description: "Date the record applies to (YYYY-MM-DD)" }
|
|
2138
|
+
],
|
|
2139
|
+
columns: [
|
|
2140
|
+
{ name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
|
|
2141
|
+
{ name: "day", type: "TEXT", unique: true, pick: (r) => r.day },
|
|
2142
|
+
{ name: "level", type: "TEXT", pick: (r) => r.level ?? null },
|
|
2143
|
+
{ name: "sleep_recovery", type: "REAL", pick: (r) => r.contributors?.sleep_recovery ?? null },
|
|
2144
|
+
{ name: "daytime_recovery", type: "REAL", pick: (r) => r.contributors?.daytime_recovery ?? null },
|
|
2145
|
+
{ name: "stress", type: "REAL", pick: (r) => r.contributors?.stress ?? null }
|
|
2146
|
+
]
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
// src/collections/vo2max.ts
|
|
2150
|
+
var vo2max = defineCollection({
|
|
2151
|
+
name: "vo2max",
|
|
2152
|
+
endpoint: "vO2_max",
|
|
2153
|
+
table: "vo2max",
|
|
2154
|
+
description: "Daily VO2 max estimate",
|
|
2155
|
+
conflict: "replace",
|
|
2156
|
+
rangeParams: "date",
|
|
2157
|
+
dayRangeOffset: [0, 1],
|
|
2158
|
+
identity: [
|
|
2159
|
+
{ field: "id", description: "Oura record id" },
|
|
2160
|
+
{ field: "day", format: "date", description: "Date the record applies to (YYYY-MM-DD)" }
|
|
2161
|
+
],
|
|
2162
|
+
columns: [
|
|
2163
|
+
{ name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
|
|
2164
|
+
{ name: "day", type: "TEXT", unique: true, pick: (r) => r.day },
|
|
2165
|
+
{ name: "vo2_max", type: "REAL", pick: (r) => r.vo2_max ?? null },
|
|
2166
|
+
{ name: "timestamp", type: "TEXT", pick: (r) => r.timestamp }
|
|
2167
|
+
]
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
// src/collections/sleep-time.ts
|
|
2171
|
+
var sleepTime = defineCollection({
|
|
2172
|
+
name: "sleep-time",
|
|
2173
|
+
endpoint: "sleep_time",
|
|
2174
|
+
table: "sleep_time",
|
|
2175
|
+
description: "Suggested bedtime window (offsets in seconds from midnight) with status and recommendation",
|
|
2176
|
+
conflict: "replace",
|
|
2177
|
+
rangeParams: "date",
|
|
2178
|
+
identity: [
|
|
2179
|
+
{ field: "id", description: "Oura record id" },
|
|
2180
|
+
{ field: "day", format: "date", description: "Date the record applies to (YYYY-MM-DD)" }
|
|
2181
|
+
],
|
|
2182
|
+
columns: [
|
|
2183
|
+
{ name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
|
|
2184
|
+
{ name: "day", type: "TEXT", unique: true, pick: (r) => r.day },
|
|
2185
|
+
{ name: "status", type: "TEXT", pick: (r) => r.status ?? null },
|
|
2186
|
+
{ name: "recommendation", type: "TEXT", pick: (r) => r.recommendation ?? null },
|
|
2187
|
+
{ name: "bedtime_start_offset", type: "INTEGER", pick: (r) => r.optimal_bedtime?.start_offset ?? null },
|
|
2188
|
+
{ name: "bedtime_end_offset", type: "INTEGER", pick: (r) => r.optimal_bedtime?.end_offset ?? null },
|
|
2189
|
+
{ name: "day_tz", type: "INTEGER", pick: (r) => r.optimal_bedtime?.day_tz ?? null }
|
|
2190
|
+
]
|
|
2191
|
+
});
|
|
2192
|
+
|
|
2193
|
+
// src/collections/session.ts
|
|
2194
|
+
var session = defineCollection({
|
|
2195
|
+
name: "session",
|
|
2196
|
+
endpoint: "session",
|
|
2197
|
+
table: "sessions",
|
|
2198
|
+
description: "Guided sessions (meditation, breathing, naps, rest) with their sample series as JSON",
|
|
2199
|
+
conflict: "replace",
|
|
2200
|
+
rangeParams: "date",
|
|
2201
|
+
dayRangeOffset: [0, 1],
|
|
2202
|
+
identity: [
|
|
2203
|
+
{ field: "id", description: "Oura record id" },
|
|
2204
|
+
{ field: "day", format: "date", description: "Date the session belongs to (YYYY-MM-DD)" }
|
|
2205
|
+
],
|
|
2206
|
+
columns: [
|
|
2207
|
+
{ name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
|
|
2208
|
+
{ name: "day", type: "TEXT", pick: (r) => r.day },
|
|
2209
|
+
{ name: "type", type: "TEXT", pick: (r) => r.type ?? null },
|
|
2210
|
+
{ name: "mood", type: "TEXT", pick: (r) => r.mood ?? null },
|
|
2211
|
+
{ name: "start_datetime", type: "TEXT", pick: (r) => r.start_datetime },
|
|
2212
|
+
{ name: "end_datetime", type: "TEXT", pick: (r) => r.end_datetime },
|
|
2213
|
+
{ name: "heart_rate", type: "TEXT", pick: (r) => r.heart_rate == null ? null : JSON.stringify(r.heart_rate) },
|
|
2214
|
+
{ name: "heart_rate_variability", type: "TEXT", pick: (r) => r.heart_rate_variability == null ? null : JSON.stringify(r.heart_rate_variability) },
|
|
2215
|
+
{ name: "motion_count", type: "TEXT", pick: (r) => r.motion_count == null ? null : JSON.stringify(r.motion_count) }
|
|
2216
|
+
],
|
|
2217
|
+
indexes: [{ name: "idx_sessions_day", columns: ["day"] }]
|
|
2218
|
+
});
|
|
2219
|
+
|
|
2220
|
+
// src/collections/rest-mode.ts
|
|
2221
|
+
var restMode = defineCollection({
|
|
2222
|
+
name: "rest-mode",
|
|
2223
|
+
endpoint: "rest_mode_period",
|
|
2224
|
+
table: "rest_mode_periods",
|
|
2225
|
+
description: "Rest mode periods; `day` is the period start day, episodes are kept as JSON",
|
|
2226
|
+
conflict: "replace",
|
|
2227
|
+
rangeParams: "date",
|
|
2228
|
+
dayRangeOffset: [0, 1],
|
|
2229
|
+
identity: [
|
|
2230
|
+
{ field: "id", description: "Oura record id" },
|
|
2231
|
+
{ field: "start_day", format: "date", description: "First day of the period (YYYY-MM-DD)" }
|
|
2232
|
+
],
|
|
2233
|
+
columns: [
|
|
2234
|
+
{ name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
|
|
2235
|
+
{ name: "day", type: "TEXT", pick: (r) => r.start_day },
|
|
2236
|
+
{ name: "end_day", type: "TEXT", pick: (r) => r.end_day ?? null },
|
|
2237
|
+
{ name: "start_time", type: "TEXT", pick: (r) => r.start_time ?? null },
|
|
2238
|
+
{ name: "end_time", type: "TEXT", pick: (r) => r.end_time ?? null },
|
|
2239
|
+
{ name: "episodes", type: "TEXT", pick: (r) => r.episodes == null ? null : JSON.stringify(r.episodes) }
|
|
2240
|
+
],
|
|
2241
|
+
indexes: [{ name: "idx_rest_mode_periods_day", columns: ["day"] }]
|
|
2242
|
+
});
|
|
2243
|
+
|
|
2244
|
+
// src/collections/tags.ts
|
|
2245
|
+
var tags = defineCollection({
|
|
2246
|
+
name: "tags",
|
|
2247
|
+
endpoint: "enhanced_tag",
|
|
2248
|
+
table: "enhanced_tags",
|
|
2249
|
+
description: "Tags the user added in the app; `day` is the tag start day",
|
|
2250
|
+
conflict: "replace",
|
|
2251
|
+
rangeParams: "date",
|
|
2252
|
+
dayRangeOffset: [0, 1],
|
|
2253
|
+
identity: [
|
|
2254
|
+
{ field: "id", description: "Oura record id" },
|
|
2255
|
+
{ field: "start_day", format: "date", description: "Day the tag starts on (YYYY-MM-DD)" }
|
|
2256
|
+
],
|
|
2257
|
+
columns: [
|
|
2258
|
+
{ name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
|
|
2259
|
+
{ name: "day", type: "TEXT", pick: (r) => r.start_day },
|
|
2260
|
+
{ name: "end_day", type: "TEXT", pick: (r) => r.end_day ?? null },
|
|
2261
|
+
{ name: "start_time", type: "TEXT", pick: (r) => r.start_time ?? null },
|
|
2262
|
+
{ name: "end_time", type: "TEXT", pick: (r) => r.end_time ?? null },
|
|
2263
|
+
{ name: "tag_type_code", type: "TEXT", pick: (r) => r.tag_type_code ?? null },
|
|
2264
|
+
{ name: "comment", type: "TEXT", pick: (r) => r.comment ?? null },
|
|
2265
|
+
{ name: "custom_name", type: "TEXT", pick: (r) => r.custom_name ?? null }
|
|
2266
|
+
],
|
|
2267
|
+
indexes: [{ name: "idx_enhanced_tags_day", columns: ["day"] }]
|
|
2268
|
+
});
|
|
2269
|
+
|
|
2270
|
+
// src/collections/ring.ts
|
|
2271
|
+
var ring = defineCollection({
|
|
2272
|
+
name: "ring",
|
|
2273
|
+
endpoint: "ring_configuration",
|
|
2274
|
+
table: "ring_configuration",
|
|
2275
|
+
description: "Ring hardware, colour, size, firmware and set-up time; a snapshot list, not a day range",
|
|
2276
|
+
conflict: "replace",
|
|
2277
|
+
rangeParams: "none",
|
|
2278
|
+
identity: [{ field: "id", description: "Oura ring id" }],
|
|
2279
|
+
columns: [
|
|
2280
|
+
{ name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
|
|
2281
|
+
{ name: "color", type: "TEXT", pick: (r) => r.color ?? null },
|
|
2282
|
+
{ name: "design", type: "TEXT", pick: (r) => r.design ?? null },
|
|
2283
|
+
{ name: "firmware_version", type: "TEXT", pick: (r) => r.firmware_version ?? null },
|
|
2284
|
+
{ name: "hardware_type", type: "TEXT", pick: (r) => r.hardware_type ?? null },
|
|
2285
|
+
{ name: "set_up_at", type: "TEXT", pick: (r) => r.set_up_at ?? null },
|
|
2286
|
+
{ name: "size", type: "INTEGER", pick: (r) => r.size ?? null }
|
|
2287
|
+
]
|
|
2288
|
+
});
|
|
2289
|
+
|
|
2290
|
+
// src/collections/battery.ts
|
|
2291
|
+
var battery = defineCollection({
|
|
2292
|
+
name: "battery",
|
|
2293
|
+
endpoint: "ring_battery_level",
|
|
2294
|
+
table: "ring_battery_level",
|
|
2295
|
+
description: "Ring battery level events (percent) with charging state",
|
|
2296
|
+
conflict: "ignore",
|
|
2297
|
+
rangeParams: "datetime",
|
|
2298
|
+
maxRangeDays: 30,
|
|
2299
|
+
identity: [{ field: "timestamp", format: "date-time", description: "ISO 8601 timestamp of the event" }],
|
|
2300
|
+
columns: [
|
|
2301
|
+
{ name: "timestamp", type: "TEXT", pick: (r) => r.timestamp },
|
|
2302
|
+
{ name: "level", type: "INTEGER", pick: (r) => r.level ?? null },
|
|
2303
|
+
{ name: "charging", type: "INTEGER", pick: (r) => r.charging == null ? null : r.charging ? 1 : 0 },
|
|
2304
|
+
{ name: "in_charger", type: "INTEGER", pick: (r) => r.in_charger == null ? null : r.in_charger ? 1 : 0 },
|
|
2305
|
+
{ name: "day", type: "TEXT", pick: (r) => r.timestamp.slice(0, 10) }
|
|
2306
|
+
],
|
|
2307
|
+
indexes: [
|
|
2308
|
+
{ name: "idx_ring_battery_level_unique", columns: ["timestamp"], unique: true },
|
|
2309
|
+
{ name: "idx_ring_battery_level_day", columns: ["day"] }
|
|
2310
|
+
]
|
|
2311
|
+
});
|
|
2312
|
+
|
|
2031
2313
|
// src/collections/index.ts
|
|
2032
2314
|
var COLLECTIONS = [
|
|
2033
2315
|
sleep,
|
|
@@ -2038,7 +2320,15 @@ var COLLECTIONS = [
|
|
|
2038
2320
|
stress,
|
|
2039
2321
|
workout,
|
|
2040
2322
|
sleepPeriods,
|
|
2041
|
-
cvAge
|
|
2323
|
+
cvAge,
|
|
2324
|
+
resilience,
|
|
2325
|
+
vo2max,
|
|
2326
|
+
sleepTime,
|
|
2327
|
+
session,
|
|
2328
|
+
restMode,
|
|
2329
|
+
tags,
|
|
2330
|
+
ring,
|
|
2331
|
+
battery
|
|
2042
2332
|
];
|
|
2043
2333
|
function names() {
|
|
2044
2334
|
return COLLECTIONS.map((c) => c.name);
|
|
@@ -2078,6 +2368,8 @@ function datetimeQueries(start, end, tz, maxDays) {
|
|
|
2078
2368
|
return out;
|
|
2079
2369
|
}
|
|
2080
2370
|
function rangeQueries(c, start, end, tz) {
|
|
2371
|
+
if (c.rangeParams === "none")
|
|
2372
|
+
return [{}];
|
|
2081
2373
|
if (start > end)
|
|
2082
2374
|
return [];
|
|
2083
2375
|
return c.rangeParams === "date" ? dateQueries(start, end, c.maxRangeDays, c.dayRangeOffset ?? [0, 0]) : datetimeQueries(start, end, tz, c.maxRangeDays);
|
|
@@ -2192,8 +2484,8 @@ function describeCommand(version, getCommands) {
|
|
|
2192
2484
|
|
|
2193
2485
|
// src/db/sync.ts
|
|
2194
2486
|
var BACKFILL_DAYS = 30;
|
|
2195
|
-
function lastDay(db, table) {
|
|
2196
|
-
return db.query(`SELECT MAX(day) AS d FROM ${table}
|
|
2487
|
+
function lastDay(db, table, end) {
|
|
2488
|
+
return db.query(`SELECT MAX(day) AS d FROM ${table} WHERE day <= ?`).get(end).d;
|
|
2197
2489
|
}
|
|
2198
2490
|
function rowCount(db, table) {
|
|
2199
2491
|
return db.query(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
|
|
@@ -2204,26 +2496,44 @@ async function importDaily(db, client, clock, log, window = {}) {
|
|
|
2204
2496
|
const end = window.to ?? today;
|
|
2205
2497
|
const backfillStart = shiftDay(end, -(BACKFILL_DAYS - 1));
|
|
2206
2498
|
const plan = COLLECTIONS.map((c) => {
|
|
2207
|
-
const last = lastDay(db, c.table);
|
|
2208
|
-
|
|
2499
|
+
const last = c.rangeParams === "none" ? null : lastDay(db, c.table, end);
|
|
2500
|
+
const resume = window.from ?? last ?? backfillStart;
|
|
2501
|
+
const start = window.from ?? shiftDay(resume, -(last === null ? 0 : c.syncLookbackDays ?? 0));
|
|
2502
|
+
return { c, last, resume, start };
|
|
2209
2503
|
});
|
|
2210
|
-
const
|
|
2211
|
-
const
|
|
2504
|
+
const ranged = plan.filter((p) => p.c.rangeParams !== "none");
|
|
2505
|
+
const isFirstSync = ranged.every((p) => p.last === null);
|
|
2506
|
+
const startDate = ranged.map((p) => p.resume).sort()[0];
|
|
2212
2507
|
_log(isFirstSync && window.from === undefined ? `First sync \u2014 backfilling the last ${BACKFILL_DAYS} days: ${startDate} \u2192 ${end}` : `Syncing ${startDate} \u2192 ${end}`);
|
|
2213
2508
|
const fetched = {};
|
|
2214
2509
|
const added = {};
|
|
2215
2510
|
for (const { c, start } of plan) {
|
|
2216
2511
|
const rows = await fetchCollection(client, c, start, end, tz);
|
|
2217
|
-
const before = rowCount(db, c.table);
|
|
2218
2512
|
const stmt = db.query(insertSql(c));
|
|
2513
|
+
if (c.rangeParams === "none") {
|
|
2514
|
+
const pk = c.columns.find((col) => col.pk)?.name;
|
|
2515
|
+
if (!pk)
|
|
2516
|
+
throw new Error(`Snapshot collection ${c.name} must declare a primary-key column (enforced by the registry tests).`);
|
|
2517
|
+
const ids = () => new Set(db.query(`SELECT ${pk} AS id FROM ${c.table}`).all().map((r) => r.id));
|
|
2518
|
+
const known = ids();
|
|
2519
|
+
db.transaction((rs) => {
|
|
2520
|
+
db.exec(`DELETE FROM ${c.table}`);
|
|
2521
|
+
for (const r of rs)
|
|
2522
|
+
stmt.run(...rowValues(c, r));
|
|
2523
|
+
})(rows);
|
|
2524
|
+
fetched[c.table] = rows.length;
|
|
2525
|
+
added[c.table] = [...ids()].filter((id) => !known.has(id)).length;
|
|
2526
|
+
_log(` + ${c.name} (${c.table}): ${rows.length} fetched, ${added[c.table]} new${rows.length === 0 ? ", table cleared" : ""}`);
|
|
2527
|
+
continue;
|
|
2528
|
+
}
|
|
2529
|
+
const before = rowCount(db, c.table);
|
|
2219
2530
|
db.transaction((rs) => {
|
|
2220
2531
|
for (const r of rs)
|
|
2221
2532
|
stmt.run(...rowValues(c, r));
|
|
2222
2533
|
})(rows);
|
|
2223
2534
|
fetched[c.table] = rows.length;
|
|
2224
2535
|
added[c.table] = rowCount(db, c.table) - before;
|
|
2225
|
-
|
|
2226
|
-
_log(` + ${c.table}: ${rows.length} fetched, ${added[c.table]} new`);
|
|
2536
|
+
_log(` + ${c.name} (${c.table}): ${rows.length} fetched, ${added[c.table]} new`);
|
|
2227
2537
|
}
|
|
2228
2538
|
_log("Import complete.");
|
|
2229
2539
|
return { startDate, endDate: end, fetched, added, isFirstSync };
|
|
@@ -2298,6 +2608,27 @@ function getStats(db, today) {
|
|
|
2298
2608
|
|
|
2299
2609
|
// src/render/format.ts
|
|
2300
2610
|
init_source();
|
|
2611
|
+
|
|
2612
|
+
// src/lib/pad.ts
|
|
2613
|
+
var ANSI = /\u001B\[[0-9;]*m/g;
|
|
2614
|
+
function visibleWidth(text) {
|
|
2615
|
+
return text.replace(ANSI, "").length;
|
|
2616
|
+
}
|
|
2617
|
+
function padLeft(text, width) {
|
|
2618
|
+
const gap = width - visibleWidth(text);
|
|
2619
|
+
return gap > 0 ? " ".repeat(gap) + text : text;
|
|
2620
|
+
}
|
|
2621
|
+
function padRight(text, width) {
|
|
2622
|
+
const gap = width - visibleWidth(text);
|
|
2623
|
+
return gap > 0 ? text + " ".repeat(gap) : text;
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
// src/lib/terminal.ts
|
|
2627
|
+
function terminalWidth() {
|
|
2628
|
+
return process.stdout.isTTY && process.stdout.columns ? process.stdout.columns : 80;
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
// src/render/format.ts
|
|
2301
2632
|
function scoreColor(score) {
|
|
2302
2633
|
if (score === null)
|
|
2303
2634
|
return source_default.gray("\u2014");
|
|
@@ -2351,13 +2682,35 @@ function formatDaySummary(summary, format, emptyHint) {
|
|
|
2351
2682
|
return lines.join(`
|
|
2352
2683
|
`);
|
|
2353
2684
|
}
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2685
|
+
var SUMMARY_INDENT = 4;
|
|
2686
|
+
var SUMMARY_GAP = 2;
|
|
2687
|
+
var SUMMARY_MAX_COLUMNS = 4;
|
|
2688
|
+
function columnsThatFit(cellWidth, width) {
|
|
2689
|
+
const fits = Math.floor((width - SUMMARY_INDENT + SUMMARY_GAP) / (cellWidth + SUMMARY_GAP));
|
|
2690
|
+
return Math.max(1, Math.min(SUMMARY_MAX_COLUMNS, fits));
|
|
2691
|
+
}
|
|
2692
|
+
function rowsOf(cells, columns) {
|
|
2693
|
+
const rows = [];
|
|
2694
|
+
for (let i = 0;i < cells.length; i += columns) {
|
|
2695
|
+
rows.push(" ".repeat(SUMMARY_INDENT) + cells.slice(i, i + columns).join(" ".repeat(SUMMARY_GAP)).trimEnd());
|
|
2696
|
+
}
|
|
2697
|
+
return rows;
|
|
2698
|
+
}
|
|
2699
|
+
function formatImportSummary(result, width = terminalWidth()) {
|
|
2700
|
+
const counts = COLLECTIONS.map((c) => ({
|
|
2701
|
+
name: c.name,
|
|
2702
|
+
fetched: String(result.fetched[c.table] ?? 0),
|
|
2703
|
+
added: String(result.added[c.table] ?? 0)
|
|
2704
|
+
}));
|
|
2705
|
+
const nameW = Math.max(...counts.map((c) => visibleWidth(c.name)));
|
|
2706
|
+
const fetchedW = Math.max(...counts.map((c) => visibleWidth(c.fetched)));
|
|
2707
|
+
const aligned = counts.map((c) => `${padRight(c.name, nameW)} ${padLeft(c.fetched, fetchedW)} (+${c.added})`);
|
|
2708
|
+
const bare = counts.map((c) => `${c.name} ${c.fetched} (+${c.added})`);
|
|
2709
|
+
const cellW = Math.max(...aligned.map(visibleWidth));
|
|
2710
|
+
const columns = columnsThatFit(cellW, width);
|
|
2711
|
+
const cells = columns === 1 ? bare : aligned.map((c) => padRight(c, cellW));
|
|
2712
|
+
const head = ` Fetched ${result.startDate} \u2192 ${result.endDate}, rows fetched (+new):`;
|
|
2713
|
+
return [head, ...rowsOf(cells, columns)].join(`
|
|
2361
2714
|
`);
|
|
2362
2715
|
}
|
|
2363
2716
|
function formatWeekTable(days, format, emptyHint) {
|
|
@@ -2373,7 +2726,7 @@ function formatWeekTable(days, format, emptyHint) {
|
|
|
2373
2726
|
}
|
|
2374
2727
|
const header = `${"Day".padEnd(12)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Activity".padStart(9)} ${"Steps".padStart(7)} ${"Stress".padEnd(10)}`;
|
|
2375
2728
|
const sep = source_default.gray("\u2500".repeat(56));
|
|
2376
|
-
const rows = days.map((d) => `${d.day
|
|
2729
|
+
const rows = days.map((d) => `${padRight(d.day, 12)} ${padLeft(scoreColor(d.sleep_score), 6)} ${padLeft(scoreColor(d.readiness_score), 6)} ` + `${padLeft(scoreColor(d.activity_score), 9)} ${padLeft(String(d.steps ?? "\u2014"), 7)} ${padRight(d.stress ?? "\u2014", 10)}`);
|
|
2377
2730
|
return [`
|
|
2378
2731
|
Last 7 Days`, sep, ` ${header}`, sep, ...rows.map((r) => ` ${r}`)].join(`
|
|
2379
2732
|
`);
|
|
@@ -2422,13 +2775,17 @@ function formatStats(stats, format) {
|
|
|
2422
2775
|
}
|
|
2423
2776
|
|
|
2424
2777
|
// src/commands/sync.ts
|
|
2425
|
-
function resolveWindow(opts) {
|
|
2778
|
+
function resolveWindow(opts, today) {
|
|
2426
2779
|
if (opts.to !== undefined && opts.from === undefined)
|
|
2427
2780
|
throw new CliError("BAD_ARGS", "--to requires --from.");
|
|
2428
2781
|
const from = opts.from === undefined ? undefined : assertCalendarDate(opts.from, "--from");
|
|
2429
2782
|
const to = opts.to === undefined ? undefined : assertCalendarDate(opts.to, "--to");
|
|
2430
2783
|
if (from !== undefined && to !== undefined && from > to)
|
|
2431
2784
|
throw new CliError("BAD_ARGS", `--from (${from}) must not be after --to (${to}).`);
|
|
2785
|
+
const end = to ?? today;
|
|
2786
|
+
if (from !== undefined && from > end) {
|
|
2787
|
+
throw new CliError("BAD_ARGS", `--from (${from}) is after the end of the window (${end}).`, "Pass --to as well to sync a window that ends in the future.");
|
|
2788
|
+
}
|
|
2432
2789
|
return { from, to };
|
|
2433
2790
|
}
|
|
2434
2791
|
async function runSync(ctx, window = {}) {
|
|
@@ -2449,7 +2806,7 @@ var syncCommand = dataCommand({
|
|
|
2449
2806
|
to: { type: "string", description: "End of the explicit window (YYYY-MM-DD, default: today); requires --from" }
|
|
2450
2807
|
},
|
|
2451
2808
|
needs: { db: true, client: true },
|
|
2452
|
-
run: (ctx, args) => runSync(ctx, resolveWindow({ from: args.from, to: args.to }))
|
|
2809
|
+
run: (ctx, args) => runSync(ctx, resolveWindow({ from: args.from, to: args.to }, ctx.today))
|
|
2453
2810
|
});
|
|
2454
2811
|
|
|
2455
2812
|
// src/commands/db.ts
|
|
@@ -2513,7 +2870,7 @@ function dayLabel(dateStr) {
|
|
|
2513
2870
|
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
2514
2871
|
return `${day} ${dd}/${mm}`;
|
|
2515
2872
|
}
|
|
2516
|
-
function getReport(db, days, today
|
|
2873
|
+
function getReport(db, days, today) {
|
|
2517
2874
|
const period = days <= 7 ? "week" : "month";
|
|
2518
2875
|
const weekEnd = today;
|
|
2519
2876
|
const weekStart = shiftDay(today, -(days - 1));
|
|
@@ -2521,7 +2878,8 @@ function getReport(db, days, today, tz = "UTC") {
|
|
|
2521
2878
|
const prevWeekStart = shiftDay(today, -(days * 2 - 1));
|
|
2522
2879
|
const windowDays = daysBack(today, days);
|
|
2523
2880
|
const lastUpload = db.query("SELECT MAX(timestamp) AS t FROM heartrate").get().t;
|
|
2524
|
-
const
|
|
2881
|
+
const newestActivityDay = db.query("SELECT MAX(day) AS d FROM daily_activity").get().d;
|
|
2882
|
+
const isComplete = (d) => d < today && newestActivityDay !== null && newestActivityDay > d;
|
|
2525
2883
|
const completeThrough = [...windowDays].reverse().find(isComplete) ?? null;
|
|
2526
2884
|
const activityEnd = completeThrough ?? shiftDay(weekStart, -1);
|
|
2527
2885
|
const dailyRows = [];
|
|
@@ -2607,13 +2965,13 @@ function colorizeScore(n) {
|
|
|
2607
2965
|
}
|
|
2608
2966
|
function scoreCell(n, width) {
|
|
2609
2967
|
if (n === null)
|
|
2610
|
-
return source_default.gray("\u2014"
|
|
2611
|
-
return colorizeScore(n)(String(n)
|
|
2968
|
+
return padLeft(source_default.gray("\u2014"), width);
|
|
2969
|
+
return padLeft(colorizeScore(n)(String(n)), width);
|
|
2612
2970
|
}
|
|
2613
2971
|
function stepsCell(n, width) {
|
|
2614
2972
|
if (n === null)
|
|
2615
|
-
return source_default.gray("\u2014"
|
|
2616
|
-
return n.toLocaleString()
|
|
2973
|
+
return padLeft(source_default.gray("\u2014"), width);
|
|
2974
|
+
return padLeft(n.toLocaleString(), width);
|
|
2617
2975
|
}
|
|
2618
2976
|
function fmtSeconds(s) {
|
|
2619
2977
|
if (s === null)
|
|
@@ -2655,18 +3013,15 @@ function bucketDaysIntoWeeks(days) {
|
|
|
2655
3013
|
}
|
|
2656
3014
|
return buckets;
|
|
2657
3015
|
}
|
|
2658
|
-
function partialDayNote(data
|
|
2659
|
-
const partial = data.days.
|
|
2660
|
-
if (partial
|
|
3016
|
+
function partialDayNote(data) {
|
|
3017
|
+
const partial = data.days.find((d) => d.partial);
|
|
3018
|
+
if (!partial)
|
|
2661
3019
|
return null;
|
|
2662
|
-
const
|
|
2663
|
-
const newestLabel = newest.day === data.weekEnd ? "today" : newest.dayLabel;
|
|
2664
|
-
const which = partial.length === 1 ? `${newestLabel} is` : `${newestLabel} and ${partial.length - 1} earlier day${partial.length > 2 ? "s" : ""} are`;
|
|
2665
|
-
const synced = data.lastUpload ? `ring last synced ${formatLocal(data.lastUpload, tz)}` : "ring sync time unknown";
|
|
3020
|
+
const which = partial.day === data.weekEnd ? "today" : partial.dayLabel;
|
|
2666
3021
|
const covers = data.completeThrough ? `through ${data.completeThrough}` : "no complete day yet";
|
|
2667
|
-
return ` * ${which} still accumulating
|
|
3022
|
+
return ` * ${which} is still accumulating; activity averages cover ${covers}.`;
|
|
2668
3023
|
}
|
|
2669
|
-
function formatReport(data, format, period
|
|
3024
|
+
function formatReport(data, format, period) {
|
|
2670
3025
|
if (format === "json")
|
|
2671
3026
|
return JSON.stringify(data, null, 2);
|
|
2672
3027
|
const lines = [];
|
|
@@ -2677,7 +3032,7 @@ function formatReport(data, format, period, tz = "UTC") {
|
|
|
2677
3032
|
lines.push(source_default.bold(" Oura Monthly Report"));
|
|
2678
3033
|
}
|
|
2679
3034
|
lines.push(source_default.gray(` ${data.weekStart} \u2014 ${data.weekEnd}`));
|
|
2680
|
-
const note = partialDayNote(data
|
|
3035
|
+
const note = partialDayNote(data);
|
|
2681
3036
|
if (note)
|
|
2682
3037
|
lines.push(source_default.yellow(note));
|
|
2683
3038
|
lines.push("");
|
|
@@ -2769,8 +3124,8 @@ var reportCommand = dataCommand({
|
|
|
2769
3124
|
if (period !== "week" && period !== "month") {
|
|
2770
3125
|
throw new CliError("BAD_ARGS", `--period must be "week" or "month", got "${period}".`);
|
|
2771
3126
|
}
|
|
2772
|
-
const data = getReport(ctx.db, period === "week" ? 7 : 30, ctx.today
|
|
2773
|
-
return { json: data, text: () => formatReport(data, "table", period
|
|
3127
|
+
const data = getReport(ctx.db, period === "week" ? 7 : 30, ctx.today);
|
|
3128
|
+
return { json: data, text: () => formatReport(data, "table", period) };
|
|
2774
3129
|
}
|
|
2775
3130
|
});
|
|
2776
3131
|
|
|
@@ -2896,28 +3251,34 @@ function exitCodeForChecks(checks) {
|
|
|
2896
3251
|
return exitCodeFor(new CliError("TOKEN_MISSING", fail.detail));
|
|
2897
3252
|
return exitCodeFor(new CliError("DB_ERROR", fail.detail));
|
|
2898
3253
|
}
|
|
3254
|
+
async function runDoctor(ctx, args) {
|
|
3255
|
+
const dbPath = getDbPath(args.db);
|
|
3256
|
+
const deps = {
|
|
3257
|
+
resolveToken: () => resolveToken(args.token),
|
|
3258
|
+
openDb: () => {
|
|
3259
|
+
const db = openDatabase(args.db);
|
|
3260
|
+
ensureSchema(db);
|
|
3261
|
+
return { db, path: dbPath };
|
|
3262
|
+
},
|
|
3263
|
+
createClient: (token) => new OuraClient({ token }),
|
|
3264
|
+
offline: args.offline === true,
|
|
3265
|
+
today: ctx.today
|
|
3266
|
+
};
|
|
3267
|
+
const result = await runChecks(deps);
|
|
3268
|
+
return {
|
|
3269
|
+
json: result,
|
|
3270
|
+
text: () => formatDoctorTable(result),
|
|
3271
|
+
exitCode: exitCodeForChecks(result.checks)
|
|
3272
|
+
};
|
|
3273
|
+
}
|
|
2899
3274
|
var doctorCommand = dataCommand({
|
|
2900
3275
|
meta: { name: "doctor", description: "Diagnose token, database, and sync health, and suggest the next step." },
|
|
2901
3276
|
args: { offline: { type: "boolean", default: false, description: "Skip the live Oura API token-validation call" } },
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
ensureSchema(db);
|
|
2908
|
-
return { db, path: getDbPath(args.db) };
|
|
2909
|
-
},
|
|
2910
|
-
createClient: (token) => new OuraClient({ token }),
|
|
2911
|
-
offline: args.offline === true,
|
|
2912
|
-
today: ctx.today
|
|
2913
|
-
};
|
|
2914
|
-
const result = await runChecks(deps);
|
|
2915
|
-
return {
|
|
2916
|
-
json: result,
|
|
2917
|
-
text: () => formatDoctorTable(result),
|
|
2918
|
-
exitCode: exitCodeForChecks(result.checks)
|
|
2919
|
-
};
|
|
2920
|
-
}
|
|
3277
|
+
run: (ctx, args) => runDoctor(ctx, {
|
|
3278
|
+
db: args.db,
|
|
3279
|
+
token: args.token,
|
|
3280
|
+
offline: args.offline === true
|
|
3281
|
+
})
|
|
2921
3282
|
});
|
|
2922
3283
|
|
|
2923
3284
|
// src/commands/manifest.ts
|
|
@@ -2979,10 +3340,15 @@ function resolveRange(opts) {
|
|
|
2979
3340
|
}
|
|
2980
3341
|
return { start: opts.today, end: opts.today };
|
|
2981
3342
|
}
|
|
3343
|
+
function assertRangeAllowed(c, opts) {
|
|
3344
|
+
if (c.rangeParams === "none" && [opts.day, opts.from, opts.to, opts.days].some((v) => v !== undefined)) {
|
|
3345
|
+
throw new CliError("BAD_ARGS", `"${c.name}" is a snapshot, not a day range; it takes no --day, --from/--to or --days.`);
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
2982
3348
|
var fetchCommand = dataCommand({
|
|
2983
3349
|
meta: { name: "fetch", description: "Fetch raw records for one Oura collection straight from the API (JSON)." },
|
|
2984
3350
|
args: {
|
|
2985
|
-
collection: { type: "positional", required: true, description: `Collection: ${names().join(" | ")}` },
|
|
3351
|
+
collection: { type: "positional", required: true, description: `Collection: ${names().join(" | ")} (ring is a snapshot and takes no range flags)` },
|
|
2986
3352
|
day: { type: "string", description: "Single day (YYYY-MM-DD). Default: today." },
|
|
2987
3353
|
from: { type: "string", description: "Range start (YYYY-MM-DD); requires --to" },
|
|
2988
3354
|
to: { type: "string", description: "Range end (YYYY-MM-DD); requires --from" },
|
|
@@ -2993,13 +3359,14 @@ var fetchCommand = dataCommand({
|
|
|
2993
3359
|
const c = byName(args.collection);
|
|
2994
3360
|
if (!c)
|
|
2995
3361
|
throw new CliError("BAD_ARGS", `Unknown collection "${args.collection}".`, `Valid collections: ${names().join(", ")}`);
|
|
2996
|
-
const
|
|
3362
|
+
const opts = {
|
|
2997
3363
|
day: args.day,
|
|
2998
3364
|
from: args.from,
|
|
2999
3365
|
to: args.to,
|
|
3000
|
-
days: args.days
|
|
3001
|
-
|
|
3002
|
-
|
|
3366
|
+
days: args.days
|
|
3367
|
+
};
|
|
3368
|
+
assertRangeAllowed(c, opts);
|
|
3369
|
+
const { start, end } = resolveRange({ ...opts, today: ctx.today });
|
|
3003
3370
|
const client = new OuraClient(args.token ? { token: args.token } : {});
|
|
3004
3371
|
const data = await fetchCollection(client, c, start, end, ctx.tz);
|
|
3005
3372
|
return { json: data, text: () => JSON.stringify(data, null, 2) };
|
|
@@ -3007,12 +3374,12 @@ var fetchCommand = dataCommand({
|
|
|
3007
3374
|
});
|
|
3008
3375
|
|
|
3009
3376
|
// src/lib/citty-error.ts
|
|
3010
|
-
var
|
|
3377
|
+
var ANSI2 = /\u001b\[[0-9;]*m/g;
|
|
3011
3378
|
function fromCittyError(err, removedCommandHints = {}) {
|
|
3012
3379
|
const code = err?.code;
|
|
3013
3380
|
if (typeof code !== "string")
|
|
3014
3381
|
return err;
|
|
3015
|
-
const message = (err instanceof Error ? err.message : String(err)).replace(
|
|
3382
|
+
const message = (err instanceof Error ? err.message : String(err)).replace(ANSI2, "");
|
|
3016
3383
|
switch (code) {
|
|
3017
3384
|
case "E_UNKNOWN_COMMAND": {
|
|
3018
3385
|
const name = message.replace(/^Unknown command\s*/, "").trim();
|
|
@@ -3030,9 +3397,6 @@ function fromCittyError(err, removedCommandHints = {}) {
|
|
|
3030
3397
|
|
|
3031
3398
|
// src/index.ts
|
|
3032
3399
|
var VERSION = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf-8")).version;
|
|
3033
|
-
if (process.argv.includes("--no-color") || process.env.NO_COLOR) {
|
|
3034
|
-
source_default.level = 0;
|
|
3035
|
-
}
|
|
3036
3400
|
var subCommands = Object.assign(Object.create(null), {
|
|
3037
3401
|
login: loginCommand,
|
|
3038
3402
|
describe: describeCommand(VERSION, () => subCommands),
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/drakulavich/oura-cli/blob/main/docs/schemas/battery.json",
|
|
4
|
+
"title": "oura-cli fetch battery output",
|
|
5
|
+
"description": "Ring battery level events (percent) with charging state",
|
|
6
|
+
"type": "array",
|
|
7
|
+
"items": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": true,
|
|
10
|
+
"required": [
|
|
11
|
+
"timestamp"
|
|
12
|
+
],
|
|
13
|
+
"properties": {
|
|
14
|
+
"timestamp": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"format": "date-time",
|
|
17
|
+
"description": "ISO 8601 timestamp of the event"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/drakulavich/oura-cli/blob/main/docs/schemas/resilience.json",
|
|
4
|
+
"title": "oura-cli fetch resilience output",
|
|
5
|
+
"description": "Daily resilience level with its sleep-recovery, daytime-recovery and stress contributors",
|
|
6
|
+
"type": "array",
|
|
7
|
+
"items": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": true,
|
|
10
|
+
"required": [
|
|
11
|
+
"id",
|
|
12
|
+
"day"
|
|
13
|
+
],
|
|
14
|
+
"properties": {
|
|
15
|
+
"id": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"description": "Oura record id"
|
|
18
|
+
},
|
|
19
|
+
"day": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"format": "date",
|
|
22
|
+
"description": "Date the record applies to (YYYY-MM-DD)"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/drakulavich/oura-cli/blob/main/docs/schemas/rest-mode.json",
|
|
4
|
+
"title": "oura-cli fetch rest-mode output",
|
|
5
|
+
"description": "Rest mode periods; `day` is the period start day, episodes are kept as JSON",
|
|
6
|
+
"type": "array",
|
|
7
|
+
"items": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": true,
|
|
10
|
+
"required": [
|
|
11
|
+
"id",
|
|
12
|
+
"start_day"
|
|
13
|
+
],
|
|
14
|
+
"properties": {
|
|
15
|
+
"id": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"description": "Oura record id"
|
|
18
|
+
},
|
|
19
|
+
"start_day": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"format": "date",
|
|
22
|
+
"description": "First day of the period (YYYY-MM-DD)"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/drakulavich/oura-cli/blob/main/docs/schemas/ring.json",
|
|
4
|
+
"title": "oura-cli fetch ring output",
|
|
5
|
+
"description": "Ring hardware, colour, size, firmware and set-up time; a snapshot list, not a day range",
|
|
6
|
+
"type": "array",
|
|
7
|
+
"items": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": true,
|
|
10
|
+
"required": [
|
|
11
|
+
"id"
|
|
12
|
+
],
|
|
13
|
+
"properties": {
|
|
14
|
+
"id": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"description": "Oura ring id"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/drakulavich/oura-cli/blob/main/docs/schemas/session.json",
|
|
4
|
+
"title": "oura-cli fetch session output",
|
|
5
|
+
"description": "Guided sessions (meditation, breathing, naps, rest) with their sample series as JSON",
|
|
6
|
+
"type": "array",
|
|
7
|
+
"items": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": true,
|
|
10
|
+
"required": [
|
|
11
|
+
"id",
|
|
12
|
+
"day"
|
|
13
|
+
],
|
|
14
|
+
"properties": {
|
|
15
|
+
"id": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"description": "Oura record id"
|
|
18
|
+
},
|
|
19
|
+
"day": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"format": "date",
|
|
22
|
+
"description": "Date the session belongs to (YYYY-MM-DD)"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/drakulavich/oura-cli/blob/main/docs/schemas/sleep-time.json",
|
|
4
|
+
"title": "oura-cli fetch sleep-time output",
|
|
5
|
+
"description": "Suggested bedtime window (offsets in seconds from midnight) with status and recommendation",
|
|
6
|
+
"type": "array",
|
|
7
|
+
"items": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": true,
|
|
10
|
+
"required": [
|
|
11
|
+
"id",
|
|
12
|
+
"day"
|
|
13
|
+
],
|
|
14
|
+
"properties": {
|
|
15
|
+
"id": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"description": "Oura record id"
|
|
18
|
+
},
|
|
19
|
+
"day": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"format": "date",
|
|
22
|
+
"description": "Date the record applies to (YYYY-MM-DD)"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/drakulavich/oura-cli/blob/main/docs/schemas/tags.json",
|
|
4
|
+
"title": "oura-cli fetch tags output",
|
|
5
|
+
"description": "Tags the user added in the app; `day` is the tag start day",
|
|
6
|
+
"type": "array",
|
|
7
|
+
"items": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": true,
|
|
10
|
+
"required": [
|
|
11
|
+
"id",
|
|
12
|
+
"start_day"
|
|
13
|
+
],
|
|
14
|
+
"properties": {
|
|
15
|
+
"id": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"description": "Oura record id"
|
|
18
|
+
},
|
|
19
|
+
"start_day": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"format": "date",
|
|
22
|
+
"description": "Day the tag starts on (YYYY-MM-DD)"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/drakulavich/oura-cli/blob/main/docs/schemas/vo2max.json",
|
|
4
|
+
"title": "oura-cli fetch vo2max output",
|
|
5
|
+
"description": "Daily VO2 max estimate",
|
|
6
|
+
"type": "array",
|
|
7
|
+
"items": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": true,
|
|
10
|
+
"required": [
|
|
11
|
+
"id",
|
|
12
|
+
"day"
|
|
13
|
+
],
|
|
14
|
+
"properties": {
|
|
15
|
+
"id": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"description": "Oura record id"
|
|
18
|
+
},
|
|
19
|
+
"day": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"format": "date",
|
|
22
|
+
"description": "Date the record applies to (YYYY-MM-DD)"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
package/package.json
CHANGED