Add oura dashboard: local web health dashboard (Rust + models) - #5
Add oura dashboard: local web health dashboard (Rust + models)#5Th0rgal wants to merge 72 commits into
oura dashboard: local web health dashboard (Rust + models)#5Conversation
A single-page, local-first health dashboard following notes/dashboard-v2-brainstorm.md. - Rust (crates/oura-cli/src/dashboard.rs) owns the DB + non-model calcs: per-night HRV/RHR/skin-temp, SpO2 % via Oura's R→% calibration, baselines + deltas, the Device & Data Health panel, and the digest. It serves the page + /api/summary. - The torch models (sleep hypnogram, activity, CVA) run via the Python runners, shelled out like `oura sessions`. Added `--json` to run_sleep_model.py and run_cva_model.py for machine-readable output. - Frontend in a separate dashboard/web/ folder: vanilla HTML/CSS/JS, no build, no external deps. Auto light/dark via prefers-color-scheme, skeleton loading, reduced-motion safe, one accent + one radius, system + mono type. Activity grouped by day, per-night hypnograms, vitals tiles with sparklines. - New `oura dashboard --port --tz-offset --sex/--age/--height/--weight` subcommand (loopback-only). Default build includes it; models still run via Python. Verified in Playwright: light, dark, and mobile all render correctly.
- Display serif (system-only, offline) for the digest 'what changed' statement and the big hero numbers (vascular age, SpO2); data/tables stay mono. - Small UPPERCASE letter-spaced labels for panel headings, tile labels, day headers (the scientific/editorial meta voice). - Italic serif sub-lines under the hero numbers; more air on panels. Verified in Playwright (light, dark, mobile). No external/Google fonts — keeps the dashboard fully offline.
Functional: - Sync the ring from the header (POST /api/sync runs `oura sync` as a subprocess, using the dashboard's --name/--key-file), then auto-refreshes. CSRF-guarded. - Battery in the header + Device panel, read offline from stored `battery_level_changed` debug events (battery % + voltage). - Editable user profile (age/sex/height/weight/ring) the ring can't measure, stored in a gitignored profile.json next to oura.db and used by the CVA model + runners (GET/POST /api/profile, CSRF-guarded; run_cva_model.py reads it as defaults). Fixes the wrong "-11.2 yr vs your age" — now driven by your real age. Visual: - Vendored Phosphor icons (MIT, offline) on panel headers, actions, chips. - Leaner palette: neutral outline tags, subtle status dots instead of filled pills, monochrome thin sparklines with a faint area fill. Verified in Playwright (light/dark): battery, icons, profile edit → CVA re-run.
- Device & data health now shows the ring's identity, read offline from the
`device` table: Ring ID (serial), firmware, API version, MAC. Added
Store::device_info() (device row joined with sync_state).
- "Last sync" now uses the real sync_state.last_sync_unix instead of the event
anchor, so it reflects the actual last drain.
- Sync errors are surfaced in a visible toast with an actionable hint
("Couldn't find your ring. Take it off the charger…") instead of only a
tooltip that read "Failed". Success shows the synced summary line.
Verified in Playwright: identity renders, sync toast shows the real reason.
… fixes
- estimate active kcal per detected session; show it in the session popover
- cache build_summary (invalidate on db/profile mtime); run sleep/CVA/activity
models concurrently; batch all nights through one sleep-model process
- resolve --db to an absolute path so Rust and the Python runners open the same
file (Bugbot: Python models use wrong DB)
- HRV digest %: divide the delta by the prior-night mean baseline, matching the
vitals tile (Bugbot: HRV digest percent wrong)
- recovery line keys off HRV direction (RHR-down as fallback), not the raw sign
of an HR-rise fragment (Bugbot: recovery text ignores resting HR)
- surface profile-save errors instead of closing the dialog on a 200+{error}
response (Bugbot: profile save ignores server errors)
- vivid-but-glassy hypnogram colors; dot-style toasts; activity icons
…nvert, decode notes - `oura sleep-score`: SleepNet hypnogram → durations/efficiency + calibrated contributor curves & combiner weights (tools/score_sleep.py, fit_*.py) - score-weights doc + data-recovery-map: the app (not cloud) is the analytics tier - run_stress_model.py runner - Ring Runner: persist horizontal/vertical invert toggles across restarts - real_steps decode: byte-layout notes confirmed vs the native parser
- route API on the path only, stripping any query string, so /api/summary?cb=… no longer 404s (Bugbot: API path ignores query string) - guard the vitals trend against a zero baseline → no Inf/NaN in the JSON (Bugbot: vitals delta divides by zero) - "Real steps" capability tile checks feature_1 OR feature_2, matching the event the Steps stream actually counts (Bugbot: real steps toggle wrong event) - last-sync fields come only from a real recorded sync timestamp; absent → null (UI shows "—") instead of faking freshness from the latest event's capture time (Bugbot: wrong fallback for last sync)
When the scored night can't be matched to a stored bedtime, fall back to the night's own midpoint (start_ds, end_ds) instead of indexing bts[-1] — which used the wrong night's end and raised IndexError when bts was empty (e.g. --start/--end with no bedtime_period rows). timing_features now takes end_ds.
A session crossing midnight had its bar sized from full duration_min while end was clamped to 1440, so the bar ran wider than its lane and the tooltip/popover time range didn't match the duration. Keep a true uncapped end (endTrue) for the duration/range label, and size the bar from the day-clamped (end - start).
…ror state
- score_sleep: compute the 7-day midpoint regularity from a circular mean so it's a
true circular delta — frame-invariant (independent of --tz / ds→clock phase) and
correct across the midnight wrap, where the naive arithmetic mean broke
(Bugbot: sleep timing mixes timezones)
- dashboard: on an /api/summary {error} or fetch failure, clear every panel's
loading skeleton (not just the digest) so the page reads as errored instead of
stuck mid-load (Bugbot: summary errors leave skeletons)
- cached_summary re-stats oura.db / profile.json after the (slow) build and only stores the result if they didn't change underneath it, so a sync that lands during a build can't be masked by a stale cache entry; also guards against a slower concurrent build clobbering a fresher entry (Bugbot: summary cache stale after sync) - startup line now reads "open_oura dashboard running — open http://… in your browser" instead of the arrow-y debug-looking string (Bugbot: wrong dashboard startup message)
…atest, matching %) Both the vitals tiles and the digest now read a single vital_stat: latest is the *most recent night's* value (None if that night had no sample, so an older night is never shown as current), baseline is the mean of the prior nights, and the percent change comes from one shared helper. - latest no longer slides to an older night when the newest night lacks samples (Bugbot: vitals "latest" skips empty nights) - the digest HRV % is now the exact value the tile shows, including the zero-baseline guard (no fragment), instead of d/base.max(1.0) (Bugbot: digest HRV percent mismatches tiles)
- Advanced & debugging panel: export the 16-byte ring key (copy / .key / QR) to set up another device without re-pairing, or import one (paste / .key / QR), written to --key-file (0600, lowercased, validated as 32 hex chars). New GET/POST /api/ring-key behind the existing CSRF + loopback guards. - capability toggles now reflect the *real* on-ring feature mode: each sync snapshots SetFeatureMode status to a gitignored feature_modes.json next to oura.db; the panel falls back to "events seen recently" until a mode is captured. - docs: document the advanced panel, key export/import, the feature-modes snapshot, the JSON API + CSRF/loopback guards, and the cached/parallel/batched summary.
…etrics tools/fit_scores_all.py extends the Sleep two-layer approach (weights × contributor curves) to Readiness and Activity, engineering lag-1 / trailing-mean features for the "today vs personal baseline" contributors. Recovered weights confirmed for all three (ceiling R²=0.84–0.998); end-to-end Sleep R²=0.97, with the Readiness/Activity gap isolated to baseline-relative / multi-day-load contributors that need accumulated history. docs/algorithms written up accordingly.
…ock end - security: GET /api/ring-key discloses the ring auth key, so it now requires the same-origin X-Oura-Dash header like the mutating endpoints — a cross-origin page (or DNS-rebind attempt, already blocked by the Host guard) can't read the key; only the dashboard's own page can. fetchRingKey() sends the header. - actogram: display the API's wall-clock `end` string (which already wraps past midnight) instead of formatting a numeric endTrue that produced invalid "24:xx" times; the day-clamped endMin is kept only for bar geometry.
Compute the 7-day midpoint regularity with a circular mean + circular distance on the 24h clock, like tools/score_sleep.py, so a schedule that straddles a wrap isn't scored as wildly irregular. Equivalent to the previous arithmetic mean on normal (contiguous) bedtimes, so the calibration is unchanged.
…n them) A successful /api/feature toggle now writes the new mode (0 off / 1 automatic) into feature_modes.json right away, and the summary cache keys on that file's mtime too — so a reload shows the new capability state instead of the pre-toggle one held until the next sync re-snapshots. (Bugbot: capability toggles stale until sync)
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_c4e42015-47f0-4a90-ad13-63b32116273f) |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_c38797d5-9329-4d31-b5b5-ca9acf6e4abc) |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1158582b95
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if markers.len() < MIN_EXPECTED_MARKERS { | ||
| merge_fallback_markers(&mut markers, llm_fallback_markers(&text, &mut warnings)); |
There was a problem hiding this comment.
When a PDF has fewer than four parsed markers and the process happens to have AGENT_BACKEND_API_KEY set, this fallback is invoked automatically during /api/blood/import*; llm_fallback_markers then sends extracted lab-result lines to https://agent-backend.thomas.md/v1. That contradicts the dashboard/blood module's local-only behavior and can leak blood results without an explicit opt-in, so the remote normalizer should be disabled unless the user explicitly requests it.
Useful? React with 👍 / 👎.
| } | ||
| } | ||
| } | ||
| beds.sort(); |
There was a problem hiding this comment.
Sort nights by wall-clock time after ring resets
The epoch code above handles ring_timestamp resets, but this sort still orders bedtime periods by raw deciseconds. After a ring reboot, newer nights start again near zero and sort before older pre-reboot nights; the later reverse and vital_stat(...).latest then treat the old high-ds night as newest, skewing the digest, latest HRV/RHR, and sleep debt. Sort bedtime periods by unix_s(start_ds) instead.
Useful? React with 👍 / 👎.
| pub fn add_homref(&mut self, variant_idx: usize) { | ||
| let v = &self.spec.variants[variant_idx]; | ||
| self.matched += 1; | ||
| self.span += 2.0 * self.spec.effective_weight(v.weight).abs(); | ||
| // dosage 0 → no contribution, not a top contributor |
There was a problem hiding this comment.
Count homozygous-reference effect alleles in scores
For gVCF/reference-block inputs, this assumes every homozygous-reference target has effect-allele dosage 0. That is only true when the effect allele is non-reference; many PGS/built-in variants can have the effect allele equal to REF, in which case a hom-ref sample should contribute dosage 2. As written, those variants are marked covered but their contribution is dropped, biasing scores for gVCF genomes.
Useful? React with 👍 / 👎.
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_7defeee0-06b8-4d4d-aff2-70891d9241a9) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c8fb184cce
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| .filter(|e| { | ||
| ds >= e.min_ds - EPOCH_RESET_SLACK_DS && ds <= e.max_ds + EPOCH_RESET_SLACK_DS | ||
| }) | ||
| .min_by_key(|e| e.max_ds - e.min_ds) |
There was a problem hiding this comment.
Choose epochs with event context after resets
Fresh evidence beyond the prior sort issue: when a post-reboot epoch overlaps raw ring_timestamp values from an older boot, this min_by_key picks one epoch using only ds, so all events at that raw decisecond—including older bedtime periods and samples—are mapped to the narrowest/newer epoch's anchor. In reset histories this shifts old nights into recent dates and feeds the wrong nights into recent-cut/vitals/sleep-debt calculations; keep the epoch assigned while walking events or pass event context into the conversion instead of resolving by raw ds alone.
Useful? React with 👍 / 👎.
|
@codex review |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_45a0bca6-d792-4757-ad55-55bcf00d7726) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 713484bdc7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
|
|
||
| // the model seam — sleep / cva / activity (Python subprocess or on-device .ptl) | ||
| let sleep_ranges: Vec<[i64; 2]> = nights.iter().map(|nt| [nt.start_ds, nt.end_ds]).collect(); |
There was a problem hiding this comment.
Pass boot epoch through SleepNet batches
For databases spanning a ring reboot, raw ring_timestamp ranges can overlap. This batch drops nt.epoch_idx and sends only [start_ds, end_ds]; the Python runner filters samples by that raw range and the results are keyed only by start_ds, so a night can be scored with samples from another boot or overwrite a same-counter night. Include an epoch/window identity in the batch and in the returned key.
Useful? React with 👍 / 👎.
|
@codex review |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_0d616815-ab1f-4fe3-bd0e-277a17a27904) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fcc46211b5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let db = db_abs.as_path(); | ||
| let demo = read_profile(db); | ||
| let store = Store::open(db).context("opening DB")?; | ||
| let events = store.decoded_events().context("reading events")?; |
There was a problem hiding this comment.
Filter dashboard events by serial
When a DB contains more than one ring serial (the schema and cursors are per-serial, so this happens after syncing a replacement/second ring into the same DB), this reads every decoded event while the device panel/next cursor come from a single most-recent device. The resulting summary can merge nights, vitals, activity, and event counts from different rings under one device; filter decoded events to the selected serial or make the summary explicitly serial-scoped.
Useful? React with 👍 / 👎.
| from epoch_time import build_epochs, make_unix_s | ||
| epochs = build_epochs([(r[0], r[3]) for r in rows]) | ||
| _unix_s = make_unix_s(epochs) |
There was a problem hiding this comment.
Carry epoch assignments into activity timestamps
For DBs spanning a ring reboot, raw ring_timestamp values overlap across boot epochs. This keeps only the epoch ranges and later maps events with make_unix_s(ds), discarding each row's actual epoch assignment; overlapping decisecond values can therefore be dated to the wrong boot/day, which corrupts activity sessions and daily activity in the dashboard. Use build_epoch_assignments and pass the per-row epoch into timestamp conversion, like the sleep runner does.
Useful? React with 👍 / 👎.
|
@codex review |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_cf96cbc5-521f-4c8b-b460-8cdc81683171) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 67e3e11b94
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| h("span", { class: "bl-imp-name" }, im.file), | ||
| h("span", { class: "bl-imp-date" }, fmtDate(im.date)), | ||
| h("span", { class: "bl-imp-meta" }, `${im.markers} markers · ${im.lab || "lab"} · ${im.status || "ok"} · ${fmtSize(im.size)}`), | ||
| ...(im.warnings && im.warnings.length ? [h("span", { class: "bl-imp-warn" }, im.warnings.join("; "))] : [])))); |
There was a problem hiding this comment.
Fix the blood panel syntax so the page can load
When /blood loads, the browser parses this script before running any of the report code, and the extra closing parenthesis here makes the entire file fail with a syntax error. That leaves the blood panel permanently blank/broken even though the Rust /api/blood/* endpoints may work.
Useful? React with 👍 / 👎.
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_d60d5839-5cc0-434c-a90e-908fe38cfeaa) |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8437dc5b11
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| .arg("--tz") | ||
| .arg(tz_offset.to_string()); | ||
| if let Some(c) = csv { | ||
| cmd.arg("--csv").arg(c); |
There was a problem hiding this comment.
Don’t forward an unsupported --csv flag
When oura sleep-score --csv ... is used, this forwards --csv to tools/score_sleep.py, but that script’s argparse does not define a --csv option, so the child exits with an unrecognized-argument error and the advertised calibration CSV option is unusable. Either add support in the script or stop exposing/forwarding this flag.
Useful? React with 👍 / 👎.
| // raw PPG bodies in time order (tag 129) | ||
| var tss: [Int64] = [], bodies: [[UInt8]] = [] | ||
| var stmt: OpaquePointer? | ||
| if sqlite3_prepare_v2(db, "SELECT ring_timestamp, body FROM events WHERE tag=129 AND body IS NOT NULL ORDER BY ring_timestamp", -1, &stmt, nil) == SQLITE_OK { |
There was a problem hiding this comment.
Order CVA PPG by epoch/insertion
After a ring reboot, ring_timestamp resets, so ordering CVA PPG rows only by ring_timestamp interleaves different boot epochs; the subsequent gap split will not catch negative jumps and can concatenate unrelated PPG bursts into one model segment, corrupting vascular age/PWV. Use insertion order plus the epoch assignment logic already used by EventStore for the other on-device models.
Useful? React with 👍 / 👎.
| if b2 is None: | ||
| continue | ||
| data.append(unpack27(b1, b2)) | ||
| tsms.append(int(_unix_s(ts) * 1000)) # unix ms |
There was a problem hiding this comment.
Restore timestamp helper for stepmotion
With STEPMOTION=1 and the stepmotion decoder present, this path reaches _unix_s, which no longer exists after the epoch-aware refactor; the exception is caught and the code silently falls back to the NaN stub, so opt-in real gait is never actually fed to the activity model. Compute tsms from unix_in_epoch/event_epochs or restore a helper with the new signature.
Useful? React with 👍 / 👎.
|
@codex review |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_9220dc35-3f26-42e1-8190-380530caa789) |
|
@codex review |
1 similar comment
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0c04d2d5bb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| #if TORCH | ||
| if base.error == nil { | ||
| let full = Core.withModels(base) | ||
| DispatchQueue.main.async { s = full } |
There was a problem hiding this comment.
Guard against stale model loads after sync reloads
In TORCH builds this assigns the slow model result unconditionally after it finishes. If a sync completes while an earlier model pass is still running, reload() sets s = nil and starts a newer load, but the older background closure can finish later and overwrite the freshly loaded DB state with a summary based on the pre-sync base. Track a load generation/token before assigning the model result so stale work is dropped.
Useful? React with 👍 / 👎.
| "SELECT ring_timestamp, tag, decoded_json, captured_unix FROM events " | ||
| "WHERE decoded_json IS NOT NULL ORDER BY ring_timestamp" | ||
| "WHERE decoded_json IS NOT NULL ORDER BY id" |
There was a problem hiding this comment.
Filter activity-model inputs to the active ring
When the same SQLite DB contains events from more than one synced ring, this query feeds all serials into the activity model even though the summary code filters non-model data to the selected device serial. The returned sessions are copied directly into the dashboard's activity array, so workouts from another ring/wearer can appear on the current device's dashboard. Pass the active serial into the runner and add an events.serial filter here.
Useful? React with 👍 / 👎.
|
|
||
| var events: [Ev] = [] | ||
| var stmt: OpaquePointer? | ||
| let sql = "SELECT ring_timestamp, tag, decoded_json, captured_unix FROM events WHERE decoded_json IS NOT NULL ORDER BY id" |
There was a problem hiding this comment.
Filter on-device model reads to the current serial
After the iOS app syncs more than one ring into the writable DB, the base summary chooses one device serial, but both on-device SleepStaging and ActivityModel call this helper and receive every serial's events. Since ring timestamps and boot epochs are per device, the TORCH models can consume another ring's IBI/MET/temp data and fold those stages or workouts into the current summary. Query only the active serial (or pass it from the base summary) instead of reading all decoded events.
Useful? React with 👍 / 👎.
Summary
A single-page, local-first health dashboard following
notes/dashboard-v2-brainstorm.md. Open it withoura dashboard→ http://127.0.0.1:8090. Everything is computed and served locally.crates/oura-cli/src/dashboard.rs): readsoura.db, computes per-night HRV / resting-HR / skin-temp, SpO2 % (Oura's R→% calibration), baselines + deltas, the Device & Data Health panel, and the digest. Serves the page +/api/summary.oura sessions: sleep hypnogram, activity, cardiovascular age. Added--jsontorun_sleep_model.pyandrun_cva_model.py.dashboard/folder: vanilla HTML/CSS/JS, no build/deps. Auto light/dark (prefers-color-scheme), skeleton loading, reduced-motion safe, one accent + one radius, system + mono type. Per-night hypnograms (DEEP/LIGHT/REM/WAKE), vitals tiles + sparklines, activity grouped by day, device panel with what's-measuring + insight status.oura dashboardsubcommand (loopback-only). Default build includes it.Axes (per the v2 vision)
Sleep & recovery · Cardiovascular · Blood oxygen · Activity · Device & data health (the new control-plane axis).
Test
Verified in Playwright: light, dark, and mobile all render correctly.
cargo build(default, no torch) clean.No model weights or personal data committed.
Note
High Risk
Large surface area: BLE ring sync and auth key handling on iOS, new HTTP dashboard APIs, and duplicated model paths (Python vs LibTorch) that must stay aligned with
build_summary.Overview
This PR turns the repo into a two-client product (web + iOS) around one JSON contract from
crates/oura-summary(build_summary), plus a localoura dashboardserver.Web: New
oura dashboardinoura-cliserves loopback HTML/JS fromdashboard/web/, computes non-model metrics in Rust, shells out to the existing Python runners for sleep/CVA/activity (with--json), and caches/api/summaryon DB/profile changes. README documents the command; related routes also wire DNA and blood explorer pages.Shared Rust: Workspace grows
oura-summary,oura-core(UniFFI),oura-ffi, andoura-dna; CLI depends on summary/DNA andureqfor public PGS fetches.oura-linkgains tracing for mobile sync.iOS (
apps/ios/OuraApp): SwiftUI “Observatory” UI reads the same summary viaoura-core;BLETransport+RingSyncdrive Rust auth/sync into a writable DB with retries and diagnostics; TORCH builds run SleepNet/CVA/activity throughTorchBridge(ports of the Python runners). xcodegen specs, xcframework build scripts, TestFlight/Xcode Cloud docs, andCLAUDE.md/docs/clients-web-and-ios.mdspell out keeping both clients in sync.Repo hygiene:
.gitignoreexpanded for local health data, genomes, iOS/libtorch artifacts, and plan drafts.Reviewed by Cursor Bugbot for commit 0c04d2d. Bugbot is set up for automated code reviews on this repo. Configure here.